{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "glass-phone-duo",
  "title": "Glass Phone Duo",
  "description": "A folding glass phone with flat screen content, an outer display, and a smooth titanium frame.",
  "dependencies": [
    "clsx"
  ],
  "files": [
    {
      "path": "registry/glassyui/glass-phone-duo.tsx",
      "content": "'use client';\n\n/* eslint-disable @next/next/no-img-element -- The registry also installs outside Next.js. */\nimport clsx from 'clsx';\nimport { forwardRef, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react';\nimport type { CSSProperties, HTMLAttributes } from 'react';\nimport { createPhoneDuoRenderer } from './lib/phone-duo-renderer';\nimport './styles/phone-duo.css';\n\nexport type GlassPhoneDuoFinish = 'night-sky' | 'star-white';\n\nexport type GlassPhoneDuoProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {\n  /** A same-origin or CORS-enabled landscape screenshot of the inner display (2670:1878). */\n  screenSrc: string;\n  /** A same-origin or CORS-enabled portrait screenshot of the outer display (1398:2034). */\n  coverSrc: string;\n  screenAlt?: string;\n  coverAlt?: string;\n  finish?: GlassPhoneDuoFinish;\n  glare?: boolean;\n  /** Animates between the flat inner display and the closed cover. */\n  open?: boolean;\n  /** Manual hinge angle: 0 closed, 180 flat. Overrides open without animating. */\n  angle?: number;\n  view?: 'front' | 'back';\n};\n\n/** A smooth titanium frame and folding glass above a continuous, flat screenshot. */\nexport const GlassPhoneDuo = forwardRef<HTMLElement, GlassPhoneDuoProps>(\n  (\n    {\n      angle,\n      className,\n      coverAlt = 'App on the outer phone display',\n      coverSrc,\n      finish = 'night-sky',\n      glare = true,\n      open = true,\n      screenAlt = 'App on the unfolded phone display',\n      screenSrc,\n      style,\n      view = 'front',\n      ...rest\n    },\n    forwardedRef,\n  ) => {\n    const figureRef = useRef<HTMLElement>(null);\n    const canvasRef = useRef<HTMLCanvasElement>(null);\n    const screenRef = useRef<HTMLImageElement>(null);\n    const coverRef = useRef<HTMLImageElement>(null);\n    const [error, setError] = useState<string>();\n    const fold = angle === undefined ? Number(open) : Math.min(180, Math.max(0, angle)) / 180;\n\n    // The intro lifts its pane with t^1.25. Encode the inverse so manual angles\n    // and interrupted animations meet at exactly the same physical hinge angle.\n    const figureStyle: CSSProperties & { '--lg-phone-duo-fold': number } = {\n      ...style,\n      '--lg-phone-duo-fold': 1 - (1 - fold) ** (1 / 1.25),\n    };\n\n    useImperativeHandle(forwardedRef, () => figureRef.current!, []);\n    useLayoutEffect(() => {\n      const figure = figureRef.current!;\n      const canvas = canvasRef.current!;\n      const images = [screenRef.current!, coverRef.current!];\n      let renderer: ReturnType<typeof createPhoneDuoRenderer>;\n\n      try {\n        renderer = createPhoneDuoRenderer(canvas);\n      } catch {\n        setError('This phone mockup needs WebGL 2.');\n\n        return;\n      }\n\n      let frame = 0;\n      let alive = true;\n      const motion = matchMedia('(prefers-reduced-motion: reduce)');\n      const transparency = matchMedia('(prefers-reduced-transparency: reduce)');\n\n      function paint() {\n        frame = 0;\n        const computed = getComputedStyle(figure);\n        const currentFold = Number(computed.getPropertyValue('--lg-phone-duo-fold'));\n        const turn = Number.parseFloat(computed.getPropertyValue('--lg-phone-duo-turn'));\n        const targetFold = Number(figure.style.getPropertyValue('--lg-phone-duo-fold'));\n        const targetTurn = figure.dataset.view === 'back' ? 172 : -8;\n\n        renderer.draw({\n          fold: currentFold,\n          turn,\n          white: figure.dataset.finish === 'star-white',\n          glare:\n            figure.dataset.glare !== 'off' &&\n            !transparency.matches &&\n            document.documentElement.dataset.lgReduceTransparency !== '1',\n        });\n\n        if (Math.abs(currentFold - targetFold) > 0.00001 || Math.abs(turn - targetTurn) > 0.001) {\n          schedule();\n        }\n      }\n\n      function schedule() {\n        if (alive && !frame) frame = requestAnimationFrame(paint);\n      }\n\n      function load(image: HTMLImageElement, index: number) {\n        if (!image.complete || !image.naturalWidth) return;\n\n        try {\n          renderer.setImage(index, image);\n          schedule();\n        } catch {\n          setError('The mockup screenshot needs permission to load across origins.');\n        }\n      }\n\n      const onLoad = images.map((image, index) => () => load(image, index));\n      const onError = () => setError('The mockup screenshot could not load.');\n\n      images.forEach((image, index) => {\n        image.addEventListener('load', onLoad[index]);\n        image.addEventListener('error', onError);\n        load(image, index);\n      });\n\n      const resize = new ResizeObserver(schedule);\n      resize.observe(canvas);\n      const attributes = new MutationObserver(schedule);\n      attributes.observe(figure, {\n        attributes: true,\n        attributeFilter: ['style', 'data-view', 'data-finish', 'data-glare', 'data-manual'],\n      });\n      attributes.observe(document.documentElement, {\n        attributes: true,\n        attributeFilter: ['data-lg-reduce-motion', 'data-lg-reduce-transparency'],\n      });\n      motion.addEventListener('change', schedule);\n      transparency.addEventListener('change', schedule);\n      schedule();\n\n      return () => {\n        alive = false;\n        cancelAnimationFrame(frame);\n        resize.disconnect();\n        attributes.disconnect();\n        motion.removeEventListener('change', schedule);\n        transparency.removeEventListener('change', schedule);\n        images.forEach((image, index) => {\n          image.removeEventListener('load', onLoad[index]);\n          image.removeEventListener('error', onError);\n        });\n        renderer.dispose();\n      };\n    }, []);\n\n    return (\n      <figure\n        ref={figureRef}\n        aria-label={\n          view === 'back'\n            ? `Rear cameras. ${fold >= 0.5 ? coverAlt : 'Phone closed'}`\n            : fold < 0.5\n              ? coverAlt\n              : screenAlt\n        }\n        className={clsx('lg-phone-duo', className)}\n        data-finish={finish}\n        data-fold={fold === 1 ? 'open' : fold === 0 ? 'closed' : 'partial'}\n        data-glare={glare ? 'on' : 'off'}\n        data-manual={angle === undefined ? undefined : ''}\n        data-view={view}\n        role=\"img\"\n        style={figureStyle}\n        {...rest}\n      >\n        <div aria-hidden className=\"lg-phone-duo-body\">\n          <span className=\"lg-phone-duo-shadow\" />\n          <canvas ref={canvasRef} className=\"lg-phone-duo-canvas\" />\n          <img\n            ref={screenRef}\n            alt=\"\"\n            crossOrigin=\"anonymous\"\n            draggable={false}\n            hidden\n            src={screenSrc}\n          />\n          <img\n            ref={coverRef}\n            alt=\"\"\n            crossOrigin=\"anonymous\"\n            draggable={false}\n            hidden\n            src={coverSrc}\n          />\n        </div>\n        {error ? <figcaption className=\"lg-phone-duo-error\">{error}</figcaption> : null}\n      </figure>\n    );\n  },\n);\n\nGlassPhoneDuo.displayName = 'GlassPhoneDuo';\n",
      "type": "registry:ui",
      "target": "components/glassyui/glass-phone-duo.tsx"
    },
    {
      "path": "registry/glassyui/lib/phone-duo-renderer.ts",
      "content": "// Millimetres: Apple's unfolded body is 164.6 × 117.8 × 5.2; closed depth is\n// 11.3. The 0.45 hinge pivot leaves 0.9 between the two OLED faces. Small parts\n// and corner radii are estimated from the product renders, rather than CAD.\ntype Vec3 = [number, number, number];\n\ntype Matrix = Float32Array;\n\ntype Group = 'base' | 'wing' | 'flat';\n\ntype Part = { first: number; count: number; group: Group; material: number };\n\ntype Point = { x: number; y: number; nx: number; ny: number };\n\ntype Vertex = { p: Vec3; n: Vec3; uv: [number, number] };\n\ntype Pose = { fold: number; turn: number; white: boolean; glare: boolean };\n\nconst stageWidth = 220;\n\nconst stageHeight = 178;\n\nconst distance = 700;\n\nconst halfHeight = 58.9;\n\nconst identity = () => new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);\n\nfunction multiply(a: Matrix, b: Matrix) {\n  const result = new Float32Array(16);\n\n  for (let col = 0; col < 4; col++) {\n    for (let row = 0; row < 4; row++) {\n      for (let k = 0; k < 4; k++) result[col * 4 + row] += a[k * 4 + row] * b[col * 4 + k];\n    }\n  }\n\n  return result;\n}\n\nfunction translation(x: number, y: number, z: number) {\n  const result = identity();\n  result[12] = x;\n  result[13] = y;\n  result[14] = z;\n\n  return result;\n}\n\nfunction rotation(angle: number, axis: 'x' | 'y') {\n  const result = identity();\n  const c = Math.cos(angle);\n  const s = Math.sin(angle);\n\n  if (axis === 'y') {\n    result[0] = c;\n    result[2] = -s;\n    result[8] = s;\n    result[10] = c;\n  } else {\n    result[5] = c;\n    result[6] = s;\n    result[9] = -s;\n    result[10] = c;\n  }\n\n  return result;\n}\n\nfunction transform(m: Matrix, [x, y, z]: Vec3, direction = false): Vec3 {\n  return [\n    m[0] * x + m[4] * y + m[8] * z + (direction ? 0 : m[12]),\n    m[1] * x + m[5] * y + m[9] * z + (direction ? 0 : m[13]),\n    m[2] * x + m[6] * y + m[10] * z + (direction ? 0 : m[14]),\n  ];\n}\n\nfunction hinge(angle: number) {\n  return multiply(\n    translation(0, 0, 0.45),\n    multiply(rotation(angle, 'y'), translation(0, 0, -0.45)),\n  );\n}\n\n// Inverse projective mapping of a fixed screenshot plane. A fragment uses its\n// viewport position, not the rotating leaf's UV: text and rows cannot bend at\n// the hinge. This is the same sampling principle as the website intro.\nfunction planeMapping(m: Matrix, x: number, y: number, width: number, height: number, z: number) {\n  const origin = transform(m, [x, y, z]);\n  const horizontal = transform(m, [width, 0, 0], true);\n  const vertical = transform(m, [0, height, 0], true);\n  const a = horizontal[0] / (stageWidth / 2);\n  const b = vertical[0] / (stageWidth / 2);\n  const c = origin[0] / (stageWidth / 2);\n  const d = -horizontal[1] / (stageHeight / 2);\n  const e = -vertical[1] / (stageHeight / 2);\n  const f = -origin[1] / (stageHeight / 2);\n  const g = -horizontal[2] / distance;\n  const h = -vertical[2] / distance;\n  const i = 1 - origin[2] / distance;\n  const determinant = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);\n  const divisor = Math.abs(determinant) < 1e-8 ? (determinant < 0 ? -1e-8 : 1e-8) : determinant;\n\n  return new Float32Array([\n    (e * i - f * h) / divisor,\n    (f * g - d * i) / divisor,\n    (d * h - e * g) / divisor,\n    (c * h - b * i) / divisor,\n    (a * i - c * g) / divisor,\n    (b * g - a * h) / divisor,\n    (b * f - c * e) / divisor,\n    (c * d - a * f) / divisor,\n    (a * e - b * d) / divisor,\n  ]);\n}\n\nclass Geometry {\n  values: number[] = [];\n  parts: Part[] = [];\n\n  triangle(a: Vertex, b: Vertex, c: Vertex) {\n    const u = b.p.map((value, index) => value - a.p[index]);\n    const v = c.p.map((value, index) => value - a.p[index]);\n\n    const facing =\n      (u[1] * v[2] - u[2] * v[1]) * a.n[0] +\n      (u[2] * v[0] - u[0] * v[2]) * a.n[1] +\n      (u[0] * v[1] - u[1] * v[0]) * a.n[2];\n\n    for (const vertex of facing >= 0 ? [a, b, c] : [a, c, b]) {\n      this.values.push(...vertex.p, ...vertex.n, ...vertex.uv);\n    }\n  }\n\n  part(group: Group, material: number, build: () => void) {\n    const first = this.values.length / 8;\n    build();\n    this.parts.push({ first, count: this.values.length / 8 - first, group, material });\n  }\n\n  contour(x: number, y: number, width: number, height: number, radii: number[], inset = 0) {\n    const points: Point[] = [];\n    radii.forEach((radius, corner) => {\n      const cx = x + (corner === 0 || corner === 3 ? radius : width - radius);\n      const cy = y + (corner < 2 ? radius : height - radius);\n\n      for (let step = 0; step <= 40; step++) {\n        const angle = Math.PI + ((corner + step / 40) * Math.PI) / 2;\n        const nx = Math.cos(angle);\n        const ny = Math.sin(angle);\n        points.push({\n          x: cx + Math.max(0, radius - inset) * nx,\n          y: cy + Math.max(0, radius - inset) * ny,\n          nx,\n          ny,\n        });\n      }\n    });\n\n    return points;\n  }\n\n  face(\n    x: number,\n    y: number,\n    width: number,\n    height: number,\n    z: number,\n    radii: number[],\n    back = false,\n    m = identity(),\n  ) {\n    const points = this.contour(x, y, width, height, radii);\n\n    const vertex = (px: number, py: number): Vertex => ({\n      p: transform(m, [px, py, z]),\n      n: transform(m, [0, 0, back ? -1 : 1], true),\n      uv: [(px - x) / width, (py - y) / height],\n    });\n\n    const center = vertex(x + width / 2, y + height / 2);\n    points.forEach((point, index) => {\n      const next = points[(index + 1) % points.length];\n      this.triangle(center, vertex(point.x, point.y), vertex(next.x, next.y));\n    });\n  }\n\n  solid(\n    x: number,\n    y: number,\n    width: number,\n    height: number,\n    front: number,\n    depth: number,\n    radii: number[],\n    bevel = 0.3,\n    m = identity(),\n  ) {\n    const rings: { points: Point[]; z: number; normalZ: number; normalXY: number }[] = [];\n\n    for (let side = 0; side < 2; side++) {\n      for (let step = 0; step <= 6; step++) {\n        const angle = ((side === 0 ? step / 6 : 1 - step / 6) * Math.PI) / 2;\n        rings.push({\n          points: this.contour(x, y, width, height, radii, bevel * (1 - Math.sin(angle))),\n          z:\n            side === 0\n              ? front - bevel * (1 - Math.cos(angle))\n              : front - depth + bevel * (1 - Math.cos(angle)),\n          normalZ: Math.cos(angle) * (side === 0 ? 1 : -1),\n          normalXY: Math.sin(angle),\n        });\n      }\n    }\n\n    for (let ring = 0; ring < rings.length - 1; ring++) {\n      const first = rings[ring];\n      const second = rings[ring + 1];\n\n      const vertex = (r: typeof first, index: number): Vertex => {\n        const p = r.points[index];\n\n        return {\n          p: transform(m, [p.x, p.y, r.z]),\n          n: transform(m, [p.nx * r.normalXY, p.ny * r.normalXY, r.normalZ], true),\n          uv: [(p.x - x) / width, (p.y - y) / height],\n        };\n      };\n\n      first.points.forEach((_, index) => {\n        const next = (index + 1) % first.points.length;\n        const a = vertex(first, index);\n        const b = vertex(first, next);\n        const c = vertex(second, index);\n        const d = vertex(second, next);\n        this.triangle(a, c, b);\n        this.triangle(b, c, d);\n      });\n    }\n\n    const cap = (ring: (typeof rings)[number], back: boolean) => {\n      const center: Vertex = {\n        p: transform(m, [x + width / 2, y + height / 2, ring.z]),\n        n: transform(m, [0, 0, back ? -1 : 1], true),\n        uv: [0.5, 0.5],\n      };\n\n      ring.points.forEach((p, index) => {\n        const q = ring.points[(index + 1) % ring.points.length];\n\n        const vertex = (point: Point): Vertex => ({\n          p: transform(m, [point.x, point.y, ring.z]),\n          n: center.n,\n          uv: [(point.x - x) / width, (point.y - y) / height],\n        });\n\n        this.triangle(center, vertex(p), vertex(q));\n      });\n    };\n\n    cap(rings[0], false);\n    cap(rings[rings.length - 1], true);\n  }\n}\n\nfunction deviceGeometry() {\n  const geometry = new Geometry();\n  const baseCorners = [0.8, 11.6, 11.6, 0.8];\n  const wingCorners = [11.6, 0.8, 0.8, 11.6];\n  const back = rotation(Math.PI, 'y');\n  const circle = (radius: number) => [radius, radius, radius, radius];\n\n  for (const group of ['base', 'wing'] as const) {\n    const x = group === 'base' ? 0 : -82.3;\n    const corners = group === 'base' ? baseCorners : wingCorners;\n    geometry.part(group, 0, () =>\n      geometry.solid(x, -halfHeight, 82.3, 117.8, 0, 5.2, corners, 0.3),\n    );\n    geometry.part(group, 2, () =>\n      geometry.face(\n        x + (group === 'base' ? 0 : 0.55),\n        -58.35,\n        81.75,\n        116.7,\n        0.015,\n        corners.map((r) => Math.max(0, r - 0.55)),\n      ),\n    );\n    geometry.part(group, group === 'wing' ? 2 : 1, () =>\n      geometry.face(\n        x + 0.55,\n        -58.35,\n        81.2,\n        116.7,\n        -5.215,\n        corners.map((r) => Math.max(0.25, r - 0.55)),\n        true,\n      ),\n    );\n  }\n\n  geometry.part('wing', 3, () =>\n    geometry.face(-78.75, -55.395, 78.75, 110.79, 0.06, [8.6, 0, 0, 8.6]),\n  );\n  geometry.part('base', 3, () => geometry.face(0, -55.395, 78.75, 110.79, 0.06, [0, 8.6, 8.6, 0]));\n  // A single primitive spans the OLED when flat, eliminating even a raster seam.\n  geometry.part('flat', 3, () => geometry.face(-78.75, -55.395, 157.5, 110.79, 0.06, circle(8.6)));\n  geometry.part('wing', 5, () =>\n    geometry.face(-80.3, -56.095, 77.1, 112.19, -5.24, [9, 0.9, 0.9, 9], true),\n  );\n  geometry.part('wing', 2, () => geometry.face(-74.4, -50.4, 6.2, 6.2, -5.26, circle(3.1), true));\n  geometry.part('wing', 4, () =>\n    geometry.face(-72.75, -48.75, 2.9, 2.9, -5.28, circle(1.45), true),\n  );\n\n  // Only the top and bottom bezel contain hinge knuckles; the OLED has no bar.\n  geometry.part('base', 0, () => {\n    for (const y of [-59.15, 57.25])\n      geometry.solid(-1.8, y, 3.6, 1.9, 0.12, 5.8, circle(0.8), 0.25);\n  });\n  geometry.part('base', 2, () => {\n    for (const y of [-58.75, 57.65]) geometry.face(-0.2, y, 0.4, 0.9, 0.13, circle(0.2));\n  });\n\n  // Rear camera plateau, polished rings, protective glass, and recessed optics.\n  geometry.part('base', 0, () =>\n    geometry.solid(-74.8, -51.9, 69, 27, 9, 3.8, circle(13.5), 0.55, back),\n  );\n  geometry.part('base', 1, () =>\n    geometry.face(-74.35, -51.45, 68.1, 26.1, 9.015, circle(13.05), false, back),\n  );\n\n  for (const x of [-72.3, -48.3]) {\n    geometry.part('base', 0, () =>\n      geometry.solid(x, -49.15, 21.5, 21.5, 10.5, 1.5, circle(10.75), 0.35, back),\n    );\n    geometry.part('base', 2, () =>\n      geometry.face(x + 0.85, -48.3, 19.8, 19.8, 10.515, circle(9.9), false, back),\n    );\n    geometry.part('base', 4, () =>\n      geometry.face(x + 2.1, -47.05, 17.3, 17.3, 10.53, circle(8.65), false, back),\n    );\n  }\n\n  geometry.part('base', 2, () =>\n    geometry.face(-19.25, -45, 2.5, 1.5, 9.03, circle(0.75), false, back),\n  );\n  geometry.part('base', 6, () =>\n    geometry.face(-20.1, -35.7, 4.2, 4.2, 9.03, circle(2.1), false, back),\n  );\n  geometry.part('base', 7, () => geometry.face(-48.15, 0.1, 14, 17, 5.24, circle(0), false, back));\n\n  // Side controls sit on the actual titanium wall, rather than floating above it.\n  const right = multiply(translation(82.4, 0, -2.6), rotation(Math.PI / 2, 'y'));\n  geometry.part('base', 0, () => {\n    geometry.solid(-1.4, -23, 2.8, 17, 0.05, 0.35, circle(1.4), 0.12, right);\n    geometry.solid(-1.4, 21, 2.8, 14, 0.05, 0.35, circle(1.4), 0.12, right);\n  });\n  geometry.part('base', 2, () =>\n    geometry.face(-0.95, 22, 1.9, 12, 0.06, circle(0.8), false, right),\n  );\n  const top = multiply(translation(0, -59.05, -2.6), rotation(Math.PI / 2, 'x'));\n  geometry.part('base', 0, () => {\n    for (const x of [39, 52]) geometry.solid(x, -1.4, 10.5, 2.8, 0.1, 0.4, circle(1.4), 0.13, top);\n  });\n  const bottom = multiply(translation(0, 58.95, -2.6), rotation(-Math.PI / 2, 'x'));\n  geometry.part('base', 2, () => {\n    geometry.face(35.9, -1.05, 10.3, 2.1, 0.02, circle(1.05), false, bottom);\n\n    for (const x of [15, 18, 21, 24, 60, 63, 66, 69])\n      geometry.face(x, -0.55, 1.1, 1.1, 0.02, circle(0.55), false, bottom);\n  });\n  geometry.part('base', 0, () =>\n    geometry.face(37, -0.22, 8.1, 0.44, 0.04, circle(0.22), false, bottom),\n  );\n  const wingTop = multiply(translation(0, -58.95, -2.6), rotation(Math.PI / 2, 'x'));\n  geometry.part('wing', 2, () => {\n    for (let x = -49; x <= -34; x += 2.5)\n      geometry.face(x, -0.45, 1.15, 0.9, 0.02, circle(0.45), false, wingTop);\n  });\n\n  // Flush antenna breaks stay on the side wall, clear of the display.\n  for (const [group, x] of [\n    ['base', 82.32],\n    ['wing', -82.32],\n  ] as const) {\n    const wall = multiply(\n      translation(x, 0, -2.6),\n      rotation(x > 0 ? Math.PI / 2 : -Math.PI / 2, 'y'),\n    );\n\n    geometry.part(group, 2, () => {\n      for (const y of [-42, 42])\n        geometry.face(-2.12, y, 4.24, 0.42, 0.02, circle(0.12), false, wall);\n    });\n  }\n\n  return geometry;\n}\n\nconst vertexSource = `#version 300 es\nprecision highp float;\nin vec3 aPosition;\nin vec3 aNormal;\nin vec2 aUv;\nuniform mat4 uModel;\nout vec3 vNormal;\nout vec3 vPosition;\nout vec3 vLocal;\nout vec2 vUv;\nvoid main() {\n  vec3 p = (uModel * vec4(aPosition, 1.)).xyz;\n  vPosition = p;\n  vLocal = aPosition;\n  vNormal = mat3(uModel) * aNormal;\n  vUv = aUv;\n  gl_Position = vec4(p.x / 110., -p.y / 89., -p.z / 350., (700. - p.z) / 700.);\n}`;\n\nconst fragmentSource = `#version 300 es\nprecision highp float;\nuniform int uMaterial;\nuniform float uWhite;\nuniform float uGlare;\nuniform float uLift;\nuniform vec2 uViewport;\nuniform mat3 uPlane;\nuniform sampler2D uInner;\nuniform sampler2D uCover;\nuniform sampler2D uLogo;\nin vec3 vNormal;\nin vec3 vPosition;\nin vec3 vLocal;\nin vec2 vUv;\nout vec4 color;\nvoid main() {\n  vec3 normal = normalize(vNormal);\n  vec3 eye = normalize(vec3(0., 0., 700.) - vPosition);\n  float diffuse = max(0., dot(normal, normalize(vec3(-.4, -.6, 1.))));\n  float rim = pow(1. - abs(dot(normal, eye)), 2.);\n  if (uMaterial == 3 || uMaterial == 5) {\n    vec2 ndc = gl_FragCoord.xy / uViewport * 2. - 1.;\n    vec3 mapped = uPlane * vec3(ndc, 1.);\n    vec2 uv = mapped.xy / mapped.z;\n    // Explicit gradients keep the flat screenshot's mip level continuous even\n    // across the very narrow triangles that join the OLED around its pivot.\n    vec3 nextX = uPlane * vec3(ndc + vec2(2. / uViewport.x, 0.), 1.);\n    vec3 nextY = uPlane * vec3(ndc + vec2(0., 2. / uViewport.y), 1.);\n    vec2 gradientX = nextX.xy / nextX.z - uv;\n    vec2 gradientY = nextY.xy / nextY.z - uv;\n    float across = uMaterial == 3 ? 1. - vUv.x : vUv.x;\n    float radius = uLift * pow(across, 1.35) * .046;\n    vec3 sampleColor;\n    if (radius > .0001) {\n      sampleColor = vec3(0.);\n      vec2 texel = 1. / vec2(uMaterial == 3 ? textureSize(uInner, 0) : textureSize(uCover, 0));\n      float lod = max(0., log2(max(radius / max(texel.x, texel.y) / 3., 1.)));\n      for (int j = 0; j < 28; j++) {\n        float angle = float(j) * 2.39996323;\n        float reach = sqrt((float(j) + .5) / 28.) * radius;\n        vec2 offset = vec2(cos(angle), sin(angle) * 1.42) * reach;\n        sampleColor += uMaterial == 3 ? textureLod(uInner, uv + offset, lod).rgb : textureLod(uCover, uv + offset, lod).rgb;\n      }\n      sampleColor /= 28.;\n    } else {\n      sampleColor = uMaterial == 3 ? textureGrad(uInner, uv, gradientX, gradientY).rgb : textureGrad(uCover, uv, gradientX, gradientY).rgb;\n    }\n    // The intro's frosting, dimming and travelling sheen; the source stays flat.\n    float dim = uLift * pow(clamp((across - .3) / .7, 0., 1.), 1.35);\n    sampleColor *= 1. - .85 * dim;\n    float sheen = exp(-pow((across - 1. + uLift) / .22, 2.)) * uLift * (1. - uLift) * .4;\n    sheen *= smoothstep(0., .08, across);\n    float edge = uMaterial == 5 ? pow(abs(normal.x), 1.8) * .035 : 0.;\n    sampleColor += (sheen + edge) * uGlare;\n    color = vec4(sampleColor, 1.);\n    return;\n  }\n  if (uMaterial == 7) {\n    float ink = texture(uLogo, vUv).a;\n    if (ink < .01) discard;\n    color = vec4(mix(vec3(.20, .25, .32), vec3(.63, .64, .65), uWhite) * (.8 + diffuse * .2) * ink, ink);\n    return;\n  }\n  vec3 metal = mix(vec3(.22, .27, .34), vec3(.62, .64, .67), uWhite);\n  vec3 surface;\n  if (uMaterial == 0) {\n    float reflected = .5 + .5 * sin(normal.y * 8. + normal.z * 7. + normal.x * 2.);\n    float polished = pow(max(0., dot(reflect(-normalize(vec3(-.35, -.7, 1.)), normal), eye)), 40.);\n    surface = metal * (.45 + diffuse * .4 + reflected * .65) + vec3(.48) * polished + rim * .12;\n  } else if (uMaterial == 1) {\n    surface = mix(vec3(.20, .25, .32), vec3(.87, .87, .85), uWhite) * (.85 + diffuse * .12);\n    surface += pow(max(0., dot(reflect(-normalize(vec3(-.4, -.6, 1.)), normal), eye)), 10.) * .035;\n    surface *= 1. - (vLocal.y + 59.) / 118. * .06;\n    if (vLocal.z > -5.3) {\n      vec2 delta = vec2(max(abs(vLocal.x - 40.3) - 21., 0.), vLocal.y + 38.4);\n      float shadow = exp(-max(length(delta) - 13.5, 0.) / 1.8) * .16;\n      surface *= 1. - shadow;\n    }\n  } else if (uMaterial == 4) {\n    vec2 p = (vUv - .5) * 2.;\n    float r = length(p);\n    float iris = exp(-pow((r - .29) / .05, 2.));\n    float highlight = exp(-dot(p - vec2(-.31, -.39), p - vec2(-.31, -.39)) * 160.);\n    float pupil = 1. - smoothstep(.09, .16, r);\n    surface = vec3(.018, .027, .05) + vec3(.04, .085, .14) * iris;\n    surface *= 1. - pupil * .65;\n    surface += vec3(.32, .43, .62) * highlight;\n    surface += vec3(.014, .025, .05) * exp(-pow((r - .76) / .03, 2.));\n  } else if (uMaterial == 6) {\n    surface = mix(vec3(.68, .66, .60), vec3(.96, .94, .85), .7 + .3 * cos(length(vUv - .5) * 12.));\n  } else {\n    surface = vec3(.014, .017, .021) + diffuse * .008 + rim * .012;\n  }\n  color = vec4(surface, 1.);\n}`;\n\nexport function createPhoneDuoRenderer(canvas: HTMLCanvasElement) {\n  const gl = canvas.getContext('webgl2', {\n    alpha: true,\n    antialias: true,\n    premultipliedAlpha: true,\n  });\n\n  if (!gl) throw new Error('WebGL 2 unavailable');\n\n  function shader(type: number, source: string) {\n    const result = gl!.createShader(type)!;\n    gl!.shaderSource(result, source);\n    gl!.compileShader(result);\n\n    if (!gl!.getShaderParameter(result, gl!.COMPILE_STATUS)) {\n      const message = gl!.getShaderInfoLog(result);\n      gl!.deleteShader(result);\n      throw new Error(message ?? 'Phone shader failed');\n    }\n\n    return result;\n  }\n\n  const vertex = shader(gl.VERTEX_SHADER, vertexSource);\n  const fragment = shader(gl.FRAGMENT_SHADER, fragmentSource);\n  const program = gl.createProgram()!;\n  gl.attachShader(program, vertex);\n  gl.attachShader(program, fragment);\n  gl.linkProgram(program);\n  gl.deleteShader(vertex);\n  gl.deleteShader(fragment);\n\n  if (!gl.getProgramParameter(program, gl.LINK_STATUS))\n    throw new Error('Phone shader linking failed');\n\n  const names = [\n    'uModel',\n    'uMaterial',\n    'uWhite',\n    'uGlare',\n    'uLift',\n    'uViewport',\n    'uPlane',\n    'uInner',\n    'uCover',\n    'uLogo',\n  ] as const;\n\n  // SAFETY: Every key comes from the literal uniform names declared in these shaders.\n  const uniforms = Object.fromEntries(\n    names.map((name) => [name, gl.getUniformLocation(program, name)]),\n  ) as Record<(typeof names)[number], WebGLUniformLocation>;\n\n  const geometry = deviceGeometry();\n  const buffer = gl.createBuffer()!;\n  const vao = gl.createVertexArray()!;\n  gl.bindVertexArray(vao);\n  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(geometry.values), gl.STATIC_DRAW);\n\n  for (const [name, size, offset] of [\n    ['aPosition', 3, 0],\n    ['aNormal', 3, 12],\n    ['aUv', 2, 24],\n  ] as const) {\n    const attribute = gl.getAttribLocation(program, name);\n    gl.enableVertexAttribArray(attribute);\n    gl.vertexAttribPointer(attribute, size, gl.FLOAT, false, 32, offset);\n  }\n\n  // A continuous OLED joins the leaves around the pivot. Its samples still\n  // come from the flat plane; there is no seam texture or screen hinge overlay.\n  const joinBuffer = gl.createBuffer()!;\n  const joinVao = gl.createVertexArray()!;\n  const join = new Float32Array(12 * 6 * 8);\n  gl.bindVertexArray(joinVao);\n  gl.bindBuffer(gl.ARRAY_BUFFER, joinBuffer);\n  gl.bufferData(gl.ARRAY_BUFFER, join.byteLength, gl.DYNAMIC_DRAW);\n\n  for (const [name, size, offset] of [\n    ['aPosition', 3, 0],\n    ['aNormal', 3, 12],\n    ['aUv', 2, 24],\n  ] as const) {\n    const attribute = gl.getAttribLocation(program, name);\n    gl.enableVertexAttribArray(attribute);\n    gl.vertexAttribPointer(attribute, size, gl.FLOAT, false, 32, offset);\n  }\n\n  const textures = Array.from({ length: 3 }, () => gl.createTexture()!);\n  textures.forEach((texture, index) => {\n    gl.activeTexture(gl.TEXTURE0 + index);\n    gl.bindTexture(gl.TEXTURE_2D, texture);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n    gl.texImage2D(\n      gl.TEXTURE_2D,\n      0,\n      gl.RGBA,\n      1,\n      1,\n      0,\n      gl.RGBA,\n      gl.UNSIGNED_BYTE,\n      new Uint8Array([0, 0, 0, 255]),\n    );\n    gl.generateMipmap(gl.TEXTURE_2D);\n  });\n  const logo = document.createElement('canvas');\n  logo.width = 256;\n  logo.height = 312;\n  const ink = logo.getContext('2d')!;\n  ink.scale(256 / 14, 312 / 17);\n  ink.translate(0, -12);\n  ink.fillStyle = '#fff';\n  ink.fill(\n    new Path2D(\n      'm13.0729 17.6825a3.61 3.61 0 0 0-1.7248 3.0365 3.5132 3.5132 0 0 0 2.1379 3.2223 8.394 8.394 0 0 1-1.0948 2.2618c-.6816.9812-1.3943 1.9623-2.4787 1.9623s-1.3633-.63-2.613-.63c-1.2187 0-1.6525.6507-2.644.6507s-1.6834-.9089-2.4787-2.0243a9.7842 9.7842 0 0 1-1.6628-5.2776c0-3.0984 2.014-4.7405 3.9969-4.7405 1.0535 0 1.9314.6919 2.5924.6919.63 0 1.6112-.7333 2.8092-.7333a3.7579 3.7579 0 0 1 3.1604 1.5802zm-3.7284-2.8918a3.5615 3.5615 0 0 0 .8469-2.22 1.5353 1.5353 0 0 0-.031-.32 3.5686 3.5686 0 0 0-2.3445 1.2084 3.4629 3.4629 0 0 0-.8779 2.1585 1.419 1.419 0 0 0 .031.2892 1.19 1.19 0 0 0 .2169.0207 3.0935 3.0935 0 0 0 2.1586-1.1368z',\n    ),\n  );\n  gl.bindTexture(gl.TEXTURE_2D, textures[2]);\n  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, logo);\n  gl.generateMipmap(gl.TEXTURE_2D);\n  gl.enable(gl.DEPTH_TEST);\n  gl.depthFunc(gl.LEQUAL);\n  gl.enable(gl.CULL_FACE);\n  // Projection has a downward Y axis to share the screenshot's coordinates.\n  gl.frontFace(gl.CW);\n  gl.enable(gl.BLEND);\n  gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);\n\n  return {\n    setImage(index: number, image: HTMLImageElement) {\n      gl.activeTexture(gl.TEXTURE0 + index);\n      gl.bindTexture(gl.TEXTURE_2D, textures[index]);\n      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);\n      gl.generateMipmap(gl.TEXTURE_2D);\n    },\n    draw({ fold, turn, white, glare }: Pose) {\n      const ratio = Math.min(3, Math.max(2, window.devicePixelRatio));\n      const width = Math.min(4096, Math.round(canvas.clientWidth * ratio));\n      const height = Math.min(4096, Math.round(canvas.clientHeight * ratio));\n\n      if (!width || !height) return;\n\n      if (canvas.width !== width || canvas.height !== height) {\n        canvas.width = width;\n        canvas.height = height;\n      }\n\n      const closing = Math.max(0, Math.min(1, 1 - fold)) ** 1.25;\n      const angle = closing * Math.PI;\n\n      const root = multiply(\n        rotation((-7 * Math.PI) / 180, 'x'),\n        multiply(rotation((turn * Math.PI) / 180, 'y'), translation(-closing * 41.15, 0, 0)),\n      );\n\n      const wing = multiply(root, hinge(angle));\n      const innerPlane = planeMapping(root, -78.75, -55.395, 157.5, 110.79, 0.06);\n\n      const coverReference = multiply(\n        root,\n        hinge(Math.cos((turn * Math.PI) / 180) >= 0 ? Math.PI : 0),\n      );\n\n      // The cover faces backwards while unfolded and forwards when closed.\n      const coverPlane = planeMapping(coverReference, -3.2, -56.095, -77.1, 112.19, -5.24);\n      const flat = closing < 0.000001;\n      gl.viewport(0, 0, width, height);\n      gl.clearColor(0, 0, 0, 0);\n      gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);\n      gl.useProgram(program);\n      gl.bindVertexArray(vao);\n      textures.forEach((texture, index) => {\n        gl.activeTexture(gl.TEXTURE0 + index);\n        gl.bindTexture(gl.TEXTURE_2D, texture);\n      });\n      gl.uniform1i(uniforms.uInner, 0);\n      gl.uniform1i(uniforms.uCover, 1);\n      gl.uniform1i(uniforms.uLogo, 2);\n      gl.uniform1f(uniforms.uWhite, Number(white));\n      gl.uniform1f(uniforms.uGlare, Number(glare));\n      gl.uniform2f(uniforms.uViewport, width, height);\n\n      for (const part of geometry.parts) {\n        if (part.group === 'flat' ? !flat : flat && part.material === 3) continue;\n        gl.uniformMatrix4fv(uniforms.uModel, false, part.group === 'wing' ? wing : root);\n        gl.uniform1i(uniforms.uMaterial, part.material);\n        gl.uniform1f(uniforms.uLift, part.group === 'wing' ? Math.sin(angle) : 0);\n        gl.uniformMatrix3fv(uniforms.uPlane, false, part.material === 5 ? coverPlane : innerPlane);\n        gl.drawArrays(gl.TRIANGLES, part.first, part.count);\n      }\n\n      if (!flat && closing < 1) {\n        let offset = 0;\n\n        const vertex = (a: number, y: number) => {\n          join.set(\n            [\n              -0.39 * Math.sin(a),\n              y,\n              0.45 - 0.39 * Math.cos(a),\n              Math.sin(a),\n              0,\n              Math.cos(a),\n              0.5,\n              (y + 55.395) / 110.79,\n            ],\n            offset,\n          );\n          offset += 8;\n        };\n\n        for (let step = 0; step < 12; step++) {\n          // Overlap the adjacent glass by a fraction of a pixel, so separate\n          // transforms cannot leave an antialiasing crack at their shared edge.\n          const reach = Math.min(Math.PI, angle + 0.2) + 0.2;\n          const a = -0.2 + (reach * step) / 12;\n          const b = -0.2 + (reach * (step + 1)) / 12;\n          vertex(a, -55.395);\n          vertex(a, 55.395);\n          vertex(b, -55.395);\n          vertex(a, 55.395);\n          vertex(b, 55.395);\n          vertex(b, -55.395);\n        }\n\n        gl.bindVertexArray(joinVao);\n        gl.bindBuffer(gl.ARRAY_BUFFER, joinBuffer);\n        gl.bufferSubData(gl.ARRAY_BUFFER, 0, join);\n        gl.uniformMatrix4fv(uniforms.uModel, false, root);\n        gl.uniform1i(uniforms.uMaterial, 3);\n        gl.uniform1f(uniforms.uLift, 0);\n        gl.uniformMatrix3fv(uniforms.uPlane, false, innerPlane);\n        gl.drawArrays(gl.TRIANGLES, 0, 12 * 6);\n      }\n    },\n    dispose() {\n      gl.deleteBuffer(buffer);\n      gl.deleteVertexArray(vao);\n      gl.deleteBuffer(joinBuffer);\n      gl.deleteVertexArray(joinVao);\n      textures.forEach((texture) => gl.deleteTexture(texture));\n      gl.deleteProgram(program);\n    },\n  };\n}\n",
      "type": "registry:lib",
      "target": "components/glassyui/lib/phone-duo-renderer.ts"
    },
    {
      "path": "registry/glassyui/styles/phone-duo.css",
      "content": "@property --lg-phone-duo-fold {\n  syntax: '<number>';\n  inherits: true;\n  initial-value: 1;\n}\n\n@property --lg-phone-duo-turn {\n  syntax: '<angle>';\n  inherits: true;\n  initial-value: -8deg;\n}\n\n@layer properties;\n@layer theme, base, components, utilities;\n\n@layer components {\n  .lg-phone-duo {\n    --lg-phone-duo-duration: 1200ms;\n    --lg-phone-duo-turn: -8deg;\n\n    width: 100%;\n    margin: 0;\n    transition:\n      --lg-phone-duo-fold var(--lg-phone-duo-duration) cubic-bezier(0.45, 0, 0.2, 1),\n      --lg-phone-duo-turn var(--lg-phone-duo-duration) cubic-bezier(0.45, 0, 0.2, 1);\n  }\n\n  .lg-phone-duo[data-view='back'] {\n    --lg-phone-duo-turn: 172deg;\n  }\n\n  .lg-phone-duo-body {\n    position: relative;\n    /* Includes room for the raised pane, rear cameras, and every rounded corner. */\n    aspect-ratio: 220 / 178;\n    isolation: isolate;\n  }\n\n  .lg-phone-duo-canvas {\n    position: relative;\n    display: block;\n    width: 100%;\n    height: 100%;\n    user-select: none;\n  }\n\n  .lg-phone-duo-shadow {\n    position: absolute;\n    inset: 77% 18% 7%;\n    border-radius: 50%;\n    background: radial-gradient(ellipse, rgb(16 23 35 / 0.13), transparent 68%);\n    filter: blur(12px);\n    scale: calc(0.55 + var(--lg-phone-duo-fold) * 0.45) 1;\n  }\n\n  .lg-phone-duo-error {\n    padding: 12px;\n    color: var(--lg-foreground, #222);\n    text-align: center;\n  }\n\n  .lg-phone-duo[data-manual],\n  html[data-lg-reduce-motion='1'] .lg-phone-duo {\n    --lg-phone-duo-duration: 0ms;\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    .lg-phone-duo {\n      --lg-phone-duo-duration: 0ms;\n    }\n  }\n}\n",
      "type": "registry:file",
      "target": "components/glassyui/styles/phone-duo.css"
    }
  ],
  "meta": {
    "sourceModules": [
      "phone-duo"
    ]
  },
  "type": "registry:ui"
}