{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "clamshell-laptop",
  "title": "Clamshell laptop",
  "description": "A portable workstation on one solved revolute hinge: the lid keeps its length at every angle, the screen is drawn only from a camera that can see it, and the hinge stops at the travel it has. Four camera angles, four displays.",
  "registryDependencies": [
    "https://robocn.dev/r/robot-style.json",
    "https://robocn.dev/r/robot-kinematics.json",
    "https://robocn.dev/r/device-geometry.json",
    "https://robocn.dev/r/use-robot-motion.json"
  ],
  "files": [
    {
      "path": "src/components/ui/clamshell-laptop.tsx",
      "content": "\"use client\"\n\n/**\n * clamshell-laptop — a portable workstation on one revolute joint.\n *\n * The hinge is the whole machine. `lid` is solved rather than tweened between\n * a shut picture and an open one, so the lid keeps its own length at every\n * angle and the drawing knows when it has passed vertical — past that you are\n * looking at the back of the display, and the screen is not drawn at all.\n *\n * `travel` is the hinge's own limit. Ask for more and it stops where the hinge\n * stops, and says so in the label.\n */\n\nimport * as React from \"react\"\n\nimport { arrowStep, useRobotDrag, useRobotScalar } from \"@/hooks/use-robot-motion\"\nimport { hingePose, panelPath, panelTransform } from \"@/lib/robocn/device\"\nimport { clamp, convexHull2, toRadians, type Vec2 } from \"@/lib/robocn/kinematics\"\nimport {\n  extrudedPath,\n  px,\n  resolveRobotPalette,\n  resolveRobotSize,\n  robotCamera,\n  robotSurface,\n  roundedFootprint,\n  type RobotPaletteProps,\n  type RobotSize,\n  type RobotVariant,\n  type RobotView,\n} from \"@/lib/robocn/style\"\nimport { cn } from \"@/lib/utils\"\n\nconst VIEW_WIDTH = 200\nconst VIEW_HEIGHT = 165\n/** Where the machine's own origin — the middle of its footprint, on the desk — lands. */\nconst ORIGIN = { x: 100, y: 112 }\n\n/** World units: x starboard, y up, z toward the back. */\nconst HALF_W = 60\nconst FRONT = -42\nconst BACK = 42\nconst DECK = 9\nconst HINGE_Z = 38\nconst LID = 78\nconst LID_HALF_W = 58\nconst LID_T = 3.4\n/** The angle the lid settles at when it is working. */\nconst WORK = 100\n/** Degrees per second while easing back into the behaviour. */\nconst SLEW_RATE = 150\nconst NATIVE_VIEW: RobotView = \"profile\"\n\n/** The hinge's own limit, as a pure function of the prop. */\nconst hingeTravel = (travel: number) =>\n  Number.isFinite(travel) ? clamp(travel, 90, 150) : 135\n\n/** Each camera gets the framing it needs; the machine is the same size in all of them. */\nconst frames: Record<RobotView, { zoom: number; dy: number }> = {\n  plan: { zoom: 0.84, dy: -18 },\n  front: { zoom: 0.94, dy: 6 },\n  profile: { zoom: 0.94, dy: 2 },\n  iso: { zoom: 0.8, dy: -4 },\n}\n\nconst viewNames: Record<RobotView, string> = {\n  plan: \"plan view\",\n  front: \"front elevation\",\n  profile: \"side elevation\",\n  iso: \"isometric view\",\n}\n\nexport type LaptopBehavior = \"open\" | \"adjust\" | \"static\"\nexport type LaptopScreen = \"desktop\" | \"code\" | \"media\" | \"off\"\n\nexport interface ClamshellLaptopProps\n  extends Omit<React.ComponentProps<\"svg\">, \"color\">,\n    RobotPaletteProps {\n  /** Controlled lid angle in degrees, 0 shut. Omit to run `behavior`. */\n  lid?: number\n  /** What the lid does when `lid` is not supplied. */\n  behavior?: LaptopBehavior\n  /** How far the hinge opens, degrees. Clamped to 90–150. */\n  travel?: number\n  /** Where the camera stands. One machine, four projections. */\n  view?: RobotView\n  /** Open-and-shut cycles per second, or adjustments per second. */\n  speed?: number\n  animate?: boolean\n  paused?: boolean\n  phase?: number\n  /** Drag the lid open, or arrow-key it. */\n  interactive?: boolean\n  onLidChange?: (angle: number) => void\n  /** What the display is showing. Structure only — no application artwork. */\n  screen?: LaptopScreen\n  showDesk?: boolean\n  label?: string\n  size?: RobotSize | number\n  variant?: RobotVariant\n}\n\nfunction ClamshellLaptop({\n  lid,\n  behavior = \"open\",\n  travel = 135,\n  view = NATIVE_VIEW,\n  speed = 0.24,\n  animate = true,\n  paused = false,\n  phase = 0,\n  interactive = false,\n  onLidChange,\n  screen = \"desktop\",\n  showDesk = true,\n  label,\n  size = \"md\",\n  variant = \"solid\",\n  color,\n  accent,\n  metal,\n  dark,\n  glow,\n  grid,\n  palette: paletteOverride,\n  className,\n  style,\n  role,\n  tabIndex,\n  onKeyDown,\n  onBlur,\n  ...props\n}: ClamshellLaptopProps) {\n  const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })\n  const width = resolveRobotSize(size)\n  const limit = hingeTravel(travel)\n  const controlled = lid !== undefined\n  const svgRef = React.useRef<SVGSVGElement>(null)\n  const [held, setHeld] = React.useState<number | null>(null)\n\n  const hold = controlled ? (Number.isFinite(lid) ? clamp(lid, 0, limit) : 0) : held\n  const goal = React.useCallback(\n    (clock: number) => Math.min(limit, laptopGoal(behavior, clock)),\n    [behavior, limit],\n  )\n  const motion = useRobotScalar(goal, {\n    rate: SLEW_RATE,\n    hold,\n    speed,\n    animate: animate && !controlled && behavior !== \"static\",\n    paused,\n    phase,\n  })\n\n  const apply = React.useCallback(\n    (next: number) => {\n      const bounded = clamp(next, 0, hingeTravel(travel))\n      setHeld(bounded)\n      onLidChange?.(bounded)\n    },\n    [onLidChange, travel, setHeld],\n  )\n  const dragging = useRobotDrag(svgRef, {\n    enabled: interactive,\n    // Up the frame opens the lid, which is the way a hand actually does it.\n    onDrag: React.useCallback((unit: Vec2) => apply((1 - unit.y) * 170 - 20), [apply]),\n    onDragEnd: React.useCallback(() => setHeld(null), [setHeld]),\n  })\n\n  const pose = hingePose(motion.value, BACK - FRONT, LID, { maxAngle: limit })\n  const angle = pose.angle\n  const readout = Math.round(angle)\n\n  const camera = robotCamera(view)\n  const frame = frames[view] ?? frames.profile\n  const at = (x: number, y: number, z: number) => camera.project(x, y, z)\n\n  // The lid, as a solid. Its front face carries the screen; the thickness is\n  // the same quad pushed back along the face normal, and the outline is the\n  // hull of both — which is what keeps it a slab seen edge on in profile.\n  const rad = toRadians(angle)\n  const tip = { y: DECK + LID * Math.sin(rad), z: HINGE_Z - LID * Math.cos(rad) }\n  const normal = { y: -Math.cos(rad), z: -Math.sin(rad) }\n  const face = (x: number, y: number, z: number, back = false) => ({\n    x,\n    y: back ? y - normal.y * LID_T : y,\n    z: back ? z - normal.z * LID_T : z,\n  })\n  const topStarboard = face(LID_HALF_W, tip.y, tip.z)\n  const topPort = face(-LID_HALF_W, tip.y, tip.z)\n  const hingeStarboard = face(LID_HALF_W, DECK, HINGE_Z)\n  const hingePort = face(-LID_HALF_W, DECK, HINGE_Z)\n  const backTopStarboard = face(LID_HALF_W, tip.y, tip.z, true)\n  const backTopPort = face(-LID_HALF_W, tip.y, tip.z, true)\n  const backHingeStarboard = face(LID_HALF_W, DECK, HINGE_Z, true)\n  const backHingePort = face(-LID_HALF_W, DECK, HINGE_Z, true)\n\n  const hull = convexHull2(\n    [\n      topStarboard, topPort, hingeStarboard, hingePort,\n      backTopStarboard, backTopPort, backHingeStarboard, backHingePort,\n    ].map((corner) => at(corner.x, corner.y, corner.z)),\n  )\n  const lidSolid = hull.length\n    ? `${hull.map((p, i) => `${i ? \"L\" : \"M\"} ${px(p.x)} ${px(p.y)}`).join(\" \")} Z`\n    : \"\"\n\n  const display = panelTransform(camera, topStarboard, topPort, hingeStarboard, LID_HALF_W * 2, LID)\n  const rear = panelTransform(camera, backTopPort, backTopStarboard, backHingePort, LID_HALF_W * 2, LID)\n  // Shut, the display is face down on the keyboard; seen edge on it is a line.\n  // Neither is worth drawing, and a panel at a grazing angle only smears.\n  const glassSide = pose.facing > 0.08 && display.facing > 0.04\n  const showScreen = glassSide && display.facing > 0.18\n  const showRear = rear.facing > 0.18\n\n  const body = extrudedPath(roundedFootprint(HALF_W, (BACK - FRONT) / 2, 11, 6), camera, DECK, 0)\n  const hinge = extrudedPath(\n    roundedFootprint(LID_HALF_W - 4, 4, 3, 3).map((p) => ({ x: p.x, y: p.y + HINGE_Z })),\n    camera,\n    DECK + 3.2,\n    DECK - 2,\n  )\n  const shadow = extrudedPath(roundedFootprint(HALF_W + 5, (BACK - FRONT) / 2 + 5, 14, 5), camera, 0, 0)\n\n  // Whichever of the deck and the lid is nearer the camera paints last. It is\n  // the lid's own middle, half a thickness behind its face, so a shut lid\n  // still reads as sitting on top of the keyboard rather than under it.\n  const lidNearer =\n    camera.depth(\n      0,\n      (DECK + tip.y) / 2 - (normal.y * LID_T) / 2,\n      (HINGE_Z + tip.z) / 2 - (normal.z * LID_T) / 2,\n    ) > camera.depth(0, DECK, 0)\n\n  const shell = robotSurface(\"shell\", variant, palette)\n  const machined = robotSurface(\"metal\", variant, palette)\n  const cast = robotSurface(\"dark\", variant, palette)\n  const keyCap = robotSurface(\"dark\", variant, palette, 0.5)\n  const glass = variant === \"solid\" ? { fill: palette.dark } : robotSurface(\"dark\", variant, palette, 0.8)\n\n  const lidGroup = (\n    <g data-lid data-angle={px(angle)}>\n      <path d={lidSolid} {...shell} />\n      {glassSide && (\n        <path d={panelPath(camera, topStarboard, topPort, hingeStarboard)} {...cast} />\n      )}\n      {showScreen && (\n        <g data-screen data-content={screen} transform={display.transform}>\n          <circle cx={58} cy={3.6} r={1.1} fill={palette.metal} />\n          <rect x={6} y={7} width={104} height={62} rx={1.6} {...glass} />\n          {screen !== \"off\" && <ScreenContent screen={screen} palette={palette} />}\n          <path d=\"M 46 73.5 H 70\" stroke={palette.metal} strokeWidth={1.2} strokeLinecap=\"round\" />\n        </g>\n      )}\n      {showRear && (\n        <g data-rear transform={rear.transform}>\n          <rect x={12} y={12} width={92} height={52} rx={3} {...machined} fillOpacity={0.3} />\n          <circle cx={58} cy={38} r={5} {...machined} />\n        </g>\n      )}\n    </g>\n  )\n\n  const baseGroup = (\n    <g data-base>\n      <path data-body d={body} {...shell} />\n      <g data-deck transform={camera.plane(DECK)}>\n        <rect x={-HALF_W + 4} y={FRONT + 4} width={HALF_W * 2 - 8} height={BACK - FRONT - 8} rx={7} {...machined} fillOpacity={variant === \"solid\" ? 0.5 : undefined} />\n        <g data-keyboard>\n          <rect x={-52} y={-3} width={104} height={37} rx={3} {...cast} />\n          {keys.map((key, index) => (\n            <rect\n              key={index}\n              data-key={index}\n              x={px(key.x)}\n              y={px(key.y)}\n              width={px(key.w)}\n              height={5.1}\n              rx={1.1}\n              {...keyCap}\n              fill={key.lit ? palette.accent : keyCap.fill}\n            />\n          ))}\n        </g>\n        <rect data-trackpad x={-15} y={-34} width={30} height={24} rx={3} {...machined} />\n      </g>\n      <path data-hinge d={hinge} {...cast} />\n    </g>\n  )\n\n  return (\n    <svg\n      ref={svgRef}\n      role={role ?? (interactive ? \"slider\" : \"img\")}\n      aria-label={`Clamshell laptop, lid ${readout} degrees, ${screen} screen, ${viewNames[view] ?? viewNames.profile}`}\n      aria-valuemin={interactive ? 0 : undefined}\n      aria-valuemax={interactive ? Math.round(limit) : undefined}\n      aria-valuenow={interactive ? readout : undefined}\n      aria-valuetext={interactive ? `${readout} degrees open` : undefined}\n      tabIndex={tabIndex ?? (interactive ? 0 : undefined)}\n      onKeyDown={(event) => {\n        onKeyDown?.(event)\n        if (!interactive || event.defaultPrevented) return\n        const delta = arrowStep(event.key, event.shiftKey ? 15 : 5, 30)\n        if (delta !== 0) apply(angle + delta)\n        else if (event.key === \"Home\") apply(0)\n        else if (event.key === \"End\") apply(limit)\n        else return\n        event.preventDefault()\n      }}\n      onBlur={(event) => {\n        onBlur?.(event)\n        if (!dragging) setHeld(null)\n      }}\n      viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}\n      width={width}\n      height={px((width * VIEW_HEIGHT) / VIEW_WIDTH)}\n      className={cn(\n        \"max-w-full select-none\",\n        interactive &&\n          \"cursor-grab touch-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[currentColor]\",\n        dragging && \"cursor-grabbing\",\n        className,\n      )}\n      style={{ color: palette.foreground, ...style }}\n      data-view={view}\n      {...props}\n    >\n      <g transform={`translate(${ORIGIN.x} ${px(ORIGIN.y + frame.dy)}) scale(${frame.zoom})`}>\n        {showDesk && <path data-desk d={shadow} fill={palette.dark} opacity={0.13} />}\n        {variant === \"blueprint\" && (\n          <g fill=\"none\" stroke={palette.grid} strokeWidth={0.6} opacity={0.6}>\n            <path d={`M ${px(at(-HALF_W - 12, DECK, HINGE_Z).x)} ${px(at(-HALF_W - 12, DECK, HINGE_Z).y)} L ${px(at(HALF_W + 12, DECK, HINGE_Z).x)} ${px(at(HALF_W + 12, DECK, HINGE_Z).y)}`} strokeDasharray=\"6 2 2 2\" />\n          </g>\n        )}\n        {lidNearer ? (\n          <>\n            {baseGroup}\n            {lidGroup}\n          </>\n        ) : (\n          <>\n            {lidGroup}\n            {baseGroup}\n          </>\n        )}\n      </g>\n      <g fontFamily=\"ui-monospace, monospace\" textAnchor=\"middle\" fill={palette.foreground}>\n        <text x={VIEW_WIDTH / 2} y={VIEW_HEIGHT - 9} fontSize={5}>\n          {`LID ${readout}° / ${screen.toUpperCase()}`}\n        </text>\n        {label && (\n          <text x={VIEW_WIDTH / 2} y={VIEW_HEIGHT - 2.5} fontSize={4.5}>\n            {label}\n          </text>\n        )}\n      </g>\n    </svg>\n  )\n}\n\n/** The keyboard, laid out once: four rows of caps and a modifier row. */\nconst keys: { x: number; y: number; w: number; lit?: boolean }[] = [\n  ...Array.from({ length: 4 }, (_, row) =>\n    Array.from({ length: 13 }, (_, column) => ({\n      x: -50 + column * 7.7,\n      y: 28.4 - row * 6.4,\n      w: 6.4,\n      lit: row === 1 && column === 4,\n    })),\n  ).flat(),\n  { x: -50, y: 2.8, w: 8 },\n  { x: -40.4, y: 2.8, w: 8 },\n  { x: -30.8, y: 2.8, w: 8 },\n  { x: -21.2, y: 2.8, w: 42.4 },\n  { x: 22.8, y: 2.8, w: 8 },\n  { x: 32.4, y: 2.8, w: 8 },\n  { x: 42, y: 2.8, w: 8 },\n]\n\n/**\n * What is on the display: structure drawn in palette roles — a bar, a window,\n * lines, a dock. Never an application's own artwork.\n */\nfunction ScreenContent({\n  screen,\n  palette,\n}: {\n  screen: LaptopScreen\n  palette: ReturnType<typeof resolveRobotPalette>\n}) {\n  if (screen === \"media\") {\n    return (\n      <g>\n        <rect x={10} y={11} width={96} height={44} rx={1.5} fill={palette.metal} opacity={0.2} />\n        <path d=\"M 53 29 L 66 36 L 53 43 Z\" fill={palette.accent} />\n        <rect x={10} y={60} width={96} height={2.4} rx={1.2} fill={palette.metal} opacity={0.45} />\n        <rect x={10} y={60} width={38} height={2.4} rx={1.2} fill={palette.accent} />\n      </g>\n    )\n  }\n  if (screen === \"code\") {\n    return (\n      <g>\n        <rect x={8} y={9} width={22} height={58} rx={1.2} fill={palette.metal} opacity={0.25} />\n        {[38, 26, 54, 44, 20, 48, 32, 58, 24, 40].map((run, index) => (\n          <rect\n            key={index}\n            x={34}\n            y={12 + index * 5.4}\n            width={run}\n            height={2.2}\n            rx={1.1}\n            fill={index % 4 === 1 ? palette.accent : palette.metal}\n            opacity={index % 4 === 1 ? 0.9 : 0.5}\n          />\n        ))}\n      </g>\n    )\n  }\n  return (\n    <g>\n      <rect x={8} y={9} width={100} height={5} rx={1.2} fill={palette.metal} opacity={0.4} />\n      <rect x={22} y={19} width={72} height={34} rx={2} fill={palette.metal} opacity={0.22} />\n      <rect x={22} y={19} width={72} height={5} rx={2} fill={palette.metal} opacity={0.5} />\n      {[46, 58, 34].map((run, index) => (\n        <rect key={run} x={27} y={29 + index * 6} width={run} height={2.4} rx={1.2} fill={index === 0 ? palette.accent : palette.metal} opacity={index === 0 ? 0.9 : 0.45} />\n      ))}\n      <rect x={36} y={58} width={44} height={7} rx={3.5} fill={palette.metal} opacity={0.3} />\n      {[0, 1, 2, 3, 4].map((slot) => (\n        <circle key={slot} cx={41 + slot * 8.5} cy={61.5} r={2.1} fill={slot === 1 ? palette.accent : palette.metal} opacity={slot === 1 ? 1 : 0.6} />\n      ))}\n    </g>\n  )\n}\n\n/**\n * Lid angle at `clock`, in degrees. `open` runs a whole session — lift, work,\n * shut; `adjust` leaves it open and works the angle the way someone sitting\n * down at it does.\n */\nexport function laptopGoal(behavior: LaptopBehavior, clock: number) {\n  if (behavior === \"static\" || !Number.isFinite(clock)) return 0\n  const t = ((clock % 1) + 1) % 1\n  if (behavior === \"adjust\") return WORK + Math.sin(t * Math.PI * 2) * 7\n  if (t < 0.15) return (t / 0.15) * WORK\n  if (t < 0.8) return WORK\n  if (t < 0.95) return WORK * (1 - (t - 0.8) / 0.15)\n  return 0\n}\n\nexport { ClamshellLaptop }\n",
      "type": "registry:ui",
      "target": "@ui/clamshell-laptop.tsx"
    }
  ],
  "categories": [
    "robotics",
    "devices"
  ],
  "type": "registry:ui"
}