@limrun/ui 0.15.1 → 0.17.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +59 -0
  2. package/dist/GLTFLoader-BKnsBFsj.js +1 -0
  3. package/dist/GLTFLoader-DW6wpvyR.mjs +1679 -0
  4. package/dist/components/duo-button-hinge.d.ts +16 -0
  5. package/dist/components/duo-controls.d.ts +21 -0
  6. package/dist/components/duo-flat-frame.d.ts +7 -0
  7. package/dist/components/duo-flat-geometry.d.ts +52 -0
  8. package/dist/components/duo-fold-motion.d.ts +27 -0
  9. package/dist/components/duo-fold-renderer.d.ts +19 -0
  10. package/dist/components/duo-fold-timing.d.ts +1 -0
  11. package/dist/components/duo-frame.d.ts +16 -0
  12. package/dist/components/duo-hardware.d.ts +20 -0
  13. package/dist/components/duo-model.d.ts +19 -0
  14. package/dist/components/duo-screen-handoff.d.ts +4 -0
  15. package/dist/components/duo-view.d.ts +4 -0
  16. package/dist/components/remote-control-framed.d.ts +2 -0
  17. package/dist/components/remote-control.d.ts +24 -1
  18. package/dist/core/duo.d.ts +63 -0
  19. package/dist/duo-fold-renderer-BXejNHcY.mjs +257 -0
  20. package/dist/duo-fold-renderer-DdltAnF7.js +53 -0
  21. package/dist/duo-frame-BzyYljix.mjs +26767 -0
  22. package/dist/duo-frame-DjfA4gG-.js +4059 -0
  23. package/dist/duo-view-COZg8ieG.js +1 -0
  24. package/dist/duo-view-o1yxIzxu.mjs +654 -0
  25. package/dist/frame-assets.d.ts +2 -0
  26. package/dist/index.cjs +1 -1
  27. package/dist/index.css +1 -1
  28. package/dist/index.d.ts +3 -2
  29. package/dist/index.js +25 -2495
  30. package/dist/lite.cjs +1 -0
  31. package/dist/lite.d.ts +6 -0
  32. package/dist/lite.js +13 -0
  33. package/dist/remote-control-CQjSEVDF.mjs +2673 -0
  34. package/dist/remote-control-DVRXfCR2.js +1 -0
  35. package/package.json +13 -3
  36. package/src/components/duo-button-hinge.test.tsx +138 -0
  37. package/src/components/duo-button-hinge.ts +75 -0
  38. package/src/components/duo-controls.tsx +142 -0
  39. package/src/components/duo-flat-frame.tsx +145 -0
  40. package/src/components/duo-flat-geometry.test.ts +63 -0
  41. package/src/components/duo-flat-geometry.ts +97 -0
  42. package/src/components/duo-fold-motion.test.tsx +245 -0
  43. package/src/components/duo-fold-motion.tsx +192 -0
  44. package/src/components/duo-fold-renderer.test.ts +29 -0
  45. package/src/components/duo-fold-renderer.ts +397 -0
  46. package/src/components/duo-fold-timing.test.ts +44 -0
  47. package/src/components/duo-fold-timing.ts +9 -0
  48. package/src/components/duo-frame.tsx +579 -0
  49. package/src/components/duo-hardware.test.ts +61 -0
  50. package/src/components/duo-hardware.ts +61 -0
  51. package/src/components/duo-model.test.ts +243 -0
  52. package/src/components/duo-model.ts +607 -0
  53. package/src/components/duo-screen-handoff.ts +52 -0
  54. package/src/components/duo-view.test.tsx +104 -0
  55. package/src/components/duo-view.tsx +315 -0
  56. package/src/components/remote-control-framed.tsx +14 -0
  57. package/src/components/remote-control.css +310 -0
  58. package/src/components/remote-control.tsx +259 -59
  59. package/src/core/duo.test.ts +142 -0
  60. package/src/core/duo.ts +156 -0
  61. package/src/demo.tsx +16 -1
  62. package/src/frame-assets.ts +22 -0
  63. package/src/index.ts +5 -1
  64. package/src/lite.test.ts +30 -0
  65. package/src/lite.ts +30 -0
  66. package/vite.config.ts +5 -0
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ createDisplayTouchMessage,
4
+ flatDisplayTouch,
5
+ duoPointerMode,
6
+ DuoButtonSender,
7
+ HingeSender,
8
+ panelTouch,
9
+ validHingeAngle,
10
+ } from './duo';
11
+
12
+ describe('native Duo input', () => {
13
+ it.each([
14
+ [0, 0.2, 0.3],
15
+ [1, 0.3, 0.8],
16
+ [2, 0.8, 0.7],
17
+ [3, 0.7, 0.2],
18
+ ])('maps 2D screen coordinates back to the native panel at rotation %s', (turns, x, y) => {
19
+ const point = flatDisplayTouch(0.2, 0.3, turns!);
20
+ expect(point.x).toBeCloseTo(x!);
21
+ expect(point.y).toBeCloseTo(y!);
22
+ });
23
+ it('keeps a captured drag inside the native panel after it leaves the screen', () => {
24
+ expect(flatDisplayTouch(-1, 2, 1)).toEqual({ x: 1, y: 1 });
25
+ });
26
+ it('rejects non-finite and out-of-range hinge values', () => {
27
+ for (const angle of [NaN, Infinity, -1, 181]) expect(validHingeAngle(angle)).toBe(false);
28
+ for (const angle of [0, 45.5, 90, 180]) expect(validHingeAngle(angle)).toBe(true);
29
+ });
30
+ it('encodes panel identity and top-left coordinates without video rotation', () => {
31
+ const point = panelTouch(0.3, 0.6);
32
+ const data = new DataView(createDisplayTouchMessage(0, 3, point.x, point.y));
33
+ expect(data.byteLength).toBe(12);
34
+ expect(data.getUint8(0)).toBe(19);
35
+ expect(data.getUint8(2)).toBe(3);
36
+ expect(data.getFloat32(4, true)).toBeCloseTo(0.3);
37
+ expect(data.getFloat32(8, true)).toBeCloseTo(0.4);
38
+ expect(() => createDisplayTouchMessage(0, 3, NaN, 0)).toThrow();
39
+ });
40
+ it('sends the final slider angle while coalescing intermediate positions', async () => {
41
+ let finish!: () => void;
42
+ const send = vi
43
+ .fn()
44
+ .mockImplementationOnce(
45
+ () =>
46
+ new Promise<void>((resolve) => {
47
+ finish = resolve;
48
+ }),
49
+ )
50
+ .mockResolvedValue(undefined);
51
+ const sender = new HingeSender(send, vi.fn());
52
+ sender.set(10);
53
+ sender.set(20);
54
+ sender.set(90);
55
+ sender.set(180);
56
+ expect(send.mock.calls).toEqual([[10]]);
57
+ finish();
58
+ await vi.waitFor(() => expect(send.mock.calls).toEqual([[10], [180]]));
59
+ });
60
+ it('drops pending angles after disconnect and reports failures', async () => {
61
+ const failed = vi.fn();
62
+ const sender = new HingeSender(async () => {
63
+ throw new Error('offline');
64
+ }, failed);
65
+ sender.set(120);
66
+ await vi.waitFor(() => expect(failed).toHaveBeenCalledOnce());
67
+ sender.stop();
68
+ sender.set(0);
69
+ expect(failed).toHaveBeenCalledOnce();
70
+ });
71
+ });
72
+
73
+ describe('Duo position lock', () => {
74
+ it.each([false, true])('keeps touch active while locked (Alt=%s)', (alt) => {
75
+ expect(duoPointerMode(true, true, alt)).toBe('touch');
76
+ expect(duoPointerMode(true, false, alt)).toBe('ignore');
77
+ });
78
+ it('allows deliberate orbit only after unlocking', () => {
79
+ expect(duoPointerMode(false, false, false)).toBe('orbit');
80
+ expect(duoPointerMode(false, true, false)).toBe('touch');
81
+ expect(duoPointerMode(false, true, true)).toBe('orbit');
82
+ });
83
+ });
84
+
85
+ describe('Duo hardware input', () => {
86
+ it('orders a quick release after the acknowledged down event', async () => {
87
+ let ack!: () => void;
88
+ const send = vi
89
+ .fn()
90
+ .mockImplementationOnce(
91
+ () =>
92
+ new Promise<void>((resolve) => {
93
+ ack = resolve;
94
+ }),
95
+ )
96
+ .mockResolvedValue(undefined);
97
+ const sender = new DuoButtonSender(send, vi.fn());
98
+ sender.press('side');
99
+ sender.release();
100
+ sender.release();
101
+ await vi.waitFor(() => expect(send.mock.calls).toEqual([['side', true]]));
102
+ ack();
103
+ await vi.waitFor(() =>
104
+ expect(send.mock.calls).toEqual([
105
+ ['side', true],
106
+ ['side', false],
107
+ ]),
108
+ );
109
+ });
110
+ it('still releases when a down event fails or times out', async () => {
111
+ const send = vi.fn().mockRejectedValueOnce(new Error('timeout')).mockResolvedValue(undefined);
112
+ const failed = vi.fn();
113
+ const sender = new DuoButtonSender(send, failed);
114
+ sender.press('volumeDown');
115
+ sender.press('volumeUp');
116
+ sender.release();
117
+ await vi.waitFor(() =>
118
+ expect(send.mock.calls).toEqual([
119
+ ['volumeDown', true],
120
+ ['volumeDown', false],
121
+ ]),
122
+ );
123
+ expect(failed).toHaveBeenCalledOnce();
124
+ });
125
+ it('releases a button if the browser loses the pointer release', async () => {
126
+ vi.useFakeTimers();
127
+ try {
128
+ const send = vi.fn().mockResolvedValue(undefined);
129
+ const sender = new DuoButtonSender(send, vi.fn());
130
+ sender.press('volumeUp');
131
+ await vi.advanceTimersByTimeAsync(10000);
132
+ expect(send.mock.calls).toEqual([
133
+ ['volumeUp', true],
134
+ ['volumeUp', false],
135
+ ]);
136
+ sender.release();
137
+ expect(send).toHaveBeenCalledTimes(2);
138
+ } finally {
139
+ vi.useRealTimers();
140
+ }
141
+ });
142
+ });
@@ -0,0 +1,156 @@
1
+ /** Native iPhone Duo display metadata. Pixel geometry comes from CoreSimulator. */
2
+ export interface DuoDisplay {
3
+ id: 'outer' | 'inner';
4
+ screenId: number;
5
+ trackId: string;
6
+ width: number;
7
+ height: number;
8
+ scale: number;
9
+ orientation: number;
10
+ }
11
+
12
+ export type DuoOrientation = 'portrait' | 'pud' | 'landscape-left' | 'landscape-right';
13
+
14
+ export interface DuoState {
15
+ orientation: DuoOrientation;
16
+ angleDegrees: number;
17
+ minAngleDegrees: number;
18
+ maxAngleDegrees: number;
19
+ displays: DuoDisplay[];
20
+ }
21
+
22
+ export const DUO_DIMENSIONS = { width: 164.6, height: 117.8, depth: 5.2 } as const;
23
+
24
+ /** Invert the CSS display rotation before sending native panel coordinates. */
25
+ export function flatDisplayTouch(x: number, y: number, turns: number): { x: number; y: number } {
26
+ x = Math.max(0, Math.min(1, x));
27
+ y = Math.max(0, Math.min(1, y));
28
+ if (turns === 1) return { x: y, y: 1 - x };
29
+ if (turns === 2) return { x: 1 - x, y: 1 - y };
30
+ if (turns === 3) return { x: 1 - y, y: x };
31
+ return { x, y };
32
+ }
33
+
34
+ export function validHingeAngle(angle: number): boolean {
35
+ return Number.isFinite(angle) && angle >= 0 && angle <= 180;
36
+ }
37
+
38
+ /** Mesh UVs use the native portrait panel; HID has a top-left origin. */
39
+ export function panelTouch(u: number, v: number): { x: number; y: number } {
40
+ return { x: Math.max(0, Math.min(1, u)), y: Math.max(0, Math.min(1, 1 - v)) };
41
+ }
42
+
43
+ /** Locking the view never suppresses screen input, including accidental Alt presses. */
44
+ export function duoPointerMode(
45
+ locked: boolean,
46
+ screenHit: boolean,
47
+ altKey: boolean,
48
+ ): 'touch' | 'orbit' | 'ignore' {
49
+ if (screenHit && (locked || !altKey)) return 'touch';
50
+ return locked ? 'ignore' : 'orbit';
51
+ }
52
+
53
+ export function createDisplayTouchMessage(
54
+ action: number,
55
+ screenId: number,
56
+ x: number,
57
+ y: number,
58
+ ): ArrayBuffer {
59
+ if (
60
+ !Number.isInteger(action) ||
61
+ action < 0 ||
62
+ action > 3 ||
63
+ !Number.isInteger(screenId) ||
64
+ screenId < 1 ||
65
+ screenId > 255 ||
66
+ !Number.isFinite(x) ||
67
+ !Number.isFinite(y) ||
68
+ x < 0 ||
69
+ x > 1 ||
70
+ y < 0 ||
71
+ y > 1
72
+ ) {
73
+ throw new RangeError('Invalid display touch coordinates');
74
+ }
75
+ const message = new ArrayBuffer(12);
76
+ const view = new DataView(message);
77
+ view.setUint8(0, 19);
78
+ view.setUint8(1, action);
79
+ view.setUint8(2, screenId);
80
+ view.setFloat32(4, x, true);
81
+ view.setFloat32(8, y, true);
82
+ return message;
83
+ }
84
+
85
+ /** One request in flight; intermediate slider positions are replaced by the newest position. */
86
+ export class HingeSender {
87
+ private latest: number | undefined;
88
+ private running = false;
89
+ private stopped = false;
90
+ constructor(
91
+ private send: (angle: number) => Promise<void>,
92
+ private failed: (error: unknown) => void,
93
+ ) {}
94
+
95
+ set(angle: number): void {
96
+ if (this.stopped || !validHingeAngle(angle)) return;
97
+ this.latest = angle;
98
+ void this.flush();
99
+ }
100
+
101
+ stop(): void {
102
+ this.stopped = true;
103
+ this.latest = undefined;
104
+ }
105
+
106
+ private async flush(): Promise<void> {
107
+ if (this.running || this.stopped) return;
108
+ this.running = true;
109
+ try {
110
+ while (this.latest !== undefined && !this.stopped) {
111
+ const angle = this.latest;
112
+ this.latest = undefined;
113
+ await this.send(angle);
114
+ }
115
+ } catch (error) {
116
+ this.latest = undefined;
117
+ if (!this.stopped) this.failed(error);
118
+ } finally {
119
+ this.running = false;
120
+ }
121
+ }
122
+ }
123
+
124
+ export type DuoButton = 'side' | 'volumeUp' | 'volumeDown';
125
+ export const DUO_BUTTONS: Record<DuoButton, string> = {
126
+ side: 'Sleep/Wake',
127
+ volumeUp: 'Volume Up',
128
+ volumeDown: 'Volume Down',
129
+ };
130
+
131
+ /** Keeps button edges ordered, including releases after cancellation or a failed press. */
132
+ export class DuoButtonSender {
133
+ private held?: DuoButton;
134
+ private tail = Promise.resolve();
135
+ private timeout?: ReturnType<typeof setTimeout>;
136
+ constructor(
137
+ private send: (button: DuoButton, down: boolean) => Promise<void>,
138
+ private failed: (error: unknown) => void,
139
+ ) {}
140
+ press(button: DuoButton): void {
141
+ if (this.held) return;
142
+ this.held = button;
143
+ this.enqueue(button, true);
144
+ this.timeout = setTimeout(() => this.release(), 10000);
145
+ }
146
+ release(): void {
147
+ if (!this.held) return;
148
+ clearTimeout(this.timeout);
149
+ const button = this.held;
150
+ this.held = undefined;
151
+ this.enqueue(button, false);
152
+ }
153
+ private enqueue(button: DuoButton, down: boolean): void {
154
+ this.tail = this.tail.then(() => this.send(button, down)).catch(this.failed);
155
+ }
156
+ }
package/src/demo.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { useState, useRef } from 'react';
2
2
  import { createRoot } from 'react-dom/client';
