@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@found-in-space/skykit",
3
- "version": "0.2.0-alpha.1",
3
+ "version": "0.2.0-dev.20260527.0",
4
4
  "description": "Slim composition and teaching layer for Found in Space packages",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,10 +23,34 @@
23
23
  "types": "./src/browser.d.ts",
24
24
  "default": "./src/browser.js"
25
25
  },
26
+ "./browser-addons": {
27
+ "types": "./src/browser-addons.d.ts",
28
+ "default": "./src/browser-addons.js"
29
+ },
30
+ "./browser-constellations": {
31
+ "types": "./src/browser-constellations.d.ts",
32
+ "default": "./src/browser-constellations.js"
33
+ },
34
+ "./browser-journey": {
35
+ "types": "./src/browser-journey.d.ts",
36
+ "default": "./src/browser-journey.js"
37
+ },
26
38
  "./embed": {
27
39
  "types": "./src/embed.d.ts",
28
40
  "default": "./src/embed.js"
29
41
  },
42
+ "./viewer": {
43
+ "types": "./src/viewer-entry.d.ts",
44
+ "default": "./src/viewer-entry.js"
45
+ },
46
+ "./data": {
47
+ "types": "./src/data.d.ts",
48
+ "default": "./src/data.js"
49
+ },
50
+ "./story": {
51
+ "types": "./src/story.d.ts",
52
+ "default": "./src/story.js"
53
+ },
30
54
  "./parallax": {
31
55
  "types": "./src/parallax.d.ts",
32
56
  "default": "./src/parallax.js"
@@ -46,7 +70,8 @@
46
70
  "README.md"
47
71
  ],
48
72
  "sideEffects": [
49
- "./src/embed.js"
73
+ "./src/embed.js",
74
+ "./src/story.js"
50
75
  ],
51
76
  "scripts": {
52
77
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -56,16 +81,17 @@
56
81
  "@found-in-space/anchored-image": "0.2.0-alpha.0",
57
82
  "@found-in-space/hr-diagram": "0.2.0-alpha.0",
58
83
  "@found-in-space/journey": "0.2.0-alpha.0",
59
- "@found-in-space/spatial": "0.2.0-alpha.0",
84
+ "@found-in-space/meta-sidecar-provider": "0.2.0-alpha.0",
85
+ "@found-in-space/spatial": "0.2.0-dev.20260527.0",
60
86
  "@found-in-space/star-octree-provider": "0.2.0-alpha.0",
61
87
  "@found-in-space/star-trees": "0.2.0-alpha.0",
62
88
  "@found-in-space/three-star-field": "0.2.0-alpha.0"
63
89
  },
64
90
  "devDependencies": {
65
- "@found-in-space/touch-os": "0.2.0-dev.1"
91
+ "@found-in-space/touch-os": "0.2.0-dev.3"
66
92
  },
67
93
  "peerDependencies": {
68
- "@found-in-space/touch-os": ">=0.2.0-dev.1 <1",
94
+ "@found-in-space/touch-os": ">=0.2.0-dev.3 <1",
69
95
  "three": "^0.170.0"
70
96
  },
71
97
  "peerDependenciesMeta": {
@@ -147,7 +147,7 @@ test('anchored image sky plugin preloads controller selection and mounts fixed-a
147
147
  });
148
148
  const viewer = await createSkykitViewer({
149
149
  renderer: createRenderer(),
150
- view: { directionIcrs: { x: 1, y: 0, z: 0 } },
150
+ view: { lookAt: { raDeg: 0, decDeg: 0 } },
151
151
  plugins: [plugin],
152
152
  });
153
153
 
@@ -161,6 +161,34 @@ test('anchored image sky plugin preloads controller selection and mounts fixed-a
161
161
  await viewer.dispose();
162
162
  });
163
163
 
