@found-in-space/skykit 0.2.0-alpha.1 → 0.2.0-dev.20260527.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 (42) hide show
  1. package/README.md +143 -6
  2. package/examples/custom-object-layer/custom-object-layer.js +1 -24
  3. package/examples/xr-free-roam/index.html +62 -4
  4. package/examples/xr-free-roam/xr-free-roam.css +249 -18
  5. package/examples/xr-free-roam/xr-free-roam.js +644 -217
  6. package/package.json +31 -5
  7. package/src/__tests__/skykit-anchored-images.test.js +32 -4
  8. package/src/__tests__/skykit-browser.test.js +217 -0
  9. package/src/__tests__/skykit-data.test.js +131 -0
  10. package/src/__tests__/skykit-parallax.test.js +4 -4
  11. package/src/__tests__/skykit-touch-os.test.js +71 -0
  12. package/src/__tests__/skykit-xr.test.js +123 -2
  13. package/src/__tests__/skykit.test.js +138 -1
  14. package/src/anchored-images.js +14 -15
  15. package/src/browser-addons.d.ts +16 -0
  16. package/src/browser-addons.js +155 -0
  17. package/src/browser-constellations.d.ts +13 -0
  18. package/src/browser-constellations.js +387 -0
  19. package/src/browser-journey.d.ts +8 -0
  20. package/src/browser-journey.js +240 -0
  21. package/src/browser.d.ts +98 -0
  22. package/src/browser.js +215 -13
  23. package/src/data.d.ts +133 -0
  24. package/src/data.js +447 -0
  25. package/src/embed.d.ts +5 -0
  26. package/src/embed.js +52 -2
  27. package/src/hr-diagram.js +23 -5
  28. package/src/index.d.ts +32 -7
  29. package/src/plugins.js +87 -43
  30. package/src/story.d.ts +57 -0
  31. package/src/story.js +396 -0
  32. package/src/three-shim.d.ts +32 -0
  33. package/src/touch-os.d.ts +70 -0
  34. package/src/touch-os.js +275 -0
  35. package/src/utils.js +96 -6
  36. package/src/viewer-entry.d.ts +10 -0
  37. package/src/viewer-entry.js +4 -0
  38. package/src/viewer.js +110 -12
  39. package/src/xr/plugins.js +224 -13
  40. package/src/xr/session.js +60 -14
  41. package/src/xr.d.ts +22 -0
  42. package/src/xr.js +1 -0
@@ -1,4 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
2
3
  import test from 'node:test';
3
4
  import * as THREE from 'three';
4
5
 
