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 @@...@@ -1,5 +1,5 @@
1import {1import {
2 DUO_POSES,2 duoFoldState,
3 type DuoCommand,3 type DuoCommand,
4 type DuoControlState,4 type DuoControlState,
5} from "@t3tools/client-runtime/device/duo-control";5} from "@t3tools/client-runtime/device/duo-control";
...@@ -8,51 +8,87 @@ import { DeviceDuoGlyph } from "./DeviceDuoGlyph";...@@ -8,51 +8,87 @@ import { DeviceDuoGlyph } from "./DeviceDuoGlyph";
8import { Button } from "~/components/ui/button";8import { Button } from "~/components/ui/button";
9import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";9import { 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 */
12export function DeviceDuoControls(props: {27export function DeviceDuoControls(props: {
13 screen: DeviceScreenSize;28 screen: DeviceScreenSize;
14 state: DuoControlState;29 state: DuoControlState;
15 enabled: boolean;30 enabled: boolean;
16 onCommand: (command: DuoCommand) => void;31 onCommand: (command: DuoCommand) => void;
17}) {32}) {
18 const angle = props.screen.hingeAngle;33 const { screen } = props;
19 const fold = angle == null ? null : angle === 0 ? "closed" : angle === 180 ? "open" : "book";34 const { fold, stand, phoneVertical } = duoFoldState(screen);
20 const selected = (id: (typeof DUO_POSES)[number]["id"]) =>35 const foldLabels = {
21 id === "laptop" || id === "tent" ? props.screen.hingePose === id : fold === id;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";
22 return (68 return (
23 <div aria-label="iPhone Duo stands" className="flex flex-col items-center gap-2">69 <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) => (70 <div role="group" aria-label="Fold shape" className={group}>
25 <div71 {FOLDS.map(({ id, angle: value }) =>
26 key={poses[0]?.id}72 button(
27 role="group"73 id,
28 aria-label={index === 0 ? "Fold shape" : "Device stance"}74 foldLabels[id],
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"75 !stand && fold === id,
30 >76 () => props.onCommand({ control: "angle", value }),
31 {poses.map((pose) => (77 <DeviceDuoGlyph pose={id === "half" ? "book" : id} rotated={!phoneVertical} />,
32 <Tooltip key={pose.id}>78 ),
33 <TooltipTrigger79 )}
34 render={80 </div>
35 <Button81 <div role="group" aria-label="Device stance" className={group}>
36 size="icon"82 {STANDS.map(({ id, label }) =>
37 variant={selected(pose.id) ? "secondary" : "ghost"}83 button(
38 disabled={!props.enabled}84 id,
39 aria-label={`${pose.label} stand`}85 label,
40 aria-pressed={selected(pose.id)}86 screen.hingePose === id,
41 data-pressed={selected(pose.id) ? "" : undefined}87 () => props.onCommand({ control: "pose", value: id }),
42 onClick={() => props.onCommand({ control: "pose", value: pose.id })}88 <DeviceDuoGlyph pose={id} />,
43 />89 ),
44 }90 )}
45 >91 </div>
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 ))}
56 {props.state.error ? (92 {props.state.error ? (
57 <Tooltip>93 <Tooltip>
58 <TooltipTrigger94 <TooltipTrigger
apps/web/src/components/device/DeviceDuoGlyph.tsx+18-12
...@@ -1,41 +1,47 @@...@@ -1,41 +1,47 @@
1import type { DuoPose } from "@t3tools/client-runtime/device/duo-control";1import 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// Fold outlines follow Simulator's Duo toolbar; the rounded stance contours are
4// the rounded stance contours are ported from SimulatorFoldingPoseGlyph's SwiftUI paths.5// ported from SimulatorFoldingPoseGlyph's SwiftUI paths.
5const stancePaths = {6const stancePaths = {
6 laptop:7 laptop:
7 "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",8 "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",
8 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",9 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",
9} as const;10} 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 }) {
12 const stance = pose === "laptop" || pose === "tent";18 const stance = pose === "laptop" || pose === "tent";
13 return (19 return (
14 <svg20 <svg
15 viewBox={stance ? "0 0 22 16" : "0 0 24 32"}21 viewBox={stance ? "0 0 22 16" : "0 0 24 24"}
16 fill="none"22 fill="none"
17 stroke="currentColor"23 stroke="currentColor"
18 strokeWidth={stance ? 1.25 : 1.8}24 strokeWidth={stance ? 1.25 : 1.35}
19 strokeLinecap="round"25 strokeLinecap="round"
20 strokeLinejoin="round"26 strokeLinejoin="round"
21 className="size-7 shrink-0"27 className={cn("size-7 shrink-0", rotated && "rotate-90")}
22 aria-hidden28 aria-hidden
23 >29 >
24 {stance ? <path d={stancePaths[pose]} /> : null}30 {stance ? <path d={stancePaths[pose]} /> : null}
25 {pose === "closed" ? (31 {pose === "closed" ? (
26 <>32 <>
27 <rect x="2" y="9" width="20" height="13" rx="2.2" />33 <rect x="5.8" y="3" width="12.4" height="18" rx="2.2" />
28 <path d="M19.3 12.5v6" />34 <circle cx="15.4" cy="5.7" r=".6" fill="currentColor" stroke="none" />
29 <circle cx="5" cy="12" r=".5" fill="currentColor" stroke="none" />35 <path d="M10.6 18.2h2.8" />
30 </>36 </>
31 ) : null}37 ) : null}
32 {pose === "book" ? (38 {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" />
34 ) : null}40 ) : null}
35 {pose === "open" ? (41 {pose === "open" ? (
36 <>42 <>
37 <rect x="2" y="2" width="20" height="28" rx="2.4" />43 <rect x="2.5" y="5" width="19" height="14" rx="2.4" />
38 <path d="M10 27h4" />44 <path d="M10.5 16.3h3" />
39 </>45 </>
40 ) : null}46 ) : null}
41 </svg>47 </svg>
packages/client-runtime/src/device/duoControl.test.ts+50-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { afterEach, expect, it, vi } from "vite-plus/test";1import { 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";
3afterEach(() => vi.useRealTimers());3afterEach(() => vi.useRealTimers());
44
5it("keeps a failed send visible, including a disconnect while draining queued motion", () => {5it("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...@@ -107,3 +107,52 @@ it("pinches only a hit device, accumulates independently of native readback, cla
107 expect(change).toHaveBeenCalledTimes(count);107 expect(change).toHaveBeenCalledTimes(count);
108 expect(pinch.active).toBe(false);108 expect(pinch.active).toBe(false);
109});109});
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 @@...@@ -1,12 +1,8 @@
1// @effect-diagnostics globalTimers:off - The stream owns this browser control queue and its timeout.1// @effect-diagnostics globalTimers:off - The stream owns this browser control queue and its timeout.
2export const DUO_POSES = [2import type { DeviceScreenSize } from "./stream.ts";
3 { id: "closed", label: "Closed", angle: 0 },3
4 { id: "book", label: "Book", angle: 90 },4/** Native hinge presets. Each one also sets the device's physical orientation. */
5 { id: "open", label: "Open", angle: 180 },5export type DuoPose = "closed" | "book" | "open" | "laptop" | "tent";
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"];
10export type DuoOrientation =6export type DuoOrientation =
11 | "portrait"7 | "portrait"
12 | "landscape_left"8 | "landscape_left"
...@@ -24,6 +20,23 @@ export type DuoControlState = {...@@ -24,6 +20,23 @@ export type DuoControlState = {
24 error: string | null;20 error: string | null;
25};21};
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
27/** One in-flight native transaction. Hinge motion coalesces; presets replace queued motion. Nothing replays after reconnect. */40/** One in-flight native transaction. Hinge motion coalesces; presets replace queued motion. Nothing replays after reconnect. */
28export function createDuoControl(options: {41export function createDuoControl(options: {
29 send: (request: { requestId: number; command: DuoCommand }) => boolean;42 send: (request: { requestId: number; command: DuoCommand }) => boolean;