authorgravatar for 30420087+gabrielelpidio@users.noreply.github.comGabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> 2026-10-06 21:25:49-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 21:25:49-04:00
log2991331515ed5b7e4df0751e5119fa04954e803a
tree52f35ba0ec8e0b9a72e95b916b05f1e321b509ef
parent740bda40c92e1ded65dd2b65ef7c583357b1d258
signature Signed by PGP key B5690EEEBB952194

fix(web): iPhone Duo fold controls follow the phone's orientation (#16630)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

4 files changed, 163 insertions(+), 59 deletions(-)

apps/web/src/components/device/DeviceDuoControls.tsx+74-38
......@@ -1,5 +1,5 @@
11import {
2 DUO_POSES,
2 duoFoldState,
33 type DuoCommand,
44 type DuoControlState,
55} from "@t3tools/client-runtime/device/duo-control";
......@@ -8,51 +8,87 @@ import { DeviceDuoGlyph } from "./DeviceDuoGlyph";
88import { Button } from "~/components/ui/button";
99import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
1010
11/** Physical presets live beside the device. Pinching supplies continuous hinge control. */
11const FOLDS = [
12 { id: "closed", angle: 0 },
13 { id: "half", angle: 90 },
14 { id: "open", angle: 180 },
15] as const;
16const STANDS = [
17 { id: "laptop", label: "Laptop stand" },
18 { id: "tent", label: "Tent stand" },
19] as const;
20
21/**
22 * Fold shapes move only the hinge, so the device opens around whichever edge it
23 * currently rests on: a vertical phone opens as a book into a landscape tablet,
24 * a horizontal one as a laptop into a portrait tablet. Stands are native presets
25 * that also place the device. Pinching supplies continuous hinge control.
26 */
1227export function DeviceDuoControls(props: {
1328 screen: DeviceScreenSize;
1429 state: DuoControlState;
1530 enabled: boolean;
1631 onCommand: (command: DuoCommand) => void;
1732}) {
18 const angle = props.screen.hingeAngle;
19 const fold = angle == null ? null : angle === 0 ? "closed" : angle === 180 ? "open" : "book";
20 const selected = (id: (typeof DUO_POSES)[number]["id"]) =>
21 id === "laptop" || id === "tent" ? props.screen.hingePose === id : fold === id;
33 const { screen } = props;
34 const { fold, stand, phoneVertical } = duoFoldState(screen);
35 const foldLabels = {
36 closed: "Closed",
37 half: phoneVertical ? "Book" : "Laptop",
38 open: "Open",
39 };
40 const button = (
41 key: string,
42 label: string,
43 pressed: boolean,
44 onClick: () => void,
45 glyph: React.ReactNode,
46 ) => (
47 <Tooltip key={key}>
48 <TooltipTrigger
49 render={
50 <Button
51 size="icon"
52 variant={pressed ? "secondary" : "ghost"}
53 disabled={!props.enabled}
54 aria-label={label}
55 aria-pressed={pressed}
56 data-pressed={pressed ? "" : undefined}
57 onClick={onClick}
58 />
59 }
60 >
61 {glyph}
62 </TooltipTrigger>
63 <TooltipPopup side="left">{label}</TooltipPopup>
64 </Tooltip>
65 );
66 const group =
67 "pointer-events-auto flex shrink-0 flex-col items-center gap-1 rounded-full border border-border/50 bg-background/80 p-1 shadow-sm";
2268 return (
2369 <div aria-label="iPhone Duo stands" className="flex flex-col items-center gap-2">
24 {([DUO_POSES.slice(0, 3), DUO_POSES.slice(3)] as const).map((poses, index) => (
25 <div
26 key={poses[0]?.id}
27 role="group"
28 aria-label={index === 0 ? "Fold shape" : "Device stance"}
29 className="pointer-events-auto flex shrink-0 flex-col items-center gap-1 rounded-full border border-border/50 bg-background/80 p-1 shadow-sm"
30 >
31 {poses.map((pose) => (
32 <Tooltip key={pose.id}>
33 <TooltipTrigger
34 render={
35 <Button
36 size="icon"
37 variant={selected(pose.id) ? "secondary" : "ghost"}
38 disabled={!props.enabled}
39 aria-label={`${pose.label} stand`}
40 aria-pressed={selected(pose.id)}
41 data-pressed={selected(pose.id) ? "" : undefined}
42 onClick={() => props.onCommand({ control: "pose", value: pose.id })}
43 />
44 }
45 >
46 <DeviceDuoGlyph pose={pose.id} />
47 </TooltipTrigger>
48 <TooltipPopup side="left">
49 {pose.label}
50 {pose.id === "book" ? " / bookshelf" : ""}
51 </TooltipPopup>
52 </Tooltip>
53 ))}
54 </div>
55 ))}
70 <div role="group" aria-label="Fold shape" className={group}>
71 {FOLDS.map(({ id, angle: value }) =>
72 button(
73 id,
74 foldLabels[id],
75 !stand && fold === id,
76 () => props.onCommand({ control: "angle", value }),
77 <DeviceDuoGlyph pose={id === "half" ? "book" : id} rotated={!phoneVertical} />,
78 ),
79 )}
80 </div>
81 <div role="group" aria-label="Device stance" className={group}>
82 {STANDS.map(({ id, label }) =>
83 button(
84 id,
85 label,
86 screen.hingePose === id,
87 () => props.onCommand({ control: "pose", value: id }),
88 <DeviceDuoGlyph pose={id} />,
89 ),
90 )}
91 </div>
5692 {props.state.error ? (
5793 <Tooltip>
5894 <TooltipTrigger
apps/web/src/components/device/DeviceDuoGlyph.tsx+18-12
......@@ -1,41 +1,47 @@
11import type { DuoPose } from "@t3tools/client-runtime/device/duo-control";
2import { cn } from "~/lib/utils";
23
3// Bitrig 0.25's toolbar glyphs. The fold outlines follow the native toolbar;
4// the rounded stance contours are ported from SimulatorFoldingPoseGlyph's SwiftUI paths.
4// Fold outlines follow Simulator's Duo toolbar; the rounded stance contours are
5// ported from SimulatorFoldingPoseGlyph's SwiftUI paths.
56const stancePaths = {
67 laptop:
78 "M2.7351 12.9857 L15.8128 11.4159 L14.4114 3.3787 C14.1943 2.134 13.1275 1.2319 12.0283 1.3639 L2.9308 2.4559 C1.8317 2.5879 1.1167 3.7038 1.3337 4.9485 L2.7351 12.9857 Z M2.7351 12.9857 L15.8128 11.4159 L19.9103 12.8914 C20.5449 13.1199 20.1683 13.4121 19.0692 13.5441 L9.9717 14.6361 C8.8725 14.7681 7.4672 14.6898 6.8326 14.4612 L2.7351 12.9857 Z",
89 tent: "M3.2522 2.7267 L18.7478 1.4816 L16.696 9.7017 C16.3782 10.9748 15.0649 12.0915 13.7626 12.1962 L2.983 13.0623 C1.6807 13.167 0.8826 12.2199 1.2004 10.9468 L3.2522 2.7267 Z M3.2522 2.7267 L18.7478 1.4816 L20.7996 10.6909 C21.1174 12.1172 20.3193 13.3581 19.017 13.4628 L8.2374 14.3289 C6.9351 14.4336 5.6218 13.3623 5.304 11.936 L3.2522 2.7267 Z",
910} as const;
1011
11export function DeviceDuoGlyph({ pose }: { pose: DuoPose }) {
12/**
13 * Fold glyphs draw the physical device held as a vertical phone: it opens like a book into a
14 * landscape tablet. `rotated` turns the whole device a quarter clockwise for a horizontal phone,
15 * camera and home indicator included, as Simulator does.
16 */
17export function DeviceDuoGlyph({ pose, rotated = false }: { pose: DuoPose; rotated?: boolean }) {
1218 const stance = pose === "laptop" || pose === "tent";
1319 return (
1420 <svg
15 viewBox={stance ? "0 0 22 16" : "0 0 24 32"}
21 viewBox={stance ? "0 0 22 16" : "0 0 24 24"}
1622 fill="none"
1723 stroke="currentColor"
18 strokeWidth={stance ? 1.25 : 1.8}
24 strokeWidth={stance ? 1.25 : 1.35}
1925 strokeLinecap="round"
2026 strokeLinejoin="round"
21 className="size-7 shrink-0"
27 className={cn("size-7 shrink-0", rotated && "rotate-90")}
2228 aria-hidden
2329 >
2430 {stance ? <path d={stancePaths[pose]} /> : null}
2531 {pose === "closed" ? (
2632 <>
27 <rect x="2" y="9" width="20" height="13" rx="2.2" />
28 <path d="M19.3 12.5v6" />
29 <circle cx="5" cy="12" r=".5" fill="currentColor" stroke="none" />
33 <rect x="5.8" y="3" width="12.4" height="18" rx="2.2" />
34 <circle cx="15.4" cy="5.7" r=".6" fill="currentColor" stroke="none" />
35 <path d="M10.6 18.2h2.8" />
3036 </>
3137 ) : null}
3238 {pose === "book" ? (
33 <path d="M4.2 6.5h15.6c1 0 1.6.8 1.2 1.7L17.7 16l3.3 7.8c.4.9-.2 1.7-1.2 1.7H4.2c-1 0-1.6-.8-1.2-1.7L6.3 16 3 8.2c-.4-.9.2-1.7 1.2-1.7Z" />
39 <path d="M4.6 18.1V5.9Q4.6 4.9 5.9 4.9L12 7.5L18.1 4.9Q19.4 4.9 19.4 5.9V18.1Q19.4 19.1 18.1 19.1L12 16.5L5.9 19.1Q4.6 19.1 4.6 18.1Z" />
3440 ) : null}
3541 {pose === "open" ? (
3642 <>
37 <rect x="2" y="2" width="20" height="28" rx="2.4" />
38 <path d="M10 27h4" />
43 <rect x="2.5" y="5" width="19" height="14" rx="2.4" />
44 <path d="M10.5 16.3h3" />
3945 </>
4046 ) : null}
4147 </svg>
packages/client-runtime/src/device/duoControl.test.ts+50-1
......@@ -1,5 +1,5 @@
11import { afterEach, expect, it, vi } from "vite-plus/test";
2import { createDuoControl, createDuoPinch, type DuoCommand } from "./duoControl.ts";
2import { createDuoControl, createDuoPinch, duoFoldState, type DuoCommand } from "./duoControl.ts";
33afterEach(() => vi.useRealTimers());
44
55it("keeps a failed send visible, including a disconnect while draining queued motion", () => {
......@@ -107,3 +107,52 @@ it("pinches only a hit device, accumulates independently of native readback, cla
107107 expect(change).toHaveBeenCalledTimes(count);
108108 expect(pinch.active).toBe(false);
109109});
110
111// Screen configs as an iPhone Duo simulator reports them for each way of holding the device.
112it.each([
113 [
114 "vertical phone, closed",
115 { screenId: 1, orientation: "portrait", hingeAngle: 0 },
116 "closed",
117 true,
118 ],
119 [
120 "horizontal phone, closed",
121 { screenId: 1, orientation: "landscape_right", hingeAngle: 0 },
122 "closed",
123 false,
124 ],
125 [
126 "vertical phone opened as a book",
127 { screenId: 3, orientation: "landscape_left", hingeAngle: 180 },
128 "open",
129 true,
130 ],
131 [
132 "horizontal phone opened as a laptop",
133 { screenId: 3, orientation: "portrait_upside_down", hingeAngle: 180 },
134 "open",
135 false,
136 ],
137 ["half-open book", { screenId: 3, orientation: "landscape_left", hingeAngle: 90 }, "half", true],
138] as const)("reads a %s", (_name, screen, fold, phoneVertical) => {
139 expect(duoFoldState(screen)).toEqual({ fold, stand: false, phoneVertical });
140});
141
142it("marks native stands so the fold group does not also claim them", () => {
143 expect(
144 duoFoldState({ screenId: 3, orientation: "portrait", hingeAngle: 90, hingePose: "laptop" }),
145 ).toEqual({ fold: "half", stand: true, phoneVertical: false });
146});
147
148it("falls back like the 3D view when hinge fields are missing", () => {
149 expect(duoFoldState({ screenId: 1, orientation: "portrait" })).toMatchObject({
150 fold: "closed",
151 phoneVertical: true,
152 });
153 // Without a display ID the 3D view draws the open inner panel, so the controls do too.
154 expect(duoFoldState({ orientation: "landscape_left" })).toMatchObject({
155 fold: "open",
156 phoneVertical: true,
157 });
158});
packages/client-runtime/src/device/duoControl.ts+21-8
......@@ -1,12 +1,8 @@
11// @effect-diagnostics globalTimers:off - The stream owns this browser control queue and its timeout.
2export const DUO_POSES = [
3 { id: "closed", label: "Closed", angle: 0 },
4 { id: "book", label: "Book", angle: 90 },
5 { id: "open", label: "Open", angle: 180 },
6 { id: "laptop", label: "Laptop", angle: 90 },
7 { id: "tent", label: "Tent", angle: 80 },
8] as const;
9export type DuoPose = (typeof DUO_POSES)[number]["id"];
2import type { DeviceScreenSize } from "./stream.ts";
3
4/** Native hinge presets. Each one also sets the device's physical orientation. */
5export type DuoPose = "closed" | "book" | "open" | "laptop" | "tent";
106export type DuoOrientation =
117 | "portrait"
128 | "landscape_left"
......@@ -24,6 +20,23 @@ export type DuoControlState = {
2420 error: string | null;
2521};
2622
23/**
24 * The fold the device is in and the way it is held. Missing hinge fields fall back the same way
25 * the 3D view does, so controls never disagree with what is drawn. The inner panel is mounted a
26 * quarter turn from the cover, so its landscape orientation means a vertical phone.
27 */
28export function duoFoldState(
29 screen: Pick<DeviceScreenSize, "orientation" | "screenId" | "hingeAngle" | "hingePose">,
30) {
31 const angle = screen.hingeAngle ?? (screen.screenId === 1 ? 0 : 180);
32 const landscape = screen.orientation.startsWith("landscape");
33 return {
34 fold: angle === 0 ? "closed" : angle === 180 ? "open" : "half",
35 stand: screen.hingePose === "laptop" || screen.hingePose === "tent",
36 phoneVertical: screen.screenId === 1 ? !landscape : landscape,
37 } as const;
38}
39
2740/** One in-flight native transaction. Hinge motion coalesces; presets replace queued motion. Nothing replays after reconnect. */
2841export function createDuoControl(options: {
2942 send: (request: { requestId: number; command: DuoCommand }) => boolean;