entangled-worlds-orbiters-shared 2.9.0 → 2.10.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 (39) hide show
  1. package/README.md +16 -0
  2. package/dist/capabilities/errors.d.ts +79 -0
  3. package/dist/capabilities/errors.js +87 -0
  4. package/dist/capabilities/index.d.ts +9 -0
  5. package/dist/capabilities/index.js +9 -0
  6. package/dist/capabilities/registry.d.ts +106 -0
  7. package/dist/capabilities/registry.js +215 -0
  8. package/dist/capabilities/schema.d.ts +52 -0
  9. package/dist/capabilities/schema.js +139 -0
  10. package/dist/studio/autosaveSession.d.ts +56 -0
  11. package/dist/studio/autosaveSession.js +138 -0
  12. package/dist/studio/headerBroadcast.d.ts +9 -0
  13. package/dist/studio/headerBroadcast.js +37 -0
  14. package/dist/studio/history.d.ts +37 -0
  15. package/dist/studio/history.js +50 -0
  16. package/dist/studio/hostTransport.d.ts +20 -0
  17. package/dist/studio/hostTransport.js +61 -0
  18. package/dist/studio/index.d.ts +6 -0
  19. package/dist/studio/index.js +9 -0
  20. package/dist/studio/patchStore.d.ts +7 -0
  21. package/dist/studio/patchStore.js +30 -0
  22. package/dist/studio/statusChannel.d.ts +12 -0
  23. package/dist/studio/statusChannel.js +14 -0
  24. package/dist/uploads/index.d.ts +34 -0
  25. package/dist/uploads/index.js +49 -0
  26. package/package.json +20 -4
  27. package/src/capabilities/errors.ts +115 -0
  28. package/src/capabilities/index.ts +22 -0
  29. package/src/capabilities/registry.ts +307 -0
  30. package/src/capabilities/schema.ts +164 -0
  31. package/src/loaders/cubeMapLoader.js +37 -15
  32. package/src/studio/autosaveSession.ts +186 -0
  33. package/src/studio/headerBroadcast.ts +52 -0
  34. package/src/studio/history.ts +91 -0
  35. package/src/studio/hostTransport.ts +70 -0
  36. package/src/studio/index.ts +30 -0
  37. package/src/studio/patchStore.ts +38 -0
  38. package/src/studio/statusChannel.ts +29 -0
  39. package/src/uploads/index.ts +93 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Tiny JSON-Schema-subset validator (shared machinery, TypeScript).