@@ -11,6 +12,7 @@ import {
11
12
  createSkykitXrObserverRig,
12
13
  createSkykitXrPickRouter,
13
14
  createSkykitXrRaySource,
15
+ createSkykitXrRayVisualPlugin,
14
16
  createSkykitXrRig,
15
17
  createSkykitXrSessionPlugin,
16
18
  createSkykitXrStarPickingPlugin,
@@ -20,6 +22,32 @@ import {
20
22
  } from '../xr.js';
21
23
  import { createSkykitActionRegistry } from '../index.js';
22
24
 
25
+ test('xr free-roam demo uses restored alpha XR regressions defaults', () => {
26
+ const source = readFileSync(new URL('../../examples/xr-free-roam/xr-free-roam.js', import.meta.url), 'utf8');
27
+
28
+ assert.match(source, /createDefaultThreeStarFieldMaterialProfile/);
29
+ assert.doesNotMatch(source, /createVrThreeStarFieldMaterialProfile/);
30
+ assert.match(source, /createSkykitXrRayVisualPlugin/);
31
+ assert.match(source, /createSurfaceShell/);
32
+ assert.match(source, /createMetaSidecarProviderService/);
33
+ assert.match(source, /deriveMetaSidecarUrlFromRenderUrl/);
34
+ assert.match(source, /metaSidecarEntryDisplayFields/);
35
+ assert.match(source, /datasetId:\s*DATASET_ID_c56103/);
36
+ assert.match(source, /attributes:\s*\[\s*'objectRef'\s*,\s*'pickMeta'\s*\]/);
37
+ assert.match(source, /selectSun:\s*'xr-demo:selected\.sun'/);
38
+ assert.match(source, /primaryActionId:\s*XR_DEMO_ACTIONS\.goSelected/);
39
+ assert.match(source, /primaryActionLabel:\s*'Fly to'/);
40
+ assert.match(source, /homeControl:\s*'button'/);
41
+ assert.match(source, /pointerType:\s*'ray'/);
42
+ assert.doesNotMatch(source, /dragThreshold/);
43
+ assert.doesNotMatch(source, /createChoiceGroup/);
44
+ assert.doesNotMatch(source, /createSlider/);
45
+ assert.doesNotMatch(source, /createToggle/);
46
+ assert.doesNotMatch(source, /createStack/);
47
+ assert.doesNotMatch(source, /waypointPrefix/);
48
+ assert.doesNotMatch(source, /panelState\.page/);
49
+ });
50
+
23
51
  test('skykit/xr rig builds multi-root hierarchy', () => {
24
52
  const camera = new THREE.PerspectiveCamera();
25
53
  const rig = createSkykitXrRig({
@@ -102,6 +130,32 @@ test('skykit/xr depth helpers compute and apply render state', () => {
102
130
  assert.deepEqual(state, { depthNear: range.depthNear, depthFar: range.depthFar });
103
131
  });
104
132
 
133
+ test('skykit/xr depth helpers include distant visible star bounds', () => {
134
+ const range = computeSkykitXrDepthRange({
135
+ observer: { x: 0, y: 0, z: 0 },
136
+ visibleBounds: {
137
+ min: { x: 62, y: 602, z: -13 },
138
+ max: { x: 64, y: 604, z: -11 },
139
+ },
140
+ observerCentricSpheres: [{ radiusNavigationUnits: 16 }],
141
+ scale: {
142
+ navigationUnits: 'pc',
143
+ metersPerNavigationUnit: 1,
144
+ worldUnitsPerNavigationUnit: 1,
145
+ },
146
+ policy: {
147
+ near: 0.03,
148
+ minFar: 100,
149
+ maxFar: 2000000,
150
+ marginFactor: 1.2,
151
+ },
152
+ });
153
+
154
+ assert.ok(range.far > 720);
155
+ assert.ok(range.telemetry.farthestVisibleBoundsDistance > 600);
156
+ assert.equal(range.telemetry.farthestObserverCentricSphereDistance, 16);
157
+ });
158
+
105
159
  test('skykit/xr session helpers use injected navigator', async () => {
106
160
  let ended = false;
107
161
  const session = {
@@ -118,7 +172,8 @@ test('skykit/xr session helpers use injected navigator', async () => {
118
172
  async isSessionSupported(mode) {
119
173
  return mode === 'immersive-vr';
120
174
  },
121
- async requestSession() {
175
+ async requestSession(_mode, init) {
176
+ this.lastInit = init;
122
177
  return session;
123
178
  },
124
179
  },
@@ -126,6 +181,7 @@ test('skykit/xr session helpers use injected navigator', async () => {
126
181
  assert.equal(await isSkykitXrModeSupported('immersive-vr', { navigator }), true);
127
182
  const handle = await enterSkykitXrSession({ navigator, mode: 'immersive-vr' });
128
183
  assert.equal(handle.presenting, true);
184
+ assert.deepEqual(navigator.xr.lastInit.optionalFeatures, ['local-floor']);
129
185
  await exitSkykitXrSession(handle);
130
186
  assert.equal(ended, true);
131
187
  });
@@ -147,15 +203,20 @@ test('skykit/xr observer rig bridges viewer state to an XR rig without camera re
147
203
 
148
204
  test('skykit/xr session plugin registers enter/exit actions and syncs snapshot state', async () => {
149
205
  let activeSession = null;
206
+ let requestedReferenceSpaceCount = 0;
150
207
  const session = {
151
208
  async requestReferenceSpace(type) {
209
+ requestedReferenceSpaceCount += 1;
152
210
  return { type };
153
211
  },
154
212
  async end() {
155
213
  this.ended = true;
214
+ activeSession = null;
215
+ renderer.xr.isPresenting = false;
156
216
  },
157
217
  addEventListener() {},
158
218
  };
219
+ let rendererReferenceSpaceType = null;
159
220
  const renderer = {
160
221
  xr: {
161
222
  enabled: false,
@@ -163,6 +224,9 @@ test('skykit/xr session plugin registers enter/exit actions and syncs snapshot s
163
224
  getSession() {
164
225
  return activeSession;
165
226
  },
227
+ setReferenceSpaceType(type) {
228
+ rendererReferenceSpaceType = type;
229
+ },
166
230
  async setSession(nextSession) {
167
231
  activeSession = nextSession;
168
232
  this.isPresenting = Boolean(nextSession);
@@ -174,7 +238,8 @@ test('skykit/xr session plugin registers enter/exit actions and syncs snapshot s
174
238
  async isSessionSupported() {
175
239
  return true;
176
240
  },
177
- async requestSession() {
241
+ async requestSession(_mode, init) {
242
+ this.lastInit = init;
178
243
  return session;
179
244
  },
180
245
  },
@@ -195,8 +260,12 @@ test('skykit/xr session plugin registers enter/exit actions and syncs snapshot s
195
260
 
196
261
  await actions.invoke('skykit:xr.enter');
197
262
  assert.equal(renderer.xr.enabled, true);
263
+ assert.equal(rendererReferenceSpaceType, 'local-floor');
264
+ assert.deepEqual(navigator.xr.lastInit.optionalFeatures, ['local-floor']);
265
+ assert.equal(requestedReferenceSpaceCount, 0);
198
266
  assert.equal(activeSession, session);
199
267
  assert.equal(plugin.getSnapshot().presenting, true);
268
+ assert.equal(plugin.getSnapshot().enterStage, 'presenting');
200
269
 
201
270
  const frame = createXrFrame({ renderer });
202
271
  part.update(frame);
@@ -240,6 +309,58 @@ test('skykit/xr navigation plugin updates viewer state from controller axes', ()
240
309
  assert.deepEqual(actions.getControlValue('skykit:ship.control.move'), { x: 0, y: 0, z: -10 });
241
310
  });
242
311
 
312
+ test('skykit/xr ray visual shows the controller ray and shortens at blockers', () => {
313
+ let part = null;
314
+ let disposedRaySource = false;
315
+ const plugin = createSkykitXrRayVisualPlugin({
316
+ raySource: {
317
+ getRay() {
318
+ return {
319
+ id: 'ray',
320
+ kind: 'target-ray',
321
+ handedness: 'right',
322
+ origin: { x: 1, y: 2, z: 3 },
323
+ direction: { x: 0, y: 0, z: -1 },
324
+ length: 10,
325
+ };
326
+ },
327
+ getSnapshot() {
328
+ return { id: 'ray-source' };
329
+ },
330
+ dispose() {
331
+ disposedRaySource = true;
332
+ },
333
+ },
334
+ blockers: [{
335
+ blockRay() {
336
+ return { blocked: true, distance: 3, hit: { componentId: 'panel' } };
337
+ },
338
+ }],
339
+ });
340
+ const context = createPluginContext({
341
+ addPart(nextPart) {
342
+ part = nextPart;
343
+ },
344
+ });
345
+ plugin.setup(context);
346
+ part.attach(context);
347
+
348
+ part.update(createXrFrame());
349
+
350
+ assert.equal(part.object3d.visible, true);
351
+ assert.equal(plugin.getSnapshot().blocked, true);
352
+ assert.equal(plugin.getSnapshot().lastLength, 3);
353
+ assert.deepEqual(
354
+ Array.from(part.object3d.children[0].geometry.getAttribute('position').array),
355
+ [1, 2, 3, 1, 2, 0],
356
+ );
357
+
358
+ part.update({ ...createXrFrame(), xr: { presenting: false } });
359
+ assert.equal(part.object3d.visible, false);
360
+ part.dispose();
361
+ assert.equal(disposedRaySource, true);
362
+ });
363
+
243
364
  test('skykit/xr star picking fires only on trigger edge and registers attribute-only demand', () => {
244
365
  const actions = createSkykitActionRegistry();
245
366
  let part = null;
@@ -77,6 +77,57 @@ function createRenderer() {
77
77
  };
78
78
  }
79
79
 
80
+ function createTextureRenderer() {
81
+ const renderer = createRenderer();
82
+ return {
83
+ ...renderer,
84
+ xr: { enabled: true },
85
+ currentTarget: null,
86
+ viewport: new THREE.Vector4(0, 0, 1, 1),
87
+ scissor: new THREE.Vector4(0, 0, 1, 1),
88
+ scissorTest: false,
89
+ getRenderTarget() {
90
+ return this.currentTarget;
91
+ },
92
+ setRenderTarget(target) {
93
+ this.currentTarget = target;
94
+ },
95
+ getViewport(target) {
96
+ return target.copy(this.viewport);
97
+ },
98
+ setViewport(value) {
99
+ if (value?.isVector4) this.viewport.copy(value);
100
+ },
101
+ getScissor(target) {
102
+ return target.copy(this.scissor);
103
+ },
104
+ setScissor(value) {
105
+ if (value?.isVector4) this.scissor.copy(value);
106
+ },
107
+ getScissorTest() {
108
+ return this.scissorTest;
109
+ },
110
+ setScissorTest(value) {
111
+ this.scissorTest = Boolean(value);
112
+ },
113
+ };
114
+ }
115
+
116
+ function createEmbeddedSurfaceSpy() {
117
+ const publishCalls = [];
118
+ const unpublishCalls = [];
119
+ return {
120
+ publishCalls,
121
+ unpublishCalls,
122
+ publish(sourceId, update) {
123
+ publishCalls.push({ sourceId, update });
124
+ },
125
+ unpublish(sourceId) {
126
+ unpublishCalls.push(sourceId);
127
+ },
128
+ };
129
+ }
130
+
80
131
  async function flushMicrotasks(count = 10) {
81
132
  for (let index = 0; index < count; index += 1) {
82
133
  await Promise.resolve();
@@ -242,6 +293,51 @@ test('viewer exposes action registry, emits action events, and resets to initial
242
293
  await viewer.dispose();
243
294
  });
244
295
 
296
+ test('viewer derives camera orientation from lookAt targets, sky coordinates, and stars', async () => {
297
+ const targetViewer = await createSkykitViewer({
298
+ renderer: createRenderer(),
299
+ view: {
300
+ observerPc: { x: 0, y: 0, z: 0 },
301
+ lookAt: { targetPc: { x: 10, y: 0, z: 0 }, positionAngleDeg: 0 },
302
+ },
303
+ });
304
+ let view = targetViewer.getViewState();
305
+ assert.deepEqual(view.targetPc, { x: 10, y: 0, z: 0 });
306
+ assertVectorApprox(localVectorFromView(view, { x: 0, y: 0, z: -1 }), { x: 1, y: 0, z: 0 });
307
+ assertVectorApprox(localVectorFromView(view, { x: 0, y: 1, z: 0 }), { x: 0, y: 0, z: 1 });
308
+ await targetViewer.dispose();
309
+
310
+ const skyViewer = await createSkykitViewer({
311
+ renderer: createRenderer(),
312
+ view: {
313
+ lookAt: { raDeg: 0, decDeg: 0, positionAngleDeg: 90 },
314
+ },
315
+ });
316
+ view = skyViewer.getViewState();
317
+ assert.equal(view.targetPc, null);
318
+ assertVectorApprox(localVectorFromView(view, { x: 0, y: 0, z: -1 }), { x: 1, y: 0, z: 0 });
319
+ assertVectorApprox(localVectorFromView(view, { x: 0, y: 1, z: 0 }), { x: 0, y: 1, z: 0 });
320
+ await skyViewer.dispose();
321
+
322
+ const starViewer = await createSkykitViewer({
323
+ renderer: createRenderer(),
324
+ view: { lookAt: { star: 'hyades' } },
325
+ resolveLookAtStar: async (star) => star === 'hyades'
326
+ ? { targetPc: { x: 4, y: 5, z: 6 } }
327
+ : null,
328
+ });
329
+ view = starViewer.getViewState();
330
+ assert.equal(view.lookAt?.star, 'hyades');
331
+ assert.deepEqual(view.targetPc, { x: 4, y: 5, z: 6 });
332
+ assert.ok(view.orientationIcrs);
333
+
334
+ starViewer.requestViewState({ lookAt: { star: 'orion' } }, 'test-star-look');
335
+ await new Promise((resolve) => setTimeout(resolve, 0));
336
+ starViewer.update(0);
337
+ assert.deepEqual(starViewer.getViewState().targetPc, null);
338
+ await starViewer.dispose();
339
+ });
340
+
245
341
  test('requestViewState batches patches and observer-centric root follows translation without rotation', async () => {
246
342
  const viewer = await createSkykitViewer({
247
343
  renderer: createRenderer(),
@@ -682,6 +778,41 @@ test('HR diagram mode changes refresh shared demand and update the renderer view
682
778
  await viewer.dispose();
683
779
  });
684
780
 
781
+ test('HR diagram touch-os surfaces can be resolved lazily from panel runtimes', async () => {
782
+ const session = createFakeSession();
783
+ const provider = {
784
+ id: 'provider',
785
+ createSession() {
786
+ return session;
787
+ },
788
+ };
789
+ const source = createSkykitStarSourcePlugin({ provider });
790
+ const surfaces = createEmbeddedSurfaceSpy();
791
+ let activeSurfaces = null;
792
+ const hr = createSkykitHrDiagramPlugin({
793
+ id: 'hr',
794
+ source,
795
+ touchOs: {
796
+ sourceId: 'hr:surface',
797
+ surfaces: () => activeSurfaces,
798
+ },
799
+ });
800
+ const viewer = await createSkykitViewer({
801
+ renderer: createTextureRenderer(),
802
+ plugins: [source, hr],
803
+ });
804
+
805
+ activeSurfaces = surfaces;
806
+ viewer.frame(0.016);
807
+
808
+ assert.equal(surfaces.publishCalls.length, 1);
809
+ assert.equal(surfaces.publishCalls[0].sourceId, 'hr:surface');
810
+ assert.equal(surfaces.publishCalls[0].update.available, true);
811
+
812
+ await viewer.dispose();
813
+ assert.deepEqual(surfaces.unpublishCalls, ['hr:surface']);
814
+ });
815
+
685
816
  test('HR diagram demand strategy override can be supplied and restored at runtime', async () => {
686
817
  const sessions = [];
687
818
  const provider = {
@@ -1698,7 +1829,7 @@ test('journey plugin can route to non-orbit scenes before applying the arrival t
1698
1829
  },
1699
1830
  },
1700
1831
  local: {
1701
- view: { targetPc: { x: 1, y: 0, z: 0 } },
1832
+ view: { lookAt: { targetPc: { x: 1, y: 0, z: 0 } } },
1702
1833
  navigation: {
1703
1834
  transitionTo: {
1704
1835
  observerPc: { x: 0, y: 0, z: 0 },
@@ -2323,6 +2454,12 @@ function localVectorFromView(view, vector) {
2323
2454
  return { x: result.x, y: result.y, z: result.z };
2324
2455
  }
2325
2456
 
2457
+ function assertVectorApprox(actual, expected, epsilon = 1e-9) {
2458
+ assert.ok(Math.abs(actual.x - expected.x) < epsilon, `x ${actual.x} !== ${expected.x}`);
2459
+ assert.ok(Math.abs(actual.y - expected.y) < epsilon, `y ${actual.y} !== ${expected.y}`);
2460
+ assert.ok(Math.abs(actual.z - expected.z) < epsilon, `z ${actual.z} !== ${expected.z}`);
2461
+ }
2462
+
2326
2463
  function createPointerTarget() {
2327
2464
  const target = createEventTarget();
2328
2465
  target.clientWidth = 800;
@@ -107,16 +107,16 @@ export async function createAnchoredImageCatalog(options = {}) {
107
107
  }),
108
108
  };
109
109
  },
110
- resolveNearest(directionIcrs, nearestOptions = {}) {
111
- const matches = scoreEntries(directionIcrs, selectEntries(entries, nearestOptions.selection, catalog));
110
+ resolveNearest(lookDirection, nearestOptions = {}) {
111
+ const matches = scoreEntries(lookDirection, selectEntries(entries, nearestOptions.selection, catalog));
112
112
  if (matches.length === 0) return null;
113
113
  const maxAngleRad = optionalAngleRad(nearestOptions.maxAngleDeg);
114
114
  return matches.find((match) => maxAngleRad == null || match.viewDistanceRad <= maxAngleRad) ?? null;
115
115
  },
116
- resolveWithinAngle(directionIcrs, withinOptions) {
116
+ resolveWithinAngle(lookDirection, withinOptions) {
117
117
  const maxAngleRad = requiredAngleRad(withinOptions?.maxAngleDeg);
118
118
  if (!Number.isFinite(maxAngleRad)) return [];
119
- return scoreEntries(directionIcrs, selectEntries(entries, withinOptions?.selection, catalog))
119
+ return scoreEntries(lookDirection, selectEntries(entries, withinOptions?.selection, catalog))
120
120
  .filter((match) => match.viewDistanceRad <= maxAngleRad);
121
121
  },
122
122
  };
@@ -308,7 +308,8 @@ export function createAnchoredImageSkyPlugin(options) {
308
308
  id,
309
309
  priority: options.priority,
310
310
  object3d: root,
311
- anchorMode: fixedAtInfinity ? 'observer-centric' : 'world-space',
311
+ anchorMode: options.anchorMode ?? (fixedAtInfinity ? 'observer-centric' : 'world-space'),
312
+ scaleBandId: options.scaleBandId,
312
313
  });
313
314
  pluginContext.addPart({
314
315
  ...layer,
@@ -661,26 +662,24 @@ function targetDirectionAt(solved, x, y) {
661
662
 
662
663
  /** @param {SkykitViewState} view @returns {Vector3Like} */
663
664
  function resolveViewDirection(view) {
664
- const explicitDirection = normalizeVector3(view.directionIcrs, null);
665
- if (explicitDirection) return explicitDirection;
666
- const orientation = normalizeQuaternion(view.orientationIcrs);
667
- if (orientation) return normalizeVector3(rotateVectorByQuaternion(LOCAL_FORWARD, orientation), LOCAL_FORWARD) ?? { ...LOCAL_FORWARD };
668
665
  const targetPc = normalizeVector3(view.targetPc, null);
669
666
  const observerPc = normalizeVector3(view.observerPc, { x: 0, y: 0, z: 0 }) ?? { x: 0, y: 0, z: 0 };
670
667
  if (targetPc) {
671
668
  const direction = normalizeVector3(subtractVectors(targetPc, observerPc), null);
672
669
  if (direction) return direction;
673
670
  }
671
+ const orientation = normalizeQuaternion(view.orientationIcrs);
672
+ if (orientation) return normalizeVector3(rotateVectorByQuaternion(LOCAL_FORWARD, orientation), LOCAL_FORWARD) ?? { ...LOCAL_FORWARD };
674
673
  return { ...LOCAL_FORWARD };
675
674
  }
676
675
 
677
676
  /**
678
- * @param {Vector3Like | [number, number, number]} directionIcrs
677
+ * @param {Vector3Like | [number, number, number]} lookDirection
679
678
  * @param {AnchoredImageCatalogEntry[]} entries
680
679
  * @returns {AnchoredImageMatch[]}
681
680
  */
682
- function scoreEntries(directionIcrs, entries) {
683
- const direction = vector3FromArray(normalizeDirection(directionIcrs));
681
+ function scoreEntries(lookDirection, entries) {
682
+ const direction = vector3FromArray(normalizeDirection(lookDirection));
684
683
  if (!direction) return [];
685
684
  return entries
686
685
  .map((entry) => scoreEntry(direction, entry))
@@ -689,13 +688,13 @@ function scoreEntries(directionIcrs, entries) {
689
688
  }
690
689
 
691
690
  /**
692
- * @param {Vector3Like | [number, number, number]} directionIcrs
691
+ * @param {Vector3Like | [number, number, number]} lookDirection
693
692
  * @param {AnchoredImageCatalogEntry | null | undefined} entry
694
693
  * @returns {AnchoredImageMatch | null}
695
694
  */
696
- function scoreEntry(directionIcrs, entry) {
695
+ function scoreEntry(lookDirection, entry) {
697
696
  if (!entry) return null;
698
- const direction = vector3FromArray(normalizeDirection(directionIcrs));
697
+ const direction = vector3FromArray(normalizeDirection(lookDirection));
699
698
  if (!direction) return null;
700
699
  const angleRad = angularDistance(direction, entry.centroidIcrs);
701
700
  const viewDistanceRad = Math.max(0, angleRad - entry.boundsConeRadiusRad);
@@ -0,0 +1,16 @@
1
+ import type {
2
+ SkykitBrowser,
3
+ SkykitBrowserAddon,
4
+ SkykitBrowserGlobal,
5
+ } from './browser.js';
6
+
7
+ export declare function installSkykitBrowserGlobal(target?: typeof globalThis): SkykitBrowserGlobal;
8
+ export declare function registerBrowserAddon(
9
+ service: SkykitBrowserGlobal,
10
+ addon: SkykitBrowserAddon
11
+ ): () => void;
12
+ export declare function registerBrowserInstance(
13
+ service: SkykitBrowserGlobal,
14
+ host: unknown,
15
+ browser: SkykitBrowser
16
+ ): () => void;
@@ -0,0 +1,155 @@
1
+ const STATE = Symbol.for('found-in-space.skykit.browserAddons');
2
+ const GLOBAL_NAME = 'Skykit';
3
+
4
+ /**
5
+ * Install or upgrade the small global service used by the noob embed path.
6
+ *
7
+ * @param {typeof globalThis} [target]
8
+ * @returns {import('./browser.d.ts').SkykitBrowserGlobal}
9
+ */
10
+ export function installSkykitBrowserGlobal(target = globalThis) {
11
+ const globalTarget = /** @type {typeof globalThis & { Skykit?: Partial<import('./browser.d.ts').SkykitBrowserGlobal> & Record<PropertyKey, unknown> }} */ (target);
12
+ const service = /** @type {Partial<import('./browser.d.ts').SkykitBrowserGlobal> & Record<PropertyKey, unknown>} */ (
13
+ globalTarget[GLOBAL_NAME] && typeof globalTarget[GLOBAL_NAME] === 'object'
14
+ ? globalTarget[GLOBAL_NAME]
15
+ : {}
16
+ );
17
+ const state = getState(service);
18
+ const queuedAddons = Array.isArray(service.browserAddons)
19
+ ? service.browserAddons.splice(0)
20
+ : [];
21
+
22
+ service.browserAddons = state.addons;
23
+ service.registerBrowserAddon = (addon) => registerBrowserAddon(service, addon);
24
+ service.whenReady = (targetOrSelector) => whenReady(service, targetOrSelector);
25
+ service.getBrowsers = () => state.records.map((record) => record.browser);
26
+ globalTarget[GLOBAL_NAME] = service;
27
+
28
+ for (const addon of queuedAddons) {
29
+ registerBrowserAddon(service, addon);
30
+ }
31
+
32
+ return /** @type {import('./browser.d.ts').SkykitBrowserGlobal} */ (service);
33
+ }
34
+
35
+ /**
36
+ * @param {import('./browser.d.ts').SkykitBrowserGlobal} service
37
+ * @param {import('./browser.d.ts').SkykitBrowserAddon} addon
38
+ */
39
+ export function registerBrowserAddon(service, addon) {
40
+ if (!addon || typeof addon.install !== 'function') {
41
+ throw new TypeError('SkyKit browser add-ons must provide install(context).');
42
+ }
43
+ const state = getState(service);
44
+ if (state.addons.includes(addon)) return () => {};
45
+ state.addons.push(addon);
46
+ for (const record of state.records) {
47
+ void installAddonOnRecord(record, addon);
48
+ }
49
+ return () => {
50
+ const index = state.addons.indexOf(addon);
51
+ if (index >= 0) state.addons.splice(index, 1);
52
+ };
53
+ }
54
+
55
+ /**
56
+ * @param {import('./browser.d.ts').SkykitBrowserGlobal} service
57
+ * @param {unknown} host
58
+ * @param {import('./browser.d.ts').SkykitBrowser} browser
59
+ * @returns {() => void}
60
+ */
61
+ export function registerBrowserInstance(service, host, browser) {
62
+ const state = getState(service);
63
+ const record = {
64
+ host,
65
+ browser,
66
+ installedAddonIds: new Set(),
67
+ };
68
+ state.records.push(record);
69
+ for (const addon of state.addons) {
70
+ void installAddonOnRecord(record, addon);
71
+ }
72
+ resolveWaiters(state, record);
73
+ return () => {
74
+ const index = state.records.indexOf(record);
75
+ if (index >= 0) state.records.splice(index, 1);
76
+ };
77
+ }
78
+
79
+ /**
80
+ * @param {Partial<import('./browser.d.ts').SkykitBrowserGlobal> & Record<PropertyKey, unknown>} service
81
+ * @returns {{
82
+ * addons: import('./browser.d.ts').SkykitBrowserAddon[];
83
+ * records: Array<{ host: unknown; browser: import('./browser.d.ts').SkykitBrowser; installedAddonIds: Set<string> }>;
84
+ * waiters: Array<{ target: unknown; resolve: (browser: import('./browser.d.ts').SkykitBrowser) => void; reject: (error: unknown) => void }>;
85
+ * }}
86
+ */
87
+ function getState(service) {
88
+ if (!service[STATE]) {
89
+ Object.defineProperty(service, STATE, {
90
+ configurable: false,
91
+ enumerable: false,
92
+ value: {
93
+ addons: [],
94
+ records: [],
95
+ waiters: [],
96
+ },
97
+ });
98
+ }
99
+ return /** @type {ReturnType<typeof getState>} */ (service[STATE]);
100
+ }
101
+
102
+ /**
103
+ * @param {ReturnType<typeof getState>['records'][number]} record
104
+ * @param {import('./browser.d.ts').SkykitBrowserAddon} addon
105
+ */
106
+ async function installAddonOnRecord(record, addon) {
107
+ const addonId = addon.id ?? addon.install;
108
+ const dedupeId = typeof addonId === 'string' ? addonId : String(record.installedAddonIds.size + 1);
109
+ if (record.installedAddonIds.has(dedupeId)) return;
110
+ record.installedAddonIds.add(dedupeId);
111
+ await record.browser.install(addon);
112
+ }
113
+
114
+ /**
115
+ * @param {import('./browser.d.ts').SkykitBrowserGlobal} service
116
+ * @param {unknown} target
117
+ * @returns {Promise<import('./browser.d.ts').SkykitBrowser>}
118
+ */
119
+ function whenReady(service, target) {
120
+ const state = getState(service);
121
+ const existing = state.records.find((record) => matchesTarget(record.host, target));
122
+ if (existing) return Promise.resolve(existing.browser);
123
+ return new Promise((resolve, reject) => {
124
+ state.waiters.push({ target, resolve, reject });
125
+ });
126
+ }
127
+
128
+ /**
129
+ * @param {ReturnType<typeof getState>} state
130
+ * @param {ReturnType<typeof getState>['records'][number]} record
131
+ */
132
+ function resolveWaiters(state, record) {
133
+ for (const waiter of [...state.waiters]) {
134
+ if (!matchesTarget(record.host, waiter.target)) continue;
135
+ const index = state.waiters.indexOf(waiter);
136
+ if (index >= 0) state.waiters.splice(index, 1);
137
+ waiter.resolve(record.browser);
138
+ }
139
+ }
140
+
141
+ /** @param {unknown} host @param {unknown} target */
142
+ function matchesTarget(host, target) {
143
+ if (target == null) return true;
144
+ if (target === host) return true;
145
+ if (typeof target !== 'string') return false;
146
+ const element = /** @type {{ matches?: (selector: string) => boolean }} */ (host);
147
+ if (typeof element.matches === 'function') {
148
+ try {
149
+ if (element.matches(target)) return true;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+ return false;
155
+ }
@@ -0,0 +1,13 @@
1
+ import type {
2
+ SkykitBrowser,
3
+ SkykitBrowserConstellationsFacade,
4
+ SkykitBrowserConstellationsOptions,
5
+ } from './browser.js';
6
+
7
+ export declare function installSkykitConstellationsBrowserCapability(
8
+ context: {
9
+ browser: SkykitBrowser;
10
+ host?: unknown;
11
+ options?: SkykitBrowserConstellationsOptions;
12
+ }
13
+ ): Promise<SkykitBrowserConstellationsFacade>;