@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,145 @@
1
+ import React, { useId } from 'react';
2
+
3
+ function outline(x: number, y: number, w: number, h: number, left: number, right: number) {
4
+ return (
5
+ 'M' +
6
+ (x + left) +
7
+ ',' +
8
+ y +
9
+ 'H' +
10
+ (x + w - right) +
11
+ 'a' +
12
+ right +
13
+ ',' +
14
+ right +
15
+ ' 0 0 1 ' +
16
+ right +
17
+ ',' +
18
+ right +
19
+ 'V' +
20
+ (y + h - right) +
21
+ 'a' +
22
+ right +
23
+ ',' +
24
+ right +
25
+ ' 0 0 1 ' +
26
+ -right +
27
+ ',' +
28
+ right +
29
+ 'H' +
30
+ (x + left) +
31
+ 'a' +
32
+ left +
33
+ ',' +
34
+ left +
35
+ ' 0 0 1 ' +
36
+ -left +
37
+ ',' +
38
+ -left +
39
+ 'V' +
40
+ (y + left) +
41
+ 'a' +
42
+ left +
43
+ ',' +
44
+ left +
45
+ ' 0 0 1 ' +
46
+ left +
47
+ ',' +
48
+ -left +
49
+ 'Z'
50
+ );
51
+ }
52
+
53
+ /** Static silver rails and glass seals follow the same front silhouette as the 3D frame. */
54
+ export function DuoFlatFrame({ inner, width, height }: { inner: boolean; width: number; height: number }) {
55
+ const id = useId().replace(/:/g, '');
56
+ const left = inner ? 8.4 : 1.25;
57
+ const right = 8.4;
58
+ return (
59
+ <svg className="rc-duo-flat-frame" viewBox={'0 0 ' + width + ' ' + height} aria-hidden="true">
60
+ <defs>
61
+ <linearGradient id={id + '-silver'} x1="0" y1="0" x2="1" y2="1">
62
+ <stop offset="0" stopColor="#62676b" />
63
+ <stop offset="0.035" stopColor="#f9faf9" />
64
+ <stop offset="0.11" stopColor="#b0b4b6" />
65
+ <stop offset="0.31" stopColor="#fdfefd" />
66
+ <stop offset="0.43" stopColor="#797f84" />
67
+ <stop offset="0.51" stopColor="#e8eaea" />
68
+ <stop offset="0.7" stopColor="#979da1" />
69
+ <stop offset="0.87" stopColor="#fafbf9" />
70
+ <stop offset="1" stopColor="#5f676c" />
71
+ </linearGradient>
72
+ <linearGradient id={id + '-spine'}>
73
+ <stop stopColor="#4a5154" />
74
+ <stop offset="0.16" stopColor="#d6d9d9" />
75
+ <stop offset="0.4" stopColor="#f8f9f7" />
76
+ <stop offset="0.62" stopColor="#a2a8ab" />
77
+ <stop offset="0.83" stopColor="#e4e7e5" />
78
+ <stop offset="1" stopColor="#666e72" />
79
+ </linearGradient>
80
+ </defs>
81
+ {!inner && (
82
+ <>
83
+ <rect
84
+ x="-2.3"
85
+ y="0.7"
86
+ width="3.3"
87
+ height={height - 1.4}
88
+ rx="0.85"
89
+ fill={'url(#' + id + '-spine)'}
90
+ stroke="#8b9296"
91
+ strokeWidth="0.15"
92
+ />
93
+ <path d={'M-.6 2V' + (height - 2)} stroke="#f5f6f4" strokeWidth="0.22" />
94
+ </>
95
+ )}
96
+ <path
97
+ d={outline(0, 0, width, height, left, right)}
98
+ fill={'url(#' + id + '-silver)'}
99
+ stroke="#696f72"
100
+ strokeWidth="0.24"
101
+ />
102
+ <path
103
+ d={outline(0.48, 0.48, width - 0.96, height - 0.96, Math.max(0.5, left - 0.48), right - 0.48)}
104
+ fill="none"
105
+ stroke="#ffffff"
106
+ strokeOpacity="0.85"
107
+ strokeWidth="0.2"
108
+ />
109
+ <path
110
+ d={outline(1.12, 1.12, width - 2.24, height - 2.24, inner ? 7.15 : 0.65, 7.15)}
111
+ fill="#090b0d"
112
+ stroke="#34393c"
113
+ strokeWidth="0.2"
114
+ />
115
+ {[width - 16, inner ? 19 : 16].map((x) => (
116
+ <React.Fragment key={x}>
117
+ <path
118
+ d={'M' + x + ',0.17v0.82M' + x + ',' + (height - 1) + 'v0.82'}
119
+ stroke="#b1b2ac"
120
+ strokeWidth="1.1"
121
+ />
122
+ </React.Fragment>
123
+ ))}
124
+ {inner &&
125
+ [0, height - 0.6].map((y) => (
126
+ <rect
127
+ key={y}
128
+ x={width / 2 - 2.2}
129
+ y={y}
130
+ width="4.4"
131
+ height="0.6"
132
+ rx="0.18"
133
+ fill={'url(#' + id + '-spine)'}
134
+ stroke="#697074"
135
+ strokeWidth="0.14"
136
+ />
137
+ ))}
138
+ <path
139
+ d={'M' + (width - 0.1) + ',20h-.9m.9,' + (height - 40) + 'h-.9'}
140
+ stroke="#b1b2ac"
141
+ strokeWidth="1.05"
142
+ />
143
+ </svg>
144
+ );
145
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { flatFrameGeometry, flatHoverEdge, hardwareIconSize } from './duo-flat-geometry';
3
+
4
+ describe('2D Duo frame controls', () => {
5
+ it('shrinks glyphs with the device without shrinking click targets', () => {
6
+ const compact = flatFrameGeometry(false, 0, 320, 360);
7
+ const regular = flatFrameGeometry(false, 0, 600, 600);
8
+ const large = flatFrameGeometry(false, 0, 1000, 1000);
9
+ expect(compact.iconSize).toBe(12);
10
+ expect(regular.iconSize).toBe(20);
11
+ expect(large.iconSize).toBe(24);
12
+ expect(hardwareIconSize({ min: { x: 0, y: 0 }, max: { x: 0, y: 0 } })).toBe(12);
13
+ });
14
+ for (const inner of [false, true]) {
15
+ for (const turns of [0, 1, 2, 3]) {
16
+ it('keeps ' + (inner ? 'inner' : 'cover') + ' controls outside the glass at rotation ' + turns, () => {
17
+ for (const [width, height] of [
18
+ [320, 240],
19
+ [440, 650],
20
+ [1000, 800],
21
+ ]) {
22
+ const f = flatFrameGeometry(inner, turns, width!, height!);
23
+ expect(f.scale).toBeGreaterThan(0);
24
+ for (const g of f.guides) {
25
+ expect(g.targetX - 22).toBeGreaterThanOrEqual(0);
26
+ expect(g.targetX + 22).toBeLessThanOrEqual(width!);
27
+ expect(g.targetY - 22).toBeGreaterThanOrEqual(0);
28
+ expect(g.targetY + 22).toBeLessThanOrEqual(height!);
29
+ expect(Math.abs(g.x - g.targetX) + f.iconSize / 2).toBeLessThanOrEqual(22.000001);
30
+ expect(Math.abs(g.y - g.targetY) + f.iconSize / 2).toBeLessThanOrEqual(22.000001);
31
+ expect(g.x < f.rect.min.x || g.x > f.rect.max.x || g.y < f.rect.min.y || g.y > f.rect.max.y).toBe(
32
+ true,
33
+ );
34
+ expect(flatHoverEdge(g.x, g.y, f.rect)).toBe(g.edge);
35
+ }
36
+ const volume = f.guides.filter((g) => g.button !== 'side');
37
+ expect(
38
+ Math.hypot(volume[0]!.targetX - volume[1]!.targetX, volume[0]!.targetY - volume[1]!.targetY),
39
+ ).toBeGreaterThanOrEqual(44);
40
+ for (const g of f.guides) {
41
+ const b = f.buttons.find((b) => b.button === g.button)!;
42
+ const radians = (f.turns * Math.PI) / 2;
43
+ const x = (b.x - f.bodyWidth / 2) * f.scale;
44
+ const y = (b.y - f.bodyHeight / 2) * f.scale;
45
+ if (g.edge === 'top' || g.edge === 'bottom') {
46
+ expect(g.x).toBeCloseTo(width! / 2 + x * Math.cos(radians) - y * Math.sin(radians));
47
+ } else {
48
+ expect(g.y).toBeCloseTo(height! / 2 + x * Math.sin(radians) + y * Math.cos(radians));
49
+ }
50
+ }
51
+ }
52
+ });
53
+ }
54
+ }
55
+ it('uses the 3D body proportions and rotates the inner display from native portrait pixels', () => {
56
+ const folded = flatFrameGeometry(false, 0, 500, 700);
57
+ const unfolded = flatFrameGeometry(true, 1, 700, 500);
58
+ expect(folded.bodyWidth * 2).toBe(unfolded.bodyWidth);
59
+ expect(folded.bodyHeight).toBe(unfolded.bodyHeight);
60
+ expect(unfolded.turns).toBe(0);
61
+ expect(flatFrameGeometry(true, 0, 500, 700).turns).toBe(3);
62
+ });
63
+ });
@@ -0,0 +1,97 @@
1
+ import { DUO_DIMENSIONS, type DuoButton } from '../core/duo';
2
+
3
+ type Edge = 'top' | 'right' | 'bottom' | 'left';
4
+
5
+ /** A front projection of the 3D body's dimensions, with room for its edge controls. */
6
+ export function flatFrameGeometry(inner: boolean, displayTurns: number, width: number, height: number) {
7
+ const bodyWidth = DUO_DIMENSIONS.width / (inner ? 1 : 2);
8
+ const bodyHeight = DUO_DIMENSIONS.height;
9
+ const screenWidth = inner ? 157.5 : 77.1;
10
+ const screenHeight = inner ? 110.8 : 112.2;
11
+ const turns = (displayTurns + (inner ? 3 : 0)) % 4;
12
+ const scale = Math.max(
13
+ 0,
14
+ Math.min(
15
+ (width - 120) / (turns % 2 ? bodyHeight : bodyWidth),
16
+ (height - 120) / (turns % 2 ? bodyWidth : bodyHeight),
17
+ ),
18
+ );
19
+ const project = (x: number, y: number) => {
20
+ x = (x - bodyWidth / 2) * scale;
21
+ y = (y - bodyHeight / 2) * scale;
22
+ const p =
23
+ turns === 1 ? { x: -y, y: x }
24
+ : turns === 2 ? { x: -x, y: -y }
25
+ : turns === 3 ? { x: y, y: -x }
26
+ : { x, y };
27
+ return { x: p.x + width / 2, y: p.y + height / 2 };
28
+ };
29
+ const horizontal = (turns % 2 ? bodyHeight : bodyWidth) * scale;
30
+ const vertical = (turns % 2 ? bodyWidth : bodyHeight) * scale;
31
+ const rect = {
32
+ min: { x: (width - horizontal) / 2, y: (height - vertical) / 2 },
33
+ max: { x: (width + horizontal) / 2, y: (height + vertical) / 2 },
34
+ };
35
+ const buttons: { button: DuoButton; x: number; y: number; width: number; height: number; edge: Edge }[] = [
36
+ { button: 'volumeDown', x: bodyWidth - 37, y: -0.32, width: 9.5, height: 0.65, edge: 'top' },
37
+ { button: 'volumeUp', x: bodyWidth - 25, y: -0.32, width: 9.5, height: 0.65, edge: 'top' },
38
+ { button: 'side', x: bodyWidth + 0.35, y: bodyHeight / 2 - 21, width: 0.7, height: 13, edge: 'right' },
39
+ ];
40
+ const edges: Edge[] = ['top', 'right', 'bottom', 'left'];
41
+ const guides = buttons.map((b) => {
42
+ const edge = edges[(edges.indexOf(b.edge) + turns) % 4]!;
43
+ const p = project(b.x, b.y);
44
+ if (edge === 'top') p.y = rect.min.y - 28;
45
+ if (edge === 'bottom') p.y = rect.max.y + 28;
46
+ if (edge === 'left') p.x = rect.min.x - 28;
47
+ if (edge === 'right') p.x = rect.max.x + 28;
48
+ return { button: b.button, edge, ...p, targetX: p.x, targetY: p.y };
49
+ });
50
+ const volume = guides.filter((g) => g.button !== 'side');
51
+ const axis = turns % 2 ? 'y' : 'x';
52
+ volume.sort((a, b) => a[axis] - b[axis]);
53
+ // Keep glyphs over the metal caps; only spread their invisible click areas.
54
+ const targetAxis = turns % 2 ? 'targetY' : 'targetX';
55
+ if (volume[1]![axis] - volume[0]![axis] < 44) {
56
+ const center = (volume[0]![axis] + volume[1]![axis]) / 2;
57
+ volume[0]![targetAxis] = center - 22;
58
+ volume[1]![targetAxis] = center + 22;
59
+ }
60
+ return {
61
+ bodyWidth,
62
+ bodyHeight,
63
+ screenWidth,
64
+ screenHeight,
65
+ scale,
66
+ turns,
67
+ buttons,
68
+ guides,
69
+ rect,
70
+ iconSize: Math.min(hardwareIconSize(rect), 12 * scale),
71
+ };
72
+ }
73
+
74
+ /** Scale the glyph with the device while its surrounding click target stays 44 pixels. */
75
+ export function hardwareIconSize(rect: { min: { x: number; y: number }; max: { x: number; y: number } }) {
76
+ return Math.max(12, Math.min(24, Math.max(rect.max.x - rect.min.x, rect.max.y - rect.min.y) / 24));
77
+ }
78
+
79
+ /** The hover band joins the metal edge to its icons without covering the display. */
80
+ export function flatHoverEdge(
81
+ x: number,
82
+ y: number,
83
+ rect: ReturnType<typeof flatFrameGeometry>['rect'],
84
+ ): Edge | undefined {
85
+ const edges: [Edge, number, number, number, number][] = [
86
+ ['top', rect.min.y - y, x, rect.min.x, rect.max.x],
87
+ ['bottom', y - rect.max.y, x, rect.min.x, rect.max.x],
88
+ ['left', rect.min.x - x, y, rect.min.y, rect.max.y],
89
+ ['right', x - rect.max.x, y, rect.min.y, rect.max.y],
90
+ ];
91
+ return edges
92
+ .filter(
93
+ ([, distance, along, min, max]) =>
94
+ distance >= -7 && distance <= 52 && along >= min - 12 && along <= max + 12,
95
+ )
96
+ .sort((a, b) => Math.abs(a[1]) - Math.abs(b[1]))[0]?.[0];
97
+ }
@@ -0,0 +1,245 @@
1
+ import React, { act } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
4
+ import { DuoFoldMotion, useDuoFoldMotion } from './duo-fold-motion';
5
+ import { flatFrameGeometry } from './duo-flat-geometry';
6
+ import type { DuoOrientation } from '../core/duo';
7
+
8
+ const renderer = vi.hoisted(() => ({
9
+ start: vi.fn(),
10
+ dispose: vi.fn(),
11
+ finish: undefined as undefined | (() => void),
12
+ }));
13
+ vi.mock('./duo-fold-renderer', () => ({ renderFold: renderer.start }));
14
+ let ready: VideoFrameRequestCallback | undefined;
15
+ let reduced = false;
16
+ const video = document.createElement('video');
17
+ const host = document.createElement('div');
18
+ let root: ReturnType<typeof createRoot>;
19
+ let prepareFold: (inner: boolean) => void;
20
+ const cancel = vi.fn();
21
+ const draw = vi.fn();
22
+ const readPixels = vi.fn();
23
+ const changePreference = new EventTarget();
24
+ function View({
25
+ inner = false,
26
+ width = 600,
27
+ turns,
28
+ orientation = 'portrait',
29
+ }: {
30
+ inner?: boolean;
31
+ width?: number;
32
+ turns?: number;
33
+ orientation?: DuoOrientation;
34
+ }) {
35
+ const { motion, finish, prepare } = useDuoFoldMotion(
36
+ { current: video },
37
+ inner,
38
+ flatFrameGeometry(inner, turns ?? (inner ? 1 : 0), width, 700),
39
+ orientation,
40
+ );
41
+ prepareFold = prepare;
42
+ return motion && <DuoFoldMotion motion={motion} onFinish={finish} />;
43
+ }
44
+ beforeEach(() => {
45
+ vi.useFakeTimers();
46
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
47
+ reduced = false;
48
+ ready = undefined;
49
+ cancel.mockClear();
50
+ draw.mockClear();
51
+ readPixels.mockClear();
52
+ renderer.start.mockReset();
53
+ renderer.dispose.mockClear();
54
+ renderer.start.mockImplementation((_canvas, _motion, finish) => {
55
+ renderer.finish = finish;
56
+ return renderer.dispose;
57
+ });
58
+ vi.stubGlobal('matchMedia', () => ({
59
+ get matches() {
60
+ return reduced;
61
+ },
62
+ addEventListener: changePreference.addEventListener.bind(changePreference),
63
+ removeEventListener: changePreference.removeEventListener.bind(changePreference),
64
+ }));
65
+ Object.defineProperties(video, {
66
+ readyState: { configurable: true, value: 4 },
67
+ videoWidth: { configurable: true, value: 1398 },
68
+ videoHeight: { configurable: true, value: 2034 },
69
+ requestVideoFrameCallback: {
70
+ configurable: true,
71
+ value: (callback: VideoFrameRequestCallback) => {
72
+ ready = callback;
73
+ return 1;
74
+ },
75
+ },
76
+ cancelVideoFrameCallback: { configurable: true, value: cancel },
77
+ });
78
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
79
+ drawImage: draw,
80
+ getImageData: readPixels,
81
+ } as unknown as CanvasRenderingContext2D);
82
+ root = createRoot(host);
83
+ });
84
+ afterEach(async () => {
85
+ await act(async () => root.unmount());
86
+ expect(vi.getTimerCount()).toBe(0);
87
+ vi.useRealTimers();
88
+ vi.restoreAllMocks();
89
+ vi.unstubAllGlobals();
90
+ });
91
+ async function incoming() {
92
+ await act(async () => ready!(0, {} as VideoFrameCallbackMetadata));
93
+ }
94
+ it('loads the renderer only for a confirmed fold and disposes it when finished', async () => {
95
+ await act(async () => root.render(<View />));
96
+ expect(renderer.start).not.toHaveBeenCalled();
97
+ expect(draw).not.toHaveBeenCalled();
98
+ await act(async () => root.render(<View inner />));
99
+ expect(host.querySelector('.rc-duo-fold-hold')).not.toBeNull();
100
+ await incoming();
101
+ expect(renderer.start).toHaveBeenCalledTimes(1);
102
+ const motion = renderer.start.mock.calls[0]![1];
103
+ expect(Math.max(motion.before.width, motion.before.height)).toBeLessThanOrEqual(1024);
104
+ expect(Math.max(motion.after.width, motion.after.height)).toBeLessThanOrEqual(1024);
105
+ await act(async () => renderer.finish!());
106
+ expect(host.childElementCount).toBe(0);
107
+ expect(renderer.dispose).toHaveBeenCalledTimes(1);
108
+ });
109
+ it('cancels on resize and ignores a late frame callback', async () => {
110
+ await act(async () => root.render(<View />));
111
+ await act(async () => root.render(<View inner />));
112
+ const late = ready!;
113
+ await act(async () => root.render(<View inner width={400} />));
114
+ await act(async () => late(0, {} as VideoFrameCallbackMetadata));
115
+ expect(host.childElementCount).toBe(0);
116
+ expect(renderer.start).not.toHaveBeenCalled();
117
+ });
118
+ it('disposes an interrupted fold before starting its reverse', async () => {
119
+ await act(async () => root.render(<View />));
120
+ await act(async () => root.render(<View inner />));
121
+ await incoming();
122
+ await act(async () => root.render(<View />));
123
+ expect(renderer.dispose).toHaveBeenCalledTimes(1);
124
+ await incoming();
125
+ expect(renderer.start).toHaveBeenCalledTimes(2);
126
+ await act(async () => renderer.finish!());
127
+ expect(host.childElementCount).toBe(0);
128
+ });
129
+ it('skips GPU work with reduced motion and cancels when the preference changes', async () => {
130
+ reduced = true;
131
+ await act(async () => root.render(<View />));
132
+ await act(async () => root.render(<View inner />));
133
+ expect(draw).not.toHaveBeenCalled();
134
+ expect(renderer.start).not.toHaveBeenCalled();
135
+ reduced = false;
136
+ await act(async () => root.render(<View />));
137
+ await incoming();
138
+ reduced = true;
139
+ await act(async () => changePreference.dispatchEvent(new Event('change')));
140
+ expect(renderer.dispose).toHaveBeenCalledTimes(1);
141
+ expect(host.childElementCount).toBe(0);
142
+ });
143
+ it('starts on the first incoming frame without brightness checks or timer delays', async () => {
144
+ await act(async () => root.render(<View />));
145
+ await act(async () => root.render(<View inner />));
146
+ expect(renderer.start).not.toHaveBeenCalled();
147
+ expect(host.querySelector('.rc-duo-fold-hold')).not.toBeNull();
148
+ await incoming();
149
+ expect(renderer.start).toHaveBeenCalledTimes(1);
150
+ expect(readPixels).not.toHaveBeenCalled();
151
+ expect(vi.getTimerCount()).toBe(0);
152
+ });
153
+ it('uses the settled native orientation without rotating the entire phone', async () => {
154
+ await act(async () => root.render(<View />));
155
+ await act(async () => root.render(<View inner turns={0} />));
156
+ await act(async () => root.render(<View inner turns={1} />));
157
+ await incoming();
158
+ const motion = renderer.start.mock.calls[0]![1];
159
+ expect(motion.to.frame.turns).toBe(0);
160
+ expect(motion.from.frame.turns).toBe(0);
161
+ });
162
+ for (const fromInner of [false, true]) {
163
+ for (const pose of [0, 1, 2, 3]) {
164
+ it(`finishes ${
165
+ fromInner ? 'folding' : 'unfolding'
166
+ } at pose ${pose} when orientation arrives after video`, async () => {
167
+ const fromTurns = (pose + (fromInner ? 1 : 0)) % 4;
168
+ const toTurns = (pose + (fromInner ? 0 : 1)) % 4;
169
+ await act(async () => root.render(<View inner={fromInner} turns={fromTurns} />));
170
+ await act(async () => root.render(<View inner={!fromInner} turns={fromTurns} />));
171
+ await incoming();
172
+ expect(renderer.start).toHaveBeenCalledTimes(1);
173
+ const motion = renderer.start.mock.calls[0]![1];
174
+ expect(motion.from.frame.turns).toBe(pose);
175
+ expect(motion.to.frame.turns).toBe(pose);
176
+ await act(async () => root.render(<View inner={!fromInner} turns={toTurns} />));
177
+ expect(renderer.start).toHaveBeenCalledTimes(1);
178
+ expect(renderer.dispose).not.toHaveBeenCalled();
179
+ expect(host.querySelector('.rc-duo-fold-canvas')).not.toBeNull();
180
+ await act(async () => renderer.finish!());
181
+ expect(renderer.dispose).toHaveBeenCalledTimes(1);
182
+ });
183
+ }
184
+ }
185
+ it('still cancels an active fold when the device is explicitly rotated', async () => {
186
+ await act(async () => root.render(<View />));
187
+ await act(async () => root.render(<View inner />));
188
+ await incoming();
189
+ await act(async () => root.render(<View inner turns={2} orientation="landscape-left" />));
190
+ expect(renderer.dispose).toHaveBeenCalledTimes(1);
191
+ expect(host.childElementCount).toBe(0);
192
+ });
193
+ it('waits for a usable frame if a video event precedes its dimensions', async () => {
194
+ await act(async () => root.render(<View />));
195
+ await act(async () => root.render(<View inner />));
196
+ Object.defineProperty(video, 'videoWidth', { configurable: true, value: 0 });
197
+ await incoming();
198
+ expect(renderer.start).not.toHaveBeenCalled();
199
+ expect(host.querySelector('.rc-duo-fold-hold')).not.toBeNull();
200
+ Object.defineProperty(video, 'videoWidth', { configurable: true, value: 1398 });
201
+ await incoming();
202
+ expect(renderer.start).toHaveBeenCalledTimes(1);
203
+ });
204
+ it('returns to live video on missing frames or WebGL failure', async () => {
205
+ await act(async () => root.render(<View />));
206
+ await act(async () => root.render(<View inner />));
207
+ Object.defineProperty(video, 'readyState', { configurable: true, value: 0 });
208
+ await act(async () => vi.advanceTimersByTime(2500));
209
+ expect(host.childElementCount).toBe(0);
210
+ Object.defineProperty(video, 'readyState', { configurable: true, value: 4 });
211
+ renderer.start.mockImplementation(() => {
212
+ throw new Error('WebGL unavailable');
213
+ });
214
+ await act(async () => root.render(<View />));
215
+ await incoming();
216
+ expect(host.childElementCount).toBe(0);
217
+ });
218
+
219
+ it('captures before the native command can blank the outgoing display', async () => {
220
+ await act(async () => root.render(<View />));
221
+ await act(async () => prepareFold(true));
222
+ expect(host.querySelector('.rc-duo-fold-hold')).not.toBeNull();
223
+ const outgoingCaptures = () => draw.mock.calls.filter(([source]) => source === video).length;
224
+ expect(outgoingCaptures()).toBe(1);
225
+ await act(async () => root.render(<View inner />));
226
+ expect(outgoingCaptures()).toBe(1);
227
+ await incoming();
228
+ expect(renderer.start).toHaveBeenCalledTimes(1);
229
+ });
230
+ it('releases a prepared image when a command never changes the native display', async () => {
231
+ await act(async () => root.render(<View />));
232
+ await act(async () => prepareFold(true));
233
+ await act(async () => vi.advanceTimersByTime(5000));
234
+ expect(host.childElementCount).toBe(0);
235
+ expect(renderer.start).not.toHaveBeenCalled();
236
+ });
237
+
238
+ it('accepts a black first frame without waiting for a brighter one', async () => {
239
+ await act(async () => root.render(<View />));
240
+ await act(async () => root.render(<View inner />));
241
+ readPixels.mockReturnValue({ data: new Uint8ClampedArray([0, 0, 0, 255]) });
242
+ await incoming();
243
+ expect(renderer.start).toHaveBeenCalledTimes(1);
244
+ expect(vi.getTimerCount()).toBe(0);
245
+ });