3
- import { RemoteControl, RemoteControlHandle } from './components/remote-control';
3
+ import { RemoteControl } from './components/remote-control-framed';
4
+ import type { RemoteControlHandle } from './components/remote-control';
4
5
  import { AxSnapshot } from './core/ax-tree';
5
6
 
6
7
  type InspectChoice = 'off' | 'hover-only' | 'select';
@@ -8,6 +9,7 @@ type InspectChoice = 'off' | 'hover-only' | 'select';
8
9
  // Pre-fill from query string so the developer testing the demo can deeplink
9
10
  // `?url=...&token=...&inspect=select&autoconnect=1`. Nothing is persisted.
10
11
  const initialParams = new URLSearchParams(window.location.search);
12
+ window.debugRemoteControl = initialParams.get('debug') === '1';
11
13
  const initialUrl = initialParams.get('url') || 'ws://localhost:8833/signaling';
12
14
  const initialToken = initialParams.get('token') || 'token';
13
15
  const initialPlatformParam = initialParams.get('platform');
@@ -63,6 +65,18 @@ function Demo() {
63
65
  }
64
66
  };
65
67
 
68
+ if (initialParams.get('compact') === '1') {
69
+ return (
70
+ <div style={{ position: 'fixed', inset: 0, background: '#edf0f4' }}>
71
+ <RemoteControl
72
+ url={url}
73
+ token={token}
74
+ deviceModel={initialParams.get('model') === 'iphone-duo' ? 'iphone-duo' : undefined}
75
+ />
76
+ </div>
77
+ );
78
+ }
79
+
66
80
  return (
67
81
  <>
68
82
  <div className="header">
@@ -194,6 +208,7 @@ function Demo() {
194
208
  <h3>{platform === 'ios' ? '📱 iOS with Frame' : '🤖 Android (No Frame)'}</h3>
195
209
  <div className="device-wrapper">
196
210
  <RemoteControl
211
+ deviceModel={initialParams.get('model') === 'iphone-duo' ? 'iphone-duo' : undefined}
197
212
  key={key}
198
213
  ref={remoteControlRef}
199
214
  url={url}
@@ -0,0 +1,22 @@
1
+ // Limrun's bundled device frames and boot logos. Imported only by the default
2
+ // entry so `@limrun/ui/lite` ships without ~600 KB of inlined artwork.
3
+ import type { RemoteControlAssets } from './components/remote-control';
4
+ import iphoneFrame from './assets/iphone16pro_black_bg.webp';
5
+ import iphoneFrameLandscape from './assets/iphone16pro_black_landscape_bg.webp';
6
+ import pixelFrame from './assets/pixel9_black.webp';
7
+ import pixelFrameLandscape from './assets/pixel9_black_landscape.webp';
8
+ import pixelTabletFrame from './assets/pixel_tablet_portrait.webp';
9
+ import pixelTabletFrameLandscape from './assets/pixel_tablet_landscape.webp';
10
+ import appleLogo from './assets/Apple_logo_white.svg';
11
+ import androidBootLogo from './assets/android_boot.webp';
12
+
13
+ export const defaultRemoteControlAssets: RemoteControlAssets = {
14
+ ios: { frame: iphoneFrame, frameLandscape: iphoneFrameLandscape, loadingLogo: appleLogo },
15
+ android: {
16
+ frame: pixelFrame,
17
+ frameLandscape: pixelFrameLandscape,
18
+ tabletFrame: pixelTabletFrame,
19
+ tabletFrameLandscape: pixelTabletFrameLandscape,
20
+ loadingLogo: androidBootLogo,
21
+ },
22
+ };
package/src/index.ts CHANGED
@@ -1,7 +1,11 @@
1
- export { RemoteControl } from './components/remote-control';
1
+ export { RemoteControl } from './components/remote-control-framed';
2
+ export { defaultRemoteControlAssets } from './frame-assets';
2
3
  export type {
3
4
  RemoteControlProps,
4
5
  RemoteControlHandle,
6
+ RemoteControlAssets,
7
+ RemoteControlPlatformAssets,
8
+ RemoteControlConnectionState,
5
9
  MicrophoneState,
6
10
  CameraStreamStats,
7
11
  } from './components/remote-control';
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ // Guards the reason the lite entry exists: an embed that imports it must not
6
+ // pull the bundled device artwork. Runs against dist, so build first.
7
+ const dist = join(__dirname, '..', 'dist');
8
+ const describeDist = existsSync(join(dist, 'lite.js')) ? describe : describe.skip;
9
+
10
+ describeDist('@limrun/ui/lite bundle', () => {
11
+ const chunksOf = (entry: string): string[] => {
12
+ const src = readFileSync(join(dist, entry), 'utf8');
13
+ const chunks = [...src.matchAll(/from "\.\/([^"]+)"/g)].map((m) => m[1]);
14
+ return [src, ...chunks.map((c) => readFileSync(join(dist, c), 'utf8'))];
15
+ };
16
+
17
+ test('lite entry and its chunks carry no inlined images', () => {
18
+ for (const code of chunksOf('lite.js')) {
19
+ expect(code).not.toMatch(/data:image\//);
20
+ }
21
+ });
22
+
23
+ test('default entry still bundles the device frames (positive control)', () => {
24
+ expect(chunksOf('index.js').some((code) => /data:image\/webp/.test(code))).toBe(true);
25
+ });
26
+
27
+ test('one stylesheet keeps its historical name', () => {
28
+ expect(readdirSync(dist).filter((f) => f.endsWith('.css'))).toEqual(['index.css']);
29
+ });
30
+ });
package/src/lite.ts ADDED
@@ -0,0 +1,30 @@
1
+ // Frameless entry: identical API, but no bundled device artwork. Pass `assets`
2
+ // to add frames or boot logos.
3
+ export { RemoteControl } from './components/remote-control';
4
+ export type {
5
+ RemoteControlProps,
6
+ RemoteControlHandle,
7
+ RemoteControlAssets,
8
+ RemoteControlPlatformAssets,
9
+ RemoteControlConnectionState,
10
+ MicrophoneState,
11
+ CameraStreamStats,
12
+ } from './components/remote-control';
13
+ export type { CameraCaptureInfo, CameraFacingMode, CameraRequest, CameraResult } from './core/camera';
14
+
15
+ // Accessibility / inspect-mode types and helpers. Exported so customers can
16
+ // build their own side panels, search UIs, or agent-driven inspectors on top
17
+ // of the snapshots delivered via `onAxSnapshotChange`.
18
+ export type { AxSnapshot, AxElement, AxRect, AxSelectors, AxPlatform } from './core/ax-tree';
19
+ export type { AndroidElementTreeOptions, AxStatus } from './core/ax-fetcher';
20
+ export {
21
+ axElementAtPoint,
22
+ axElementSelectorExpression,
23
+ axElementSummary,
24
+ axElementsEqual,
25
+ axSnapshotsEqual,
26
+ clampAxFrameForScreen,
27
+ normalizeAndroidTree,
28
+ normalizeIosTree,
29
+ AX_UNAVAILABLE_ERROR,
30
+ } from './core/ax-tree';
package/vite.config.ts CHANGED
@@ -11,6 +11,7 @@ export default defineConfig({
11
11
  lib: {
12
12
  entry: {
13
13
  index: resolve(__dirname, 'src/index.ts'),
14
+ lite: resolve(__dirname, 'src/lite.ts'),
14
15
  },
15
16
  name: 'LimrunUI',
16
17
  formats: ['es', 'cjs'],
@@ -19,6 +20,10 @@ export default defineConfig({
19
20
  rollupOptions: {
20
21
  external: ['react', 'react-dom', 'react/jsx-runtime'],
21
22
  output: {
23
+ // Both entries share one stylesheet; keep its historical name so
24
+ // consumers that reference dist/index.css directly keep working.
25
+ assetFileNames: (info) =>
26
+ info.names?.some((n) => n.endsWith('.css')) ? 'index.css' : '[name][extname]',
22
27
  globals: {
23
28
  react: 'React',
24
29
  'react-dom': 'ReactDOM',