| 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 | 1 | import { |
| 2 | DUO_POSES, | |
| 2 | duoFoldState, | |
| 3 | 3 | type DuoCommand, |
| 4 | 4 | type DuoControlState, |
| 5 | 5 | } from "@t3tools/client-runtime/device/duo-control"; |
| ... | ... | @@ -8,51 +8,87 @@ import { DeviceDuoGlyph } from "./DeviceDuoGlyph"; |
| 8 | 8 | import { Button } from "~/components/ui/button"; |
| 9 | 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 | 27 | export function DeviceDuoControls(props: { |
| 13 | 28 | screen: DeviceScreenSize; |
| 14 | 29 | state: DuoControlState; |
| 15 | 30 | enabled: boolean; |
| 16 | 31 | onCommand: (command: DuoCommand) => void; |
| 17 | 32 | }) { |
| 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"; | |
| 22 | 68 | return ( |
| 23 | 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) => ( | |
| 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> | |
| 56 | 92 | {props.state.error ? ( |
| 57 | 93 | <Tooltip> |
| 58 | 94 | <TooltipTrigger |
apps/web/src/components/device/DeviceDuoGlyph.tsx+18-12| ... | ... | @@ -1,41 +1,47 @@ |
| 1 | 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 | // 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. | |
| 5 | 6 | const stancePaths = { |
| 6 | 7 | laptop: |
| 7 | 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 | 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 | 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 | 18 | const stance = pose === "laptop" || pose === "tent"; |
| 13 | 19 | return ( |
| 14 | 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 | 22 | fill="none" |
| 17 | 23 | stroke="currentColor" |
| 18 | strokeWidth={stance ? 1.25 : 1.8} | |
| 24 | strokeWidth={stance ? 1.25 : 1.35} | |
| 19 | 25 | strokeLinecap="round" |
| 20 | 26 | strokeLinejoin="round" |
| 21 | className="size-7 shrink-0" | |
| 27 | className={cn("size-7 shrink-0", rotated && "rotate-90")} | |
| 22 | 28 | aria-hidden |
| 23 | 29 | > |
| 24 | 30 | {stance ? <path d={stancePaths[pose]} /> : null} |
| 25 | 31 | {pose === "closed" ? ( |
| 26 | 32 | <> |
| 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" /> | |
| 30 | 36 | </> |
| 31 | 37 | ) : null} |
| 32 | 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 | 40 | ) : null} |
| 35 | 41 | {pose === "open" ? ( |
| 36 | 42 | <> |
| 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" /> | |
| 39 | 45 | </> |
| 40 | 46 | ) : null} |
| 41 | 47 | </svg> |
packages/client-runtime/src/device/duoControl.test.ts+50-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 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 | 3 | afterEach(() => vi.useRealTimers()); |
| 4 | 4 | |
| 5 | 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 | 107 | expect(change).toHaveBeenCalledTimes(count); |
| 108 | 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 | 1 | // @effect-diagnostics globalTimers:off - The stream owns this browser control queue and its timeout. |
| 2 | export 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; | |
| 9 | export type DuoPose = (typeof DUO_POSES)[number]["id"]; | |
| 2 | import type { DeviceScreenSize } from "./stream.ts"; | |
| 3 | ||
| 4 | /** Native hinge presets. Each one also sets the device's physical orientation. */ | |
| 5 | export type DuoPose = "closed" | "book" | "open" | "laptop" | "tent"; | |
| 10 | 6 | export type DuoOrientation = |
| 11 | 7 | | "portrait" |
| 12 | 8 | | "landscape_left" |
| ... | ... | @@ -24,6 +20,23 @@ export type DuoControlState = { |
| 24 | 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 | 40 | /** One in-flight native transaction. Hinge motion coalesces; presets replace queued motion. Nothing replays after reconnect. */ |
| 28 | 41 | export function createDuoControl(options: { |
| 29 | 42 | send: (request: { requestId: number; command: DuoCommand }) => boolean; |