3
+ *
4
+ * The ONE validator both registries use (supersedes the duplicated
5
+ * `studio/capabilities/schema.js` + `core/edit/capabilities/schema.ts`). Deliberately NOT a
6
+ * dependency (no zod/ajv) — the app is bundle-weight sensitive and the house style is
7
+ * lightweight guard clauses. Each capability carries an `inputSchema` literal in this subset;
8
+ * the SAME literal is what the future agent layer hands the model as a tool definition, so
9
+ * there is no second schema language to keep in sync.
10
+ *
11
+ * Supported keywords (a pragmatic subset of JSON Schema draft-07):
12
+ * type 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
13
+ * (or an array of those — value matches if it satisfies any)
14
+ * properties per-key subschemas (objects)
15
+ * required array of keys that must be present and not undefined (objects)
16
+ * items subschema applied to every array element
17
+ * enum value must be one of the listed values
18
+ * const value must deep-equal the given value
19
+ * nullable when true, null always passes regardless of type
20
+ * minimum / maximum numbers
21
+ * minLength / maxLength strings
22
+ * minItems / maxItems arrays
23
+ * pattern string regex source (tested with RegExp)
24
+ *
25
+ * A schema with no `type` accepts any value (still honouring enum/const/properties if set).
26
+ * Unknown keywords are ignored — forward-compatible, never throws on shape it doesn't know.
27
+ */
28
+
29
+ import { ValidationError, type SchemaIssue } from './errors.js'
30
+
31
+ export type JsonType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
32
+
33
+ export interface JsonSchema {
34
+ type?: JsonType | JsonType[]
35
+ properties?: Record<string, JsonSchema>
36
+ required?: string[]
37
+ items?: JsonSchema
38
+ enum?: unknown[]
39
+ const?: unknown
40
+ nullable?: boolean
41
+ minimum?: number
42
+ maximum?: number
43
+ minLength?: number
44
+ maxLength?: number
45
+ minItems?: number
46
+ maxItems?: number
47
+ pattern?: string
48
+ }
49
+
50
+ const TYPE_CHECKS: Record<JsonType, (v: unknown) => boolean> = {
51
+ object: (v) => v !== null && typeof v === 'object' && !Array.isArray(v),
52
+ array: (v) => Array.isArray(v),
53
+ string: (v) => typeof v === 'string',
54
+ number: (v) => typeof v === 'number' && Number.isFinite(v),
55
+ integer: (v) => typeof v === 'number' && Number.isInteger(v),
56
+ boolean: (v) => typeof v === 'boolean',
57
+ null: (v) => v === null,
58
+ }
59
+
60
+ function deepEqual(a: unknown, b: unknown): boolean {
61
+ if (a === b) return true
62
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') return false
63
+ const ka = Object.keys(a as object)
64
+ const kb = Object.keys(b as object)
65
+ if (ka.length !== kb.length) return false
66
+ return ka.every((k) => deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]))
67
+ }
68
+
69
+ function checkType(schema: JsonSchema, value: unknown): boolean {
70
+ if (schema.type == null) return true
71
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type]
72
+ return types.some((t) => (TYPE_CHECKS[t] ? TYPE_CHECKS[t](value) : true))
73
+ }
74
+
75
+ function describeType(value: unknown): string {
76
+ if (value === null) return 'null'
77
+ if (Array.isArray(value)) return 'array'
78
+ return typeof value
79
+ }
80
+
81
+ function validateNode(schema: JsonSchema | undefined, value: unknown, path: string, issues: SchemaIssue[]): void {
82
+ if (!schema || typeof schema !== 'object') return
83
+ const at = path || '(root)'
84
+
85
+ if (schema.nullable && value === null) return
86
+
87
+ if (!checkType(schema, value)) {
88
+ const want = Array.isArray(schema.type) ? schema.type.join('|') : schema.type
89
+ issues.push({ path: at, message: `expected type ${want}, got ${describeType(value)}` })
90
+ return // type is the gate; downstream checks would be noise
91
+ }
92
+
93
+ if ('const' in schema && !deepEqual(value, schema.const)) {
94
+ issues.push({ path: at, message: `must equal ${JSON.stringify(schema.const)}` })
95
+ }
96
+
97
+ if (Array.isArray(schema.enum) && !schema.enum.some((e) => deepEqual(e, value))) {
98
+ issues.push({ path: at, message: `must be one of ${JSON.stringify(schema.enum)}` })
99
+ }
100
+
101
+ if (typeof value === 'number') {
102
+ if (typeof schema.minimum === 'number' && value < schema.minimum) {
103
+ issues.push({ path: at, message: `must be >= ${schema.minimum}` })
104
+ }
105
+ if (typeof schema.maximum === 'number' && value > schema.maximum) {
106
+ issues.push({ path: at, message: `must be <= ${schema.maximum}` })
107
+ }
108
+ }
109
+
110
+ if (typeof value === 'string') {
111
+ if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
112
+ issues.push({ path: at, message: `must be at least ${schema.minLength} chars` })
113
+ }
114
+ if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) {
115
+ issues.push({ path: at, message: `must be at most ${schema.maxLength} chars` })
116
+ }
117
+ if (typeof schema.pattern === 'string' && !new RegExp(schema.pattern).test(value)) {
118
+ issues.push({ path: at, message: `must match /${schema.pattern}/` })
119
+ }
120
+ }
121
+
122
+ if (Array.isArray(value)) {
123
+ if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
124
+ issues.push({ path: at, message: `must have at least ${schema.minItems} items` })
125
+ }
126
+ if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
127
+ issues.push({ path: at, message: `must have at most ${schema.maxItems} items` })
128
+ }
129
+ if (schema.items) {
130
+ value.forEach((el, i) => validateNode(schema.items, el, `${path}[${i}]`, issues))
131
+ }
132
+ }
133
+
134
+ if (TYPE_CHECKS.object(value)) {
135
+ const obj = value as Record<string, unknown>
136
+ if (Array.isArray(schema.required)) {
137
+ for (const key of schema.required) {
138
+ if (obj[key] === undefined) issues.push({ path: path ? `${path}.${key}` : key, message: 'is required' })
139
+ }
140
+ }
141
+ if (schema.properties) {
142
+ for (const [key, sub] of Object.entries(schema.properties)) {
143
+ if (obj[key] !== undefined) validateNode(sub, obj[key], path ? `${path}.${key}` : key, issues)
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ /** Returns { ok, issues }. Never throws. */
150
+ export function validate(schema: JsonSchema, value: unknown): { ok: boolean; issues: SchemaIssue[] } {
151
+ const issues: SchemaIssue[] = []
152
+ validateNode(schema ?? {}, value, '', issues)
153
+ return { ok: issues.length === 0, issues }
154
+ }
155
+
156
+ /** Throws ValidationError when invalid; returns the value otherwise. Used by the registry. */
157
+ export function assertValid<T>(schema: JsonSchema, value: T, capability: string | null = null): T {
158
+ const { ok, issues } = validate(schema, value)
159
+ if (!ok) {
160
+ const summary = issues.map((i) => `${i.path}: ${i.message}`).join('; ')
161
+ throw new ValidationError(`Invalid input${capability ? ` for "${capability}"` : ''}: ${summary}`, { capability, issues })
162
+ }
163
+ return value
164
+ }
@@ -56,25 +56,47 @@ function detectExtension(faceKeys) {
56
56
  return 'ktx2'; // default assumption
57
57
  }
58
58
 
59
+ // How many faces may be in flight at once. Not six: the KTX2 worker pool three.js gives us holds
60
+ // four transcoder workers, each carrying its own copy of the basis wasm, and that same pool also
61
+ // serves body textures and GLBs. Asking for all six together spawned every worker and queued the
62
+ // other loaders behind the whole sky; measured on cold fetches it was no faster than three.
63
+ const FACES_IN_FLIGHT = 3;
64
+
59
65
  // Load KTX2 faces and create skybox mesh
60
66
  async function loadFacesKTX2(faceKeys, renderer) {
61
67
  const loader = getKTX2LoaderForRenderer(renderer);
68
+ const anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
69
+
70
+ const loadFace = async face => {
71
+ const tex = await loader.loadAsync(faceKeys[face]);
72
+ tex.flipY = false;
73
+ tex.wrapS = THREE.ClampToEdgeWrapping;
74
+ tex.wrapT = THREE.ClampToEdgeWrapping;
75
+ tex.anisotropy = anisotropy;
76
+ return { face, tex };
77
+ };
78
+
79
+ // Faces overlap instead of chaining. Awaiting each one in turn made every face wait on the
80
+ // previous face's download *and* its transcode, so the sky arrived six round trips deep.
81
+ const settled = [];
82
+ for (let i = 0; i < FACE_ORDER.length; i += FACES_IN_FLIGHT) {
83
+ const wave = FACE_ORDER.slice(i, i + FACES_IN_FLIGHT).map(face => loadFace(face));
84
+ settled.push(...await Promise.allSettled(wave));
85
+ }
86
+
87
+ // allSettled, not all: a bare Promise.all rejects on the first failure and leaks the faces that
88
+ // land after it. Settle every face first, then dispose everything that arrived before rethrowing.
62
89
  const loaded = [];
63
-
64
- for (const face of FACE_ORDER) {
65
- const url = faceKeys[face];
66
- try {
67
- const tex = await loader.loadAsync(url);
68
- tex.flipY = false;
69
- tex.wrapS = THREE.ClampToEdgeWrapping;
70
- tex.wrapT = THREE.ClampToEdgeWrapping;
71
- tex.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
72
- loaded.push({ face, tex });
73
- } catch (e) {
74
- // Dispose already loaded textures on failure
75
- loaded.forEach(f => f.tex.dispose?.());
76
- throw e;
77
- }
90
+ let failure = null;
91
+ for (const result of settled) {
92
+ if (result.status === 'fulfilled') loaded.push(result.value);
93
+ else if (!failure) failure = result.reason;
94
+ }
95
+
96
+ if (failure) {
97
+ // Dispose already loaded textures on failure
98
+ loaded.forEach(f => f.tex.dispose?.());
99
+ throw failure;
78
100
  }
79
101
 
80
102
  const size = 500;
@@ -0,0 +1,186 @@
1
+ // AutosaveSession — the single owner of a studio's autosave state slice.
2
+ //
3
+ // Owns STATE TRANSITIONS only: the debounce timer and the
4
+ // dirty/inFlight/queued/lastSavedDraftKey/pendingHost* transitions. The real I/O (building the
5
+ // draft, the API call, the host postMessage, toasts, analytics) stays in the app's host bridge,
6
+ // which drives this session through methods and never writes the slice directly. No DOM: the
7
+ // header save-status dot is rendered through the injected `setHeaderStatus` callback.
8
+ //
9
+ // Convention: a `*Session` owns a mutable state slice (no DOM); `createX(holder, deps)`.
10
+
11
+ export type AutosaveTone = 'default' | 'busy' | 'warn' | 'ok' | 'error'
12
+
13
+ export interface AutosaveState {
14
+ ready: boolean
15
+ timer: ReturnType<typeof setTimeout> | null
16
+ dirty: boolean
17
+ inFlight: boolean
18
+ queued: boolean
19
+ lastSavedDraftKey: string
20
+ pendingHostDraftKey: string
21
+ pendingHostDraftManual: boolean
22
+ }
23
+
24
+ export interface AutosaveCtrl {
25
+ autosave: AutosaveState
26
+ }
27
+
28
+ export interface AutosaveDeps {
29
+ // Persist the draft now — the debounce timer's trigger. Late-bound: it may close over a host
30
+ // bridge built after the session, since it only ever runs inside setTimeout.
31
+ flushAutosave?: () => unknown
32
+ // Render the header save-status chip ({ tone } to show, null to clear). The session never
33
+ // touches the DOM/shell directly — this is the one presentation seam.
34
+ setHeaderStatus?: (arg: { tone: AutosaveTone } | null) => void
35
+ // Return true while a save would be rejected (e.g. a server-side lock is held) — the timer
36
+ // reschedules instead of flushing until it clears.
37
+ shouldDefer?: () => boolean
38
+ }
39
+
40
+ export interface AutosaveSession {
41
+ markDirty(): void
42
+ schedule(opts?: { delayMs?: number }): void
43
+ clearTimer(): void
44
+ syncStatus(): void
45
+ setStatus(text?: string, tone?: AutosaveTone): void
46
+ arm(baselineKey: string): void
47
+ // Save round-trip (driven by the app's host bridge save). Split in two so a blocked autosave
48
+ // tick (not-ready / not-dirty / inFlight) returns before the descriptor is ever built.
49
+ tryBegin(opts: { manual: boolean }): boolean
50
+ checkDedupAndBegin(opts: { manual: boolean; currentDraftKey: string }): { proceed: boolean; result: boolean }
51
+ markSaved(draftKey: string): void
52
+ beginHostAck(opts: { manual: boolean; currentDraftKey: string }): void
53
+ settleAfterApi(opts: { manual: boolean }): void
54
+ // Host-ack is two calls so the caller can interleave its status/toast/
55
+ // analytics between the state resolution and the final settle — preserving the exact order
56
+ // of the original handler (where the transient 'Save failed' header is then re-synced).
57
+ resolveHostAck(opts: { ok: boolean }): { wasManual: boolean }
58
+ settleHostAck(): void
59
+ }
60
+
61
+ export function createAutosaveSession(ctrl: AutosaveCtrl, deps: AutosaveDeps = {}): AutosaveSession {
62
+ ctrl.autosave = {
63
+ ready: false,
64
+ timer: null,
65
+ dirty: false,
66
+ inFlight: false,
67
+ queued: false,
68
+ lastSavedDraftKey: '',
69
+ pendingHostDraftKey: '',
70
+ pendingHostDraftManual: false,
71
+ }
72
+ const state = ctrl.autosave
73
+
74
+ const api: AutosaveSession = {
75
+ setStatus(text = '', tone = 'default') {
76
+ deps.setHeaderStatus?.(text ? { tone } : null)
77
+ },
78
+
79
+ syncStatus() {
80
+ if (state.inFlight) { api.setStatus('Saving...', 'busy'); return }
81
+ if (state.dirty) { api.setStatus('Unsaved changes', 'warn'); return }
82
+ if (state.lastSavedDraftKey) { api.setStatus('All changes saved', 'ok'); return }
83
+ api.setStatus('')
84
+ },
85
+
86
+ clearTimer() {
87
+ if (!state.timer) return
88
+ clearTimeout(state.timer)
89
+ state.timer = null
90
+ },
91
+
92
+ schedule({ delayMs = 2000 } = {}) {
93
+ if (!state.ready) return
94
+ api.clearTimer()
95
+ if (!state.dirty) { api.syncStatus(); return }
96
+ api.syncStatus()
97
+ state.timer = setTimeout(() => {
98
+ state.timer = null
99
+ // Reschedule (don't flush) while the app says a save would be rejected.
100
+ if (deps.shouldDefer?.()) {
101
+ api.schedule({ delayMs: 2000 })
102
+ return
103
+ }
104
+ void deps.flushAutosave?.()
105
+ }, delayMs)
106
+ },
107
+
108
+ markDirty() {
109
+ if (!state.ready) return
110
+ state.dirty = true
111
+ if (state.inFlight) {
112
+ state.queued = true
113
+ api.syncStatus()
114
+ return
115
+ }
116
+ api.schedule()
117
+ },
118
+
119
+ arm(baselineKey: string) {
120
+ state.lastSavedDraftKey = baselineKey
121
+ state.ready = true
122
+ },
123
+
124
+ tryBegin({ manual }) {
125
+ api.clearTimer()
126
+ if (!manual && (!state.ready || !state.dirty)) return false
127
+ if (state.inFlight) {
128
+ state.queued = true
129
+ api.syncStatus()
130
+ return false
131
+ }
132
+ return true
133
+ },
134
+
135
+ checkDedupAndBegin({ manual, currentDraftKey }) {
136
+ if (!manual && currentDraftKey && currentDraftKey === state.lastSavedDraftKey) {
137
+ state.dirty = false
138
+ api.syncStatus()
139
+ return { proceed: false, result: true }
140
+ }
141
+ state.inFlight = true
142
+ api.syncStatus()
143
+ return { proceed: true, result: false }
144
+ },
145
+
146
+ markSaved(draftKey: string) {
147
+ // An edit that landed while this save was in flight is queued and NOT in what was saved —
148
+ // stay dirty so the queued reschedule writes it.
149
+ state.dirty = state.queued
150
+ state.lastSavedDraftKey = draftKey
151
+ },
152
+
153
+ beginHostAck({ manual, currentDraftKey }) {
154
+ state.pendingHostDraftKey = currentDraftKey
155
+ state.pendingHostDraftManual = manual === true
156
+ },
157
+
158
+ settleAfterApi({ manual }) {
159
+ state.inFlight = false
160
+ api.syncStatus()
161
+ if (state.queued) {
162
+ state.queued = false
163
+ api.schedule({ delayMs: manual ? 300 : 1200 })
164
+ }
165
+ },
166
+
167
+ resolveHostAck({ ok }) {
168
+ const wasManual = state.pendingHostDraftManual === true
169
+ if (ok) api.markSaved(state.pendingHostDraftKey || state.lastSavedDraftKey)
170
+ return { wasManual }
171
+ },
172
+
173
+ settleHostAck() {
174
+ state.pendingHostDraftKey = ''
175
+ state.pendingHostDraftManual = false
176
+ state.inFlight = false
177
+ api.syncStatus()
178
+ if (state.queued) {
179
+ state.queued = false
180
+ api.schedule({ delayMs: 1200 })
181
+ }
182
+ },
183
+ }
184
+
185
+ return api
186
+ }
@@ -0,0 +1,52 @@
1
+ // Header-protocol broadcast (the "header sink" seam).
2
+ //
3
+ // A studio controller drives its header imperatively (`shell.setUndoRedoState(...)`,
4
+ // `shell.setHeaderStatus(...)`, …). When a React header mounts over the stage it must show the
5
+ // SAME state, driven by the SAME controller logic — no duplicated per-step rules, one source of
6
+ // truth.
7
+ //
8
+ // `makeHeaderBroadcast` wraps the shell so every listed header setter ALSO fans out to a sink
9
+ // (read live through `getSink`, so a late registration is picked up) and retains its last value,
10
+ // so a sink that registers after the shell was driven can be replayed race-free via `replayInto`.
11
+ // Every other member of the shell passes through untouched.
12
+
13
+ type Setter = (arg: never) => void
14
+
15
+ /** A header sink implements any subset of the setters. */
16
+ export type HeaderSinkOf<K extends string> = Partial<Record<K, Setter>>
17
+
18
+ export type HeaderBroadcast<Shell extends object, K extends string> = Shell & {
19
+ /** Push the last value of every header setter into a freshly registered sink. */
20
+ replayInto(sink: HeaderSinkOf<K> | null | undefined): void
21
+ }
22
+
23
+ export function makeHeaderBroadcast<Shell extends object, K extends string>(
24
+ shell: Shell,
25
+ getSink: () => HeaderSinkOf<K> | null | undefined,
26
+ setters: readonly K[],
27
+ ): HeaderBroadcast<Shell, K> {
28
+ // One slot per setter (all single-arg, last write wins) is the full retained header model.
29
+ const lastArg = new Map<K, unknown>()
30
+ const call = (fn: unknown, self: unknown, arg: unknown) => {
31
+ if (typeof fn === 'function') fn.call(self, arg)
32
+ }
33
+
34
+ // Spread carries the shell's own members (data props and non-header methods). Shell methods
35
+ // are expected to be closures (no `this`), so copying the references is safe.
36
+ const wrapper = { ...shell } as Record<string, unknown>
37
+ for (const key of setters) {
38
+ const original = (shell as Record<string, unknown>)[key]
39
+ wrapper[key] = (arg: unknown) => {
40
+ call(original, shell, arg)
41
+ lastArg.set(key, arg)
42
+ call(getSink()?.[key], undefined, arg)
43
+ }
44
+ }
45
+
46
+ wrapper.replayInto = (sink: HeaderSinkOf<K> | null | undefined) => {
47
+ if (!sink) return
48
+ for (const [key, arg] of lastArg) call(sink[key], undefined, arg)
49
+ }
50
+
51
+ return wrapper as HeaderBroadcast<Shell, K>
52
+ }
@@ -0,0 +1,91 @@
1
+ // Snapshot-based undo/redo for a studio.
2
+ //
3
+ // The history knows nothing about what it stores: the app injects `snapshot()` (capture the
4
+ // editable state, deep-cloned) and `restore(s)` (put a snapshot back). `onChange` fires after every
5
+ // push/undo/redo/clear so the app can mark its draft dirty, rebuild derived views, and refresh the
6
+ // header's undo/redo buttons. The stacks can be supplied by the caller so an existing controller
7
+ // that already exposes `history.undo` / `history.redo` arrays keeps reading the same arrays.
8
+
9
+ export interface HistoryStacks<S> {
10
+ undo: S[]
11
+ redo: S[]
12
+ }
13
+
14
+ export type HistoryAction = 'push' | 'undo' | 'redo' | 'clear'
15
+
16
+ export interface HistoryState {
17
+ canUndo: boolean
18
+ canRedo: boolean
19
+ }
20
+
21
+ export interface HistoryDeps<S> {
22
+ /** Capture the current editable state. Must return a detached copy (deep clone). */
23
+ snapshot: () => S
24
+ /** Put a previously captured snapshot back as the current state. */
25
+ restore: (snapshot: S) => void
26
+ /** Return false to skip a push (e.g. nothing is loaded yet). */
27
+ canPush?: () => boolean
28
+ /** Oldest entries drop off beyond this many undo steps. */
29
+ maxHistory?: number
30
+ /** Reuse existing stacks instead of fresh ones. */
31
+ stacks?: HistoryStacks<S>
32
+ /** Fires after every change of the stacks, with the action that caused it. */
33
+ onChange?: (state: HistoryState, action: HistoryAction) => void
34
+ }
35
+
36
+ export interface StudioHistory<S> {
37
+ readonly stacks: HistoryStacks<S>
38
+ /** Record the current state as an undo step. Call BEFORE applying a reversible edit. */
39
+ push(): void
40
+ undo(): boolean
41
+ redo(): boolean
42
+ clear(): void
43
+ canUndo(): boolean
44
+ canRedo(): boolean
45
+ getState(): HistoryState
46
+ }
47
+
48
+ export function createHistory<S>(deps: HistoryDeps<S>): StudioHistory<S> {
49
+ const { snapshot, restore, canPush, maxHistory = 60, onChange } = deps
50
+ const stacks: HistoryStacks<S> = deps.stacks ?? { undo: [], redo: [] }
51
+
52
+ const getState = (): HistoryState => ({
53
+ canUndo: stacks.undo.length > 0,
54
+ canRedo: stacks.redo.length > 0,
55
+ })
56
+ const notify = (action: HistoryAction) => onChange?.(getState(), action)
57
+
58
+ function step(from: S[], to: S[], action: 'undo' | 'redo'): boolean {
59
+ if (!from.length) return false
60
+ to.push(snapshot())
61
+ restore(from.pop() as S)
62
+ notify(action)
63
+ return true
64
+ }
65
+
66
+ return {
67
+ stacks,
68
+ push() {
69
+ if (canPush && !canPush()) return
70
+ stacks.undo.push(snapshot())
71
+ if (stacks.undo.length > maxHistory) stacks.undo.shift()
72
+ stacks.redo.length = 0
73
+ notify('push')
74
+ },
75
+ undo: () => step(stacks.undo, stacks.redo, 'undo'),
76
+ redo: () => step(stacks.redo, stacks.undo, 'redo'),
77
+ clear() {
78
+ stacks.undo.length = 0
79
+ stacks.redo.length = 0
80
+ notify('clear')
81
+ },
82
+ canUndo: () => stacks.undo.length > 0,
83
+ canRedo: () => stacks.redo.length > 0,
84
+ getState,
85
+ }
86
+ }
87
+
88
+ /** Deep clone for JSON-shaped studio state — the usual `snapshot()` building block. */
89
+ export function cloneJson<T>(value: T): T {
90
+ return value == null ? value : (JSON.parse(JSON.stringify(value)) as T)
91
+ }
@@ -0,0 +1,70 @@
1
+ // The generic half of a studio's host bridge: who may act as the host, and how to talk to it.
2
+ //
3
+ // A studio runs embedded in an iframe of the platform dashboard (or a local dev server). Drafts
4
+ // and captured media are posted back to whichever origin claims the host role, so an arbitrary
5
+ // embedding page must never pass the trust gate, and nothing is ever broadcast to '*' except the
6
+ // initial "ready" ping (which carries no payload).
7
+ //
8
+ // The app-specific half — what a draft looks like, which API saves it, which message types exist
9
+ // — stays in each app and drives an AutosaveSession.
10
+
11
+ interface WindowLike {
12
+ location: { origin: string }
13
+ parent: WindowLike | null
14
+ postMessage(message: unknown, targetOrigin: string, transfer?: Transferable[]): void
15
+ }
16
+
17
+ const TRUSTED_DEV_HOSTNAMES = new Set(['localhost', '127.0.0.1'])
18
+ const TRUSTED_DOMAIN = 'plantasia.space'
19
+
20
+ const currentWindow = (): WindowLike | null =>
21
+ typeof window === 'undefined' ? null : (window as unknown as WindowLike)
22
+
23
+ /**
24
+ * True for the studio's own origin, a local dev host, or an https plantasia.space origin
25
+ * (apex or any subdomain).
26
+ */
27
+ export function isTrustedHostOrigin(origin: string | null | undefined, win = currentWindow()): boolean {
28
+ if (!origin || origin === 'null') return false
29
+ let url: URL
30
+ try {
31
+ url = new URL(origin)
32
+ } catch {
33
+ return false
34
+ }
35
+ if (win && url.origin === win.location.origin) return true
36
+ const hostname = url.hostname.toLowerCase()
37
+ if (TRUSTED_DEV_HOSTNAMES.has(hostname)) return true
38
+ return url.protocol === 'https:' && (hostname === TRUSTED_DOMAIN || hostname.endsWith(`.${TRUSTED_DOMAIN}`))
39
+ }
40
+
41
+ /** True when running inside a parent frame. */
42
+ export function isEmbedded(win = currentWindow()): boolean {
43
+ try {
44
+ return Boolean(win?.parent && win.parent !== win)
45
+ } catch {
46
+ return false
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Post to the parent frame. Returns false (and sends nothing) when there is no recorded host
52
+ * origin or no parent — never falls back to a '*' broadcast for payloads.
53
+ */
54
+ export function postToHost(
55
+ message: unknown,
56
+ targetOrigin: string | null | undefined,
57
+ transfer: Transferable[] = [],
58
+ win = currentWindow(),
59
+ ): boolean {
60
+ try {
61
+ if (!targetOrigin || !win) return false
62
+ if (isEmbedded(win)) {
63
+ ;(win.parent as WindowLike).postMessage(message, targetOrigin, transfer)
64
+ return true
65
+ }
66
+ } catch (err) {
67
+ console.warn('[Studio] postMessage to host failed', err)
68
+ }
69
+ return false
70
+ }
@@ -0,0 +1,30 @@
1
+ // Studio session machinery shared by the Entangled World and Orbiter studios: undo/redo, the
2
+ // autosave state machine, the header mirror (patch store + broadcast/replay), the status line
3
+ // seam, and the host-frame transport. Framework-free: no React, no DOM beyond postMessage.
4
+
5
+ export {
6
+ createHistory,
7
+ cloneJson,
8
+ type StudioHistory,
9
+ type HistoryDeps,
10
+ type HistoryStacks,
11
+ type HistoryState,
12
+ type HistoryAction,
13
+ } from './history.js'
14
+ export {
15
+ createAutosaveSession,
16
+ type AutosaveSession,
17
+ type AutosaveState,
18
+ type AutosaveCtrl,
19
+ type AutosaveDeps,
20
+ type AutosaveTone,
21
+ } from './autosaveSession.js'
22
+ export { createPatchStore, type PatchStore } from './patchStore.js'
23
+ export { makeHeaderBroadcast, type HeaderBroadcast, type HeaderSinkOf } from './headerBroadcast.js'
24
+ export {
25
+ createStatusChannel,
26
+ type StatusChannel,
27
+ type StatusSink,
28
+ type StatusShowOptions,
29
+ } from './statusChannel.js'
30
+ export { isTrustedHostOrigin, isEmbedded, postToHost } from './hostTransport.js'