| author | |
| committer | |
| log | 2991331515ed5b7e4df0751e5119fa04954e803a |
| tree | 52f35ba0ec8e0b9a72e95b916b05f1e321b509ef |
| parent | 740bda40c92e1ded65dd2b65ef7c583357b1d258 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 8 | import { Button } from "~/components/ui/button"; | 8 | import { Button } from "~/components/ui/button"; |
| 9 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | 9 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 10 | 10 | ||
| 11 | /** Physical presets live beside the device. Pinching supplies continuous hinge control. */ | 11 | const FOLDS = [ |
| 12 | { id: "closed", angle: 0 }, | ||
| 13 | { id: "half", angle: 90 }, | ||
| 14 | { id: "open", angle: 180 }, | ||
| 15 | ] as const; | ||
| 16 | const 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 | */ | ||
| 12 | export function DeviceDuoControls(props: { | 27 | export 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 | <div | 71 | {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 | <TooltipTrigger | 79 | )} |
| 34 | render={ | 80 | </div> |
| 35 | <Button | 81 | <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 | <TooltipTrigger | 94 | <TooltipTrigger |
apps/web/src/components/device/DeviceDuoGlyph.tsx+18-12| ... | @@ -1,41 +1,47 @@ | ... | @@ -1,41 +1,47 @@ |
| 1 | import type { DuoPose } from "@t3tools/client-runtime/device/duo-control"; | 1 | import type { DuoPose } from "@t3tools/client-runtime/device/duo-control"; |
| 2 | import { cn } from "~/lib/utils"; | ||
| 2 | 3 | ||
| 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. |
| 5 | const stancePaths = { | 6 | const 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; |
| 10 | 11 | ||
| 11 | export 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 | */ | ||
| 17 | export 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 | <svg | 20 | <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-hidden | 28 | 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 @@ |
| 1 | import { afterEach, expect, it, vi } from "vite-plus/test"; | 1 | import { afterEach, expect, it, vi } from "vite-plus/test"; |
| 2 | import { createDuoControl, createDuoPinch, type DuoCommand } from "./duoControl.ts"; | 2 | import { createDuoControl, createDuoPinch, duoFoldState, type DuoCommand } from "./duoControl.ts"; |
| 3 | afterEach(() => vi.useRealTimers()); | 3 | afterEach(() => vi.useRealTimers()); |
| 4 | 4 | ||
| 5 | it("keeps a failed send visible, including a disconnect while draining queued motion", () => { | 5 | it("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. | ||
| 112 | it.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 | |||
| 142 | it("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 | |||
| 148 | it("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. |
| 2 | export const DUO_POSES = [ | 2 | import 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 }, | 5 | export type DuoPose = "closed" | "book" | "open" | "laptop" | "tent"; |
| 6 | { id: "laptop", label: "Laptop", angle: 90 }, | ||
| 7 | { id: "tent", label: "Tent", angle: 80 }, | ||
| 8 | ] as const; | ||
| 9 | export type DuoPose = (typeof DUO_POSES)[number]["id"]; | ||
| 10 | export type DuoOrientation = | 6 | export 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 | }; |
| 26 | 22 | ||
| 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 | */ | ||
| 28 | export 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. */ |
| 28 | export function createDuoControl(options: { | 41 | export function createDuoControl(options: { |
| 29 | send: (request: { requestId: number; command: DuoCommand }) => boolean; | 42 | send: (request: { requestId: number; command: DuoCommand }) => boolean; |