@limrun/ui 0.16.0 → 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 (58) hide show
  1. package/README.md +49 -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.d.ts +5 -1
  17. package/dist/core/duo.d.ts +63 -0
  18. package/dist/duo-fold-renderer-BXejNHcY.mjs +257 -0
  19. package/dist/duo-fold-renderer-DdltAnF7.js +53 -0
  20. package/dist/duo-frame-BzyYljix.mjs +26767 -0
  21. package/dist/duo-frame-DjfA4gG-.js +4059 -0
  22. package/dist/duo-view-COZg8ieG.js +1 -0
  23. package/dist/duo-view-o1yxIzxu.mjs +654 -0
  24. package/dist/index.cjs +1 -1
  25. package/dist/index.css +1 -1
  26. package/dist/index.js +2 -2
  27. package/dist/lite.cjs +1 -1
  28. package/dist/lite.js +1 -1
  29. package/dist/remote-control-CQjSEVDF.mjs +2673 -0
  30. package/dist/remote-control-DVRXfCR2.js +1 -0
  31. package/package.json +4 -2
  32. package/src/components/duo-button-hinge.test.tsx +138 -0
  33. package/src/components/duo-button-hinge.ts +75 -0
  34. package/src/components/duo-controls.tsx +142 -0
  35. package/src/components/duo-flat-frame.tsx +145 -0
  36. package/src/components/duo-flat-geometry.test.ts +63 -0
  37. package/src/components/duo-flat-geometry.ts +97 -0
  38. package/src/components/duo-fold-motion.test.tsx +245 -0
  39. package/src/components/duo-fold-motion.tsx +192 -0
  40. package/src/components/duo-fold-renderer.test.ts +29 -0
  41. package/src/components/duo-fold-renderer.ts +397 -0
  42. package/src/components/duo-fold-timing.test.ts +44 -0
  43. package/src/components/duo-fold-timing.ts +9 -0
  44. package/src/components/duo-frame.tsx +579 -0
  45. package/src/components/duo-hardware.test.ts +61 -0
  46. package/src/components/duo-hardware.ts +61 -0
  47. package/src/components/duo-model.test.ts +243 -0
  48. package/src/components/duo-model.ts +607 -0
  49. package/src/components/duo-screen-handoff.ts +52 -0
  50. package/src/components/duo-view.test.tsx +104 -0
  51. package/src/components/duo-view.tsx +315 -0
  52. package/src/components/remote-control.css +310 -0
  53. package/src/components/remote-control.tsx +150 -14
  54. package/src/core/duo.test.ts +142 -0
  55. package/src/core/duo.ts +156 -0
  56. package/src/demo.tsx +14 -0
  57. package/dist/remote-control-BRgbD7yo.mjs +0 -2516
  58. package/dist/remote-control-VISrP1zI.js +0 -1
@@ -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
@@ -9,6 +9,7 @@ type InspectChoice = 'off' | 'hover-only' | 'select';
9
9
  // Pre-fill from query string so the developer testing the demo can deeplink
10
10
  // `?url=...&token=...&inspect=select&autoconnect=1`. Nothing is persisted.
11
11
  const initialParams = new URLSearchParams(window.location.search);
12
+ window.debugRemoteControl = initialParams.get('debug') === '1';
12
13
  const initialUrl = initialParams.get('url') || 'ws://localhost:8833/signaling';
13
14
  const initialToken = initialParams.get('token') || 'token';
14
15
  const initialPlatformParam = initialParams.get('platform');
@@ -64,6 +65,18 @@ function Demo() {
64
65
  }
65
66
  };
66
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
+
67
80
  return (
68
81
  <>
69
82
  <div className="header">
@@ -195,6 +208,7 @@ function Demo() {
195
208
  <h3>{platform === 'ios' ? '📱 iOS with Frame' : '🤖 Android (No Frame)'}</h3>
196
209
  <div className="device-wrapper">
197
210
  <RemoteControl
211
+ deviceModel={initialParams.get('model') === 'iphone-duo' ? 'iphone-duo' : undefined}
198
212
  key={key}
199
213
  ref={remoteControlRef}
200
214
  url={url}