164
+ test('anchored image sky plugin can mount art into a named scale band', async () => {
165
+ const catalog = await createAnchoredImageCatalog({ manifest: MANIFEST });
166
+ const requests = [];
167
+ const scaleRoot = new THREE.Group();
168
+ const plugin = createAnchoredImageSkyPlugin({
169
+ id: 'banded-art',
170
+ catalog,
171
+ controller: createManualAnchoredImageController({ selection: 'alpha' }),
172
+ loading: 'preload',
173
+ textureLoader: createTextureLoader(requests),
174
+ anchorMode: 'scale-banded',
175
+ scaleBandId: 'constellation-art',
176
+ });
177
+ const viewer = await createSkykitViewer({
178
+ renderer: createRenderer(),
179
+ roots: {
180
+ scaleBandedContentRoots: new Map([['constellation-art', scaleRoot]]),
181
+ },
182
+ view: { lookAt: { raDeg: 0, decDeg: 0 } },
183
+ plugins: [plugin],
184
+ });
185
+
186
+ assert.deepEqual(requests, ['alpha.png']);
187
+ assert.equal(scaleRoot.children.some((child) => child.name === 'banded-art'), true);
188
+
189
+ await viewer.dispose();
190
+ });
191
+
164
192
  test('anchored image sky plugin lazy-loads active controller entries and caches them', async () => {
165
193
  const catalog = await createAnchoredImageCatalog({ manifest: MANIFEST });
166
194
  const requests = [];
@@ -174,13 +202,13 @@ test('anchored image sky plugin lazy-loads active controller entries and caches
174
202
  });
175
203
  const viewer = await createSkykitViewer({
176
204
  renderer: createRenderer(),
177
- view: { directionIcrs: { x: 1, y: 0, z: 0 } },
205
+ view: { lookAt: { raDeg: 0, decDeg: 0 } },
178
206
  plugins: [plugin],
179
207
  });
180
208
  await flushPromises();
181
209
 
182
210
  assert.deepEqual(requests, ['alpha.png']);
183
- viewer.requestViewState({ directionIcrs: { x: 0, y: 1, z: 0 } }, 'test-active-change');
211
+ viewer.requestViewState({ lookAt: { raDeg: 90, decDeg: 0 } }, 'test-active-change');
184
212
  viewer.update(0);
185
213
  await flushPromises();
186
214
  assert.deepEqual(requests, ['alpha.png', 'beta.png']);
@@ -208,7 +236,7 @@ test('anchored image sky plugin fades opacity by seconds and keeps fading object
208
236
  });
209
237
  const viewer = await createSkykitViewer({
210
238
  renderer: createRenderer(),
211
- view: { directionIcrs: { x: 1, y: 0, z: 0 } },
239
+ view: { lookAt: { raDeg: 0, decDeg: 0 } },
212
240
  plugins: [plugin],
213
241
  });
214
242
  await flushPromises();
@@ -2,6 +2,10 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
  import * as THREE from 'three';
4
4
 
5
+ import {
6
+ installSkykitBrowserGlobal,
7
+ registerBrowserInstance,
8
+ } from '../browser-addons.js';
5
9
  import { createSkykitBrowser } from '../browser.js';
6
10
 
7
11
  test('createSkykitBrowser wires the starter viewer and extra plugins', async () => {
@@ -44,6 +48,20 @@ test('createSkykitBrowser wires the starter viewer and extra plugins', async ()
44
48
  assert.deepEqual(extraPartCalls, ['attach', 'start']);
45
49
  assert.match(status.textContent, /"starsLoaded": 0/);
46
50
 
51
+ const marker = new THREE.Object3D();
52
+ const markerHandle = browser.addObject(marker, {
53
+ id: 'hyades-marker',
54
+ positionPc: { x: 17.574, y: 42.316, z: 13.963 },
55
+ });
56
+ await Promise.resolve();
57
+ assert.ok(Math.abs(marker.position.x - 0.017574) < 1e-12);
58
+ assert.ok(Math.abs(marker.position.y - 0.042316) < 1e-12);
59
+ assert.ok(Math.abs(marker.position.z - 0.013963) < 1e-12);
60
+ assert.equal(browser.viewer.roots.originContentRoot.children.includes(marker), true);
61
+ markerHandle.remove();
62
+ await Promise.resolve();
63
+ assert.equal(browser.viewer.roots.originContentRoot.children.includes(marker), false);
64
+
47
65
  browser.resize();
48
66
  assert.equal(fakeWindow.addedEvents.length, 0);
49
67
 
@@ -88,6 +106,205 @@ test('createSkykitBrowser registers resize and page-lifecycle cleanup by default
88
106
  });
89
107
  });
90
108
 
109
+ test('createSkykitBrowser accepts startup lookAt and mouse look mode', async () => {
110
+ await withFakeWindow(async () => {
111
+ const browser = await createSkykitBrowser({
112
+ host: createHost(),
113
+ status: false,
114
+ renderer: createRenderer(),
115
+ provider: createProvider(),
116
+ starField: createStarField(),
117
+ autoResize: false,
118
+ autoDispose: false,
119
+ autoStart: false,
120
+ mouseMode: 'strafe',
121
+ lookAt: { targetPc: { x: 10, y: 0, z: 0 } },
122
+ });
123
+
124
+ const view = browser.viewer.getViewState();
125
+ assert.deepEqual(view.targetPc, { x: 10, y: 0, z: 0 });
126
+ assert.ok(view.orientationIcrs);
127
+ assert.equal(
128
+ browser.viewer.getSnapshot().parts.some((part) => part.id === 'mouse-look'),
129
+ true,
130
+ );
131
+ assert.equal(
132
+ browser.viewer.getSnapshot().parts.some((part) => part.id === 'sky-grab'),
133
+ false,
134
+ );
135
+
136
+ await browser.dispose();
137
+ });
138
+ });
139
+
140
+ test('createSkykitBrowser can disable mouse drag controls', async () => {
141
+ await withFakeWindow(async () => {
142
+ const browser = await createSkykitBrowser({
143
+ host: createHost(),
144
+ status: false,
145
+ renderer: createRenderer(),
146
+ provider: createProvider(),
147
+ starField: createStarField(),
148
+ autoResize: false,
149
+ autoDispose: false,
150
+ autoStart: false,
151
+ mouseMode: 'none',
152
+ });
153
+
154
+ assert.equal(
155
+ browser.viewer.getSnapshot().parts.some((part) => part.id === 'mouse-look' || part.id === 'sky-grab'),
156
+ false,
157
+ );
158
+
159
+ await browser.dispose();
160
+ });
161
+ });
162
+
163
+ test('browser.install adds plugins after startup and cleans returned teardowns', async () => {
164
+ await withFakeWindow(async () => {
165
+ const calls = [];
166
+ const browser = await createSkykitBrowser({
167
+ host: createHost(),
168
+ status: false,
169
+ renderer: createRenderer(),
170
+ provider: createProvider(),
171
+ starField: createStarField(),
172
+ autoResize: false,
173
+ autoDispose: false,
174
+ autoStart: false,
175
+ });
176
+
177
+ const uninstall = await browser.install((context) => {
178
+ context.addPart({
179
+ id: 'late-part',
180
+ attach() { calls.push('attach'); },
181
+ start() { calls.push('start'); },
182
+ });
183
+ return () => calls.push('teardown');
184
+ });
185
+
186
+ await Promise.resolve();
187
+ assert.deepEqual(calls, ['attach', 'start']);
188
+ assert.equal(browser.viewer.getSnapshot().parts.some((part) => part.id === 'late-part'), true);
189
+ uninstall();
190
+ assert.deepEqual(calls, ['attach', 'start', 'teardown']);
191
+
192
+ await browser.dispose();
193
+ });
194
+ });
195
+
196
+ test('browser journey capability transitions through navigation actions and loads instances', async () => {
197
+ await withFakeWindow(async () => {
198
+ const browser = await createSkykitBrowser({
199
+ host: createHost(),
200
+ status: false,
201
+ renderer: createRenderer(),
202
+ provider: createProvider(),
203
+ starField: createStarField(),
204
+ autoResize: false,
205
+ autoDispose: false,
206
+ autoStart: false,
207
+ });
208
+
209
+ await browser.journey.transitionTo({
210
+ lookAt: { targetPc: { x: 10, y: 0, z: 0 } },
211
+ durationSecs: 1,
212
+ });
213
+ browser.viewer.update(1);
214
+ browser.viewer.update(0);
215
+ assert.ok(browser.viewer.getViewState().orientationIcrs);
216
+ assert.equal(browser.capabilities.has('skykit:navigation'), true);
217
+
218
+ const journey = await browser.journey.load({
219
+ initial: 'home',
220
+ scenes: {
221
+ home: { view: { observerPc: { x: 0, y: 0, z: 0 } } },
222
+ away: { view: { observerPc: { x: 1, y: 2, z: 3 } } },
223
+ },
224
+ });
225
+ await journey.goTo('away');
226
+ browser.viewer.update(0);
227
+ assert.deepEqual(browser.viewer.getViewState().observerPc, { x: 1, y: 2, z: 3 });
228
+ assert.equal(journey.getSnapshot().disposed, false);
229
+ journey.dispose();
230
+ assert.equal(journey.getSnapshot().disposed, true);
231
+
232
+ await browser.dispose();
233
+ });
234
+ });
235
+
236
+ test('browser constellations capability loads manifest boundaries without art', async () => {
237
+ await withFakeWindow(async () => {
238
+ const browser = await createSkykitBrowser({
239
+ host: createHost(),
240
+ status: false,
241
+ renderer: createRenderer(),
242
+ provider: createProvider(),
243
+ starField: createStarField(),
244
+ autoResize: false,
245
+ autoDispose: false,
246
+ autoStart: false,
247
+ });
248
+
249
+ const constellations = await browser.constellations.load({
250
+ art: 'off',
251
+ manifest: {
252
+ id: 'test-skyculture',
253
+ boundaries: {
254
+ edges: ['001:002 M+ 00:00:00 +00:00:00 01:00:00 +00:00:00 AAA BBB'],
255
+ },
256
+ constellations: [],
257
+ },
258
+ });
259
+
260
+ assert.equal(constellations.getSnapshot().lineCount, 1);
261
+ assert.equal(browser.capabilities.has('skykit:browser.constellations'), true);
262
+ assert.equal(browser.viewer.roots.observerContentRoot.children.some((child) => child.name === 'constellation-boundaries'), true);
263
+ assert.equal(constellations.hide(), false);
264
+ assert.equal(constellations.show(), true);
265
+
266
+ await browser.dispose();
267
+ });
268
+ });
269
+
270
+ test('Skykit browser global resolves existing and future browsers and installs add-ons once', async () => {
271
+ await withFakeWindow(async () => {
272
+ const service = installSkykitBrowserGlobal(/** @type {typeof globalThis} */ ({}));
273
+ const host = createHost();
274
+ const browser = await createSkykitBrowser({
275
+ host,
276
+ status: false,
277
+ renderer: createRenderer(),
278
+ provider: createProvider(),
279
+ starField: createStarField(),
280
+ autoResize: false,
281
+ autoDispose: false,
282
+ autoStart: false,
283
+ });
284
+ let installs = 0;
285
+ service.registerBrowserAddon({
286
+ id: 'test-addon',
287
+ install({ browser: installedBrowser }) {
288
+ installs += 1;
289
+ assert.equal(installedBrowser, browser);
290
+ },
291
+ });
292
+
293
+ const unregister = registerBrowserInstance(service, host, browser);
294
+ assert.equal(await service.whenReady(), browser);
295
+ assert.equal(installs, 1);
296
+ service.registerBrowserAddon({
297
+ id: 'test-addon',
298
+ install() {
299
+ installs += 1;
300
+ },
301
+ });
302
+ assert.equal(installs, 1);
303
+ unregister();
304
+ await browser.dispose();
305
+ });
306
+ });
307
+
91
308
  async function withFakeWindow(callback) {
92
309
  const previousWindow = globalThis.window;
93
310
  const fakeWindow = {
@@ -0,0 +1,131 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ formatStarLabel,
6
+ loadStarLabels,
7
+ loadStarRows,
8
+ rowsFromStarCells,
9
+ streamStarRows,
10
+ } from '../data.js';
11
+
12
+ test('rowsFromStarCells converts cells to plain app rows', () => {
13
+ const rows = rowsFromStarCells([createCell()], {
14
+ observerPc: { x: 0, y: 0, z: 0 },
15
+ });
16
+
17
+ assert.equal(rows.length, 2);
18
+ assert.deepEqual(rows[0].positionPc, { x: 1, y: 2, z: 2 });
19
+ assert.equal(rows[0].distancePc, 3);
20
+ assert.equal(rows[0].apparentMagnitude, 1 + 5 * (Math.log10(3) - 1));
21
+ assert.equal(rows[0].temperatureK, 5800);
22
+ assert.deepEqual(rows[0].ref, {
23
+ datasetId: 'dataset-a',
24
+ level: 1,
25
+ mortonCode: '2',
26
+ ordinal: 0,
27
+ });
28
+ });
29
+
30
+ test('loadStarRows streams provider cells, filters visible rows, and respects maxStars', async () => {
31
+ const provider = createProvider();
32
+ const rows = await loadStarRows({
33
+ provider,
34
+ limitingMagnitude: 4,
35
+ maxStars: 1,
36
+ sortBy: 'distancePc',
37
+ });
38
+
39
+ assert.equal(provider.disposed, false);
40
+ assert.equal(provider.streamOptions.length, 1);
41
+ assert.deepEqual(provider.streamOptions[0].view.observerPc, { x: 0, y: 0, z: 0 });
42
+ assert.deepEqual(provider.streamOptions[0].attributes, ['position', 'magAbs', 'teffLog8', 'objectRef']);
43
+ assert.equal(rows.length, 1);
44
+ assert.equal(rows[0].ordinal, 0);
45
+ });
46
+
47
+ test('streamStarRows yields row batches without exposing cell deltas', async () => {
48
+ const batches = [];
49
+ for await (const rows of streamStarRows({
50
+ provider: createProvider(),
51
+ limitingMagnitude: 99,
52
+ })) {
53
+ batches.push(rows);
54
+ }
55
+
56
+ assert.equal(batches.length, 1);
57
+ assert.equal(batches[0].length, 2);
58
+ });
59
+
60
+ test('loadStarLabels formats metadata labels for rows', async () => {
61
+ const [row] = rowsFromStarCells([createCell()]);
62
+ const labels = await loadStarLabels([row], {
63
+ metaProvider: {
64
+ async getMeta(ref) {
65
+ assert.equal(ref.ordinal, 0);
66
+ return { proper_name: 'Sol', hip_id: 0 };
67
+ },
68
+ dispose() {
69
+ throw new Error('caller-owned meta providers are not disposed');
70
+ },
71
+ },
72
+ });
73
+
74
+ assert.equal(labels.length, 1);
75
+ assert.equal(labels[0].label, 'Sol');
76
+ assert.equal(formatStarLabel(null, 'Fallback'), 'Fallback');
77
+ });
78
+
79
+ function createCell() {
80
+ return {
81
+ cellKey: '1:2',
82
+ cell: { level: 1, mortonCode: '2' },
83
+ bounds: {
84
+ centerPc: { x: 0, y: 0, z: 0 },
85
+ halfSizePc: 1,
86
+ gridX: 0,
87
+ gridY: 0,
88
+ gridZ: 0,
89
+ },
90
+ count: 2,
91
+ coordinates: {
92
+ name: 'position',
93
+ frame: 'icrs',
94
+ units: ['pc', 'pc', 'pc'],
95
+ components: new Float32Array([
96
+ 1, 2, 2,
97
+ 20, 0, 0,
98
+ ]),
99
+ },
100
+ attributes: {
101
+ magAbs: new Float32Array([1, 9]),
102
+ teffLog8: new Uint8Array([255, 0]),
103
+ },
104
+ refs: [
105
+ { datasetId: 'dataset-a', level: 1, mortonCode: '2', ordinal: 0 },
106
+ { datasetId: 'dataset-a', level: 1, mortonCode: '2', ordinal: 1 },
107
+ ],
108
+ };
109
+ }
110
+
111
+ function createProvider() {
112
+ return {
113
+ disposed: false,
114
+ streamOptions: [],
115
+ async *streamCells(options) {
116
+ this.streamOptions.push(options);
117
+ yield {
118
+ type: 'stars/cells-upsert',
119
+ cells: [createCell()],
120
+ };
121
+ yield {
122
+ type: 'stars/current',
123
+ cellKeys: ['1:2'],
124
+ starCount: 2,
125
+ };
126
+ },
127
+ dispose() {
128
+ this.disposed = true;
129
+ },
130
+ };
131
+ }
@@ -185,7 +185,7 @@ test('parallax observer moves in the target-relative plane without accumulating
185
185
  const viewer = await createSkykitViewer({
186
186
  view: {
187
187
  observerPc: { x: 0, y: 0, z: 0 },
188
- targetPc: { x: 0, y: 0, z: -10 },
188
+ lookAt: { targetPc: { x: 0, y: 0, z: -10 } },
189
189
  },
190
190
  plugins: [
191
191
  createParallaxObserverPlugin({
@@ -223,7 +223,7 @@ test('parallax observer smoothing approaches the requested offset', async () =>
223
223
  const viewer = await createSkykitViewer({
224
224
  view: {
225
225
  observerPc: { x: 0, y: 0, z: 0 },
226
- targetPc: { x: 0, y: 0, z: -10 },
226
+ lookAt: { targetPc: { x: 0, y: 0, z: -10 } },
227
227
  },
228
228
  plugins: [
229
229
  createParallaxObserverPlugin({
@@ -253,7 +253,7 @@ test('parallax observer static upIcrs controls the target-relative up plane', as
253
253
  const viewer = await createSkykitViewer({
254
254
  view: {
255
255
  observerPc: { x: 0, y: 0, z: 0 },
256
- targetPc: { x: 0, y: -10, z: 0 },
256
+ lookAt: { targetPc: { x: 0, y: -10, z: 0 } },
257
257
  },
258
258
  plugins: [
259
259
  createParallaxObserverPlugin({
@@ -285,7 +285,7 @@ test('parallax observer resolveUpIcrs overrides static up and can change at runt
285
285
  const viewer = await createSkykitViewer({
286
286
  view: {
287
287
  observerPc: { x: 0, y: 0, z: 0 },
288
- targetPc: { x: 0, y: -10, z: 0 },
288
+ lookAt: { targetPc: { x: 0, y: -10, z: 0 } },
289
289
  },
290
290
  plugins: [
291
291
  createParallaxObserverPlugin({
@@ -1,6 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
  import * as THREE from 'three';
4
+ import { createRuntime } from '@found-in-space/touch-os';
4
5
 
5
6
  import {
6
7
  SKYKIT_ACTIONS,
@@ -8,6 +9,8 @@ import {
8
9
  } from '../index.js';
9
10
  import {
10
11
  createSkykitShipControlsRoot,
12
+ createSkykitSurfaceApp,
13
+ createSkykitTabletRoot,
11
14
  createTouchOsHudPlugin,
12
15
  createTouchOsPanelPlugin,
13
16
  dispatchTouchOsActionOutputs,
@@ -72,6 +75,74 @@ test('createSkykitShipControlsRoot builds reusable pseudo-key controls and statu
72
75
  );
73
76
  });
74
77
 
78
+ test('createSkykitTabletRoot builds a tablet app shell from touch apps', () => {
79
+ const app = createSkykitSurfaceApp({
80
+ id: 'app.surface',
81
+ name: 'Surface',
82
+ node: createSkykitShipControlsRoot({ id: 'surface-child', movePad: false, verticalControls: false }),
83
+ });
84
+ const root = createSkykitTabletRoot({
85
+ id: 'test-tablet',
86
+ apps: [app],
87
+ appStates: { 'app.surface': { ready: true } },
88
+ });
89
+
90
+ assert.equal(root.id, 'test-tablet');
91
+ assert.equal(root.component.kind, 'app-shell');
92
+ assert.equal(root.props.presentation.kind, 'tablet-home');
93
+ assert.equal(root.props.appHostMode, 'same-runtime');
94
+ assert.equal(root.props.homeKey, true);
95
+ assert.deepEqual(root.props.registry.list().map((manifest) => manifest.id), ['app.surface']);
96
+
97
+ const runtime = createRuntime({
98
+ root,
99
+ surface: { width: 320, height: 240 },
100
+ });
101
+ const snapshot = runtime.render();
102
+ assert.equal(snapshot.commands.some((command) => command.role === 'tablet-home-button'), true);
103
+ assert.equal(snapshot.commands.some((command) => command.role === 'tablet-home-bar'), false);
104
+ });
105
+
106
+ test('createSkykitSurfaceApp wraps display nodes and emits app events', () => {
107
+ const emitted = [];
108
+ const app = createSkykitSurfaceApp({
109
+ id: 'app.hr',
110
+ name: 'HR',
111
+ node: () => createSkykitShipControlsRoot({ id: 'hr-child', movePad: false, verticalControls: false }),
112
+ });
113
+ const instance = app.createApp({
114
+ appId: 'app.hr',
115
+ instanceId: 'app-1',
116
+ windowId: 'app-1-window',
117
+ surface: { width: 420, height: 300, pixelDensity: 1, safeArea: { top: 0, right: 0, bottom: 0, left: 0 } },
118
+ theme: { getTokens() { return {}; } },
119
+ actions: { emit(event) { emitted.push(event); } },
120
+ windows: {
121
+ setTitle() {},
122
+ requestClose() {},
123
+ requestResize() {},
124
+ openApp() {},
125
+ },
126
+ });
127
+
128
+ const root = instance.render({});
129
+ assert.equal(app.manifest.id, 'app.hr');
130
+ assert.deepEqual(app.manifest.capabilities, ['surfaces']);
131
+ assert.equal(root.component.kind, 'skykit-surface-app-frame');
132
+ assert.equal(root.props.child.id, 'hr-child');
133
+
134
+ instance.handleOutput({ type: 'action', actionId: 'app.fly', componentId: 'fly', payload: { target: 'sun' } });
135
+ assert.deepEqual(emitted, [{
136
+ type: 'app-action',
137
+ appId: 'app.hr',
138
+ instanceId: 'app-1',
139
+ windowId: 'app-1-window',
140
+ name: 'app.fly',
141
+ payload: { target: 'sun' },
142
+ componentId: 'fly',
143
+ }]);
144
+ });
145
+
75
146
  test('touch-os pointer helpers resolve screen input and surface metrics', () => {
76
147
  const target = createTarget({ width: 640, height: 360, pixelRatio: 3 });
77
148
  const metrics = resolveTouchOsSurfaceMetrics(target, { pixelDensity: 1.5 });