{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "phone-frame",
  "title": "Phone Frame",
  "description": "iPhone-style device frame with a dynamic island and a screen slot — flat or lightly 3D-tilted, with rise, rotate-in, or float entrances and a subtle float loop.",
  "dependencies": [
    "remotion"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/phone-frame/index.tsx",
      "content": "\"use client\";\n\nimport type { CSSProperties, ReactNode } from \"react\";\nimport {\n  Easing,\n  getRemotionEnvironment,\n  Img,\n  interpolate,\n  OffthreadVideo,\n  staticFile,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport { type SnapCnTheme, useSnapCnTheme, withAlpha } from \"@/lib/snap-cn-ui\";\n\nexport type PhoneFrameVariant = \"flat\" | \"tilt\" | \"showcase\";\nexport type PhoneFrameEntrance = \"rise\" | \"rotate-in\" | \"float\";\n\nexport interface PhoneFrameProps {\n  /** Screen content. Falls back to `screenSrc`, then the ride-summary placeholder. */\n  children?: ReactNode;\n  /**\n   * Image *or* video to fill the screen when `children` is omitted. Videos\n   * (.mp4/.webm/.mov/.m4v) play via `<OffthreadVideo>`, images via `<Img>` —\n   * both cover-fit the screen.\n   */\n  screenSrc?: string;\n  /**\n   * `flat` faces the camera; `tilt` adds a static 3D rotateY; `showcase` is a\n   * cinematic crane move — the camera starts low at the bottom-left corner of\n   * the laid-back device, sweeps up the screen while zooming in, then pulls\n   * back and settles on a straight frontal view.\n   */\n  variant?: PhoneFrameVariant;\n  /** How the device enters the frame. */\n  entrance?: PhoneFrameEntrance;\n  /** Device shell color. Defaults to the theme's `foreground`. */\n  bezelColor?: string;\n  /** Screen fill behind the children. Defaults to the theme's `card`. */\n  screenColor?: string;\n  /** Design-system token overrides. */\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  /** Outer body corner radius. Defaults to real-device curvature. */\n  radius?: number;\n  /** Screen corner radius. Defaults to `radius - bezel` (concentric corners). */\n  screenRadius?: number;\n  /** CSS box-shadow under the device. Empty string disables it. */\n  shadow?: string;\n  /** Uniform size multiplier for the whole device. */\n  scale?: number;\n  /** Render the dynamic-island cutout over the screen. */\n  showDynamicIsland?: boolean;\n  /** Gentle vertical bob after the entrance settles. */\n  floatLoop?: boolean;\n  /** Peak float displacement in pixels. */\n  floatAmplitude?: number;\n  /** rotateY angle (degrees) used by the `tilt` variant. */\n  tiltAngle?: number;\n  speed?: number;\n  className?: string;\n}\n\nconst FONT_FAMILY =\n  'Inter, var(--font-geist-sans), -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif';\n\n/** Device shell size — matches the ~0.488 width:height ratio of a real iPhone. */\nexport const DEVICE_WIDTH = 320;\nexport const DEVICE_HEIGHT = 656;\n\n/** Shell thickness between the body edge and the screen. */\nexport const BEZEL_WIDTH = 12;\n\n/** Outer body curvature: real devices sit near 16.5% of the body width. */\nexport const DEVICE_RADIUS_RATIO = 0.165;\n\n/** Frames the entrance takes before the device is fully settled. */\nexport const ENTRANCE_FRAMES = 30;\n\n/** Seconds per full float-loop cycle. */\nexport const FLOAT_PERIOD_SECONDS = 4.5;\n\n/** Default outer radius for a given device width (real-device curvature). */\nexport function deviceRadius(deviceWidth: number): number {\n  return Math.round(deviceWidth * DEVICE_RADIUS_RATIO);\n}\n\n/**\n * Concentric-corner rule: the screen radius is the body radius minus the\n * bezel thickness, floored so tiny radii never go negative.\n */\nexport function screenRadiusFor(\n  radius: number,\n  bezel: number = BEZEL_WIDTH,\n): number {\n  return Math.max(4, radius - bezel);\n}\n\nexport interface EntrancePose {\n  opacity: number;\n  translateY: number;\n  rotateDeg: number;\n  scale: number;\n}\n\n/**\n * Pure entrance schedule. All three entrances settle to the identity pose by\n * `ENTRANCE_FRAMES`, so the float loop can take over without a seam.\n */\nexport function entrancePose(\n  frame: number,\n  entrance: PhoneFrameEntrance,\n): EntrancePose {\n  const clamp = {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  } as const;\n  const easeOut = Easing.out(Easing.cubic);\n\n  if (entrance === \"rotate-in\") {\n    return {\n      opacity: interpolate(frame, [0, 12], [0, 1], clamp),\n      translateY: interpolate(frame, [0, 28], [60, 0], {\n        ...clamp,\n        easing: easeOut,\n      }),\n      rotateDeg: interpolate(frame, [0, 28], [-8, 0], {\n        ...clamp,\n        easing: easeOut,\n      }),\n      scale: interpolate(frame, [0, 28], [0.94, 1], {\n        ...clamp,\n        easing: easeOut,\n      }),\n    };\n  }\n\n  if (entrance === \"float\") {\n    return {\n      opacity: interpolate(frame, [0, 16], [0, 1], clamp),\n      translateY: 0,\n      rotateDeg: 0,\n      scale: interpolate(frame, [0, 24], [0.96, 1], {\n        ...clamp,\n        easing: easeOut,\n      }),\n    };\n  }\n\n  // rise (default)\n  return {\n    opacity: interpolate(frame, [0, 12], [0, 1], clamp),\n    translateY: interpolate(frame, [0, 26], [70, 0], {\n      ...clamp,\n      easing: easeOut,\n    }),\n    rotateDeg: 0,\n    scale: 1,\n  };\n}\n\n/**\n * Deterministic float-loop offset in pixels. Starts at 0 with an upward\n * drift at `startFrame`, so it blends seamlessly out of any entrance.\n */\nexport function floatOffset(\n  frame: number,\n  fps: number,\n  amplitude: number,\n  startFrame = 0,\n  periodSeconds: number = FLOAT_PERIOD_SECONDS,\n): number {\n  const local = Math.max(0, frame - startFrame);\n  const value =\n    Math.sin((local / (fps * periodSeconds)) * Math.PI * 2) * amplitude;\n  // Negative = upward drift; the `=== 0` guard avoids returning -0.\n  return value === 0 ? 0 : -value;\n}\n\n/** Side-button spec (position along the edge, length) in body pixels. */\nexport const SIDE_BUTTONS: Array<{\n  side: \"left\" | \"right\";\n  top: number;\n  length: number;\n}> = [\n  { side: \"left\", top: 118, length: 26 }, // action button\n  { side: \"left\", top: 166, length: 46 }, // volume up\n  { side: \"left\", top: 222, length: 46 }, // volume down\n  { side: \"right\", top: 176, length: 68 }, // power\n];\n\n// ─── Ride-summary demo screen ────────────────────────────────────────────────\n// A glowing GPS route draws itself across a dark map while stat \"insight\" pills\n// pop in along it — the default screen the phone shows off as it rotates.\n\n/** Screen interior (device minus bezel on both sides). */\nconst SCREEN_W = DEVICE_WIDTH - BEZEL_WIDTH * 2; // 296\nconst SCREEN_H = DEVICE_HEIGHT - BEZEL_WIDTH * 2; // 632\n\n/** Route waypoints in screen space; a closed loop drawn head-first from [0]. */\nconst ROUTE: ReadonlyArray<readonly [number, number]> = [\n  [224, 450],\n  [172, 462],\n  [116, 460],\n  [70, 450],\n  [52, 412],\n  [74, 382],\n  [104, 388],\n  [134, 372],\n  [162, 380],\n  [192, 364],\n  [222, 372],\n  [248, 396],\n  [256, 424],\n  [244, 448],\n];\n\n/** Cumulative segment lengths of the closed polyline (last→first closes it). */\nfunction routeCumLengths(pts: ReadonlyArray<readonly [number, number]>) {\n  const cum = [0];\n  let total = 0;\n  for (let i = 0; i < pts.length; i++) {\n    const a = pts[i];\n    const b = pts[(i + 1) % pts.length];\n    total += Math.hypot(b[0] - a[0], b[1] - a[1]);\n    cum.push(total);\n  }\n  return { cum, total };\n}\n\nconst ROUTE_LEN = routeCumLengths(ROUTE);\n\n/** Point at fraction `f` (0..1) along the closed route — the draw head. */\nexport function pointAtFraction(\n  f: number,\n  pts: ReadonlyArray<readonly [number, number]> = ROUTE,\n): { x: number; y: number } {\n  const { cum, total } = pts === ROUTE ? ROUTE_LEN : routeCumLengths(pts);\n  const target = Math.max(0, Math.min(1, f)) * total;\n  for (let i = 0; i < pts.length; i++) {\n    if (cum[i + 1] >= target) {\n      const seg = cum[i + 1] - cum[i] || 1;\n      const t = (target - cum[i]) / seg;\n      const a = pts[i];\n      const b = pts[(i + 1) % pts.length];\n      return { x: a[0] + (b[0] - a[0]) * t, y: a[1] + (b[1] - a[1]) * t };\n    }\n  }\n  const last = pts[pts.length - 1];\n  return { x: last[0], y: last[1] };\n}\n\n/** Catmull-Rom → cubic-bezier smoothing of a closed point loop into an SVG `d`. */\nexport function smoothClosedPath(\n  pts: ReadonlyArray<readonly [number, number]>,\n): string {\n  const n = pts.length;\n  const p = (i: number) => pts[((i % n) + n) % n];\n  let d = `M ${p(0)[0]} ${p(0)[1]}`;\n  for (let i = 0; i < n; i++) {\n    const p0 = p(i - 1);\n    const p1 = p(i);\n    const p2 = p(i + 1);\n    const p3 = p(i + 2);\n    const c1x = p1[0] + (p2[0] - p0[0]) / 6;\n    const c1y = p1[1] + (p2[1] - p0[1]) / 6;\n    const c2x = p2[0] - (p3[0] - p1[0]) / 6;\n    const c2y = p2[1] - (p3[1] - p1[1]) / 6;\n    d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${p2[0]} ${p2[1]}`;\n  }\n  return `${d} Z`;\n}\n\nconst ROUTE_PATH = smoothClosedPath(ROUTE);\n\n/** An \"insight\" pill anchored to a point on the route. */\ninterface Pill {\n  title: string;\n  sub: string;\n  icon: \"climb\" | \"heart\" | \"trophy\" | \"bolt\" | \"flame\";\n  tint: string;\n  x: number;\n  y: number;\n  anchor: readonly [number, number];\n  appear: number;\n}\n\nconst PILLS: Pill[] = [\n  {\n    title: \"BIG CLIMB\",\n    sub: \"+993 ft\",\n    icon: \"climb\",\n    tint: \"#D2925F\",\n    x: 100,\n    y: 196,\n    anchor: [148, 372],\n    appear: 74,\n  },\n  {\n    title: \"SETTLED\",\n    sub: \"HR steady at 116\",\n    icon: \"heart\",\n    tint: \"#84B27C\",\n    x: 80,\n    y: 234,\n    anchor: [132, 374],\n    appear: 60,\n  },\n  {\n    title: \"NEW PR\",\n    sub: \"Fastest 5 mi\",\n    icon: \"trophy\",\n    tint: \"#ADA69E\",\n    x: 44,\n    y: 292,\n    anchor: [64, 398],\n    appear: 40,\n  },\n  {\n    title: \"FASTEST SPLIT\",\n    sub: \"23 mph avg\",\n    icon: \"bolt\",\n    tint: \"#ADA69E\",\n    x: 168,\n    y: 300,\n    anchor: [242, 378],\n    appear: 52,\n  },\n  {\n    title: \"SPRINT\",\n    sub: \"Hit 32 mph\",\n    icon: \"flame\",\n    tint: \"#CC6D5C\",\n    x: 158,\n    y: 402,\n    anchor: [232, 448],\n    appear: 22,\n  },\n];\n\n/** Small monochrome pill glyphs, tinted per pill. */\nfunction PillIcon({ kind, color }: { kind: Pill[\"icon\"]; color: string }) {\n  const stroke = {\n    width: 11,\n    height: 11,\n    viewBox: \"0 0 24 24\",\n    fill: \"none\",\n    stroke: color,\n    strokeWidth: 2.4,\n    strokeLinecap: \"round\" as const,\n    strokeLinejoin: \"round\" as const,\n  };\n  if (kind === \"climb\") {\n    return (\n      <svg aria-hidden {...stroke}>\n        <title>climb</title>\n        <path d=\"M7 17 17 7\" />\n        <path d=\"M8 7h9v9\" />\n      </svg>\n    );\n  }\n  if (kind === \"trophy\") {\n    return (\n      <svg aria-hidden {...stroke}>\n        <title>trophy</title>\n        <path d=\"M7 4h10v5a5 5 0 0 1-10 0z\" />\n        <path d=\"M7 6H4v1a3 3 0 0 0 3 3\" />\n        <path d=\"M17 6h3v1a3 3 0 0 1-3 3\" />\n        <path d=\"M9 20h6M12 14v6\" />\n      </svg>\n    );\n  }\n  if (kind === \"heart\") {\n    return (\n      <svg aria-hidden width={11} height={11} viewBox=\"0 0 24 24\" fill={color}>\n        <title>heart</title>\n        <path d=\"M12 21S3.5 15.4 3.5 9.4C3.5 6.4 5.6 4.5 8 4.5c1.8 0 3.2 1.1 4 2.4.8-1.3 2.2-2.4 4-2.4 2.4 0 4.5 1.9 4.5 4.9 0 6-8.5 11.6-8.5 11.6z\" />\n      </svg>\n    );\n  }\n  if (kind === \"bolt\") {\n    return (\n      <svg aria-hidden width={11} height={11} viewBox=\"0 0 24 24\" fill={color}>\n        <title>bolt</title>\n        <path d=\"M13 2 4 14h6l-1 8 9-12h-6z\" />\n      </svg>\n    );\n  }\n  return (\n    <svg aria-hidden width={11} height={11} viewBox=\"0 0 24 24\" fill={color}>\n      <title>flame</title>\n      <path d=\"M12 2c1 4 5 5 5 10a5 5 0 0 1-10 0c0-1.6.7-2.8 1.6-3.7.2 1.7 1.2 2.7 2.6 2.7-1-3 1-4 .8-9z\" />\n    </svg>\n  );\n}\n\n/** iOS-style status-bar right cluster: signal, wifi, battery. */\nfunction StatusIcons() {\n  return (\n    <div style={{ display: \"flex\", alignItems: \"center\", gap: 5 }}>\n      <svg aria-hidden width={16} height={11} viewBox=\"0 0 18 12\" fill=\"#fff\">\n        <title>signal</title>\n        <rect x=\"0\" y=\"8\" width=\"3\" height=\"4\" rx=\"1\" />\n        <rect x=\"5\" y=\"5\" width=\"3\" height=\"7\" rx=\"1\" />\n        <rect x=\"10\" y=\"2.5\" width=\"3\" height=\"9.5\" rx=\"1\" />\n        <rect x=\"15\" y=\"0\" width=\"3\" height=\"12\" rx=\"1\" opacity=\"0.4\" />\n      </svg>\n      <svg aria-hidden width={15} height={11} viewBox=\"0 0 16 12\" fill=\"#fff\">\n        <title>wifi</title>\n        <path d=\"M8 2.2c2.7 0 5.2 1 7 2.8l-1.5 1.6A7.8 7.8 0 0 0 8 4.4 7.8 7.8 0 0 0 2.5 6.6L1 5C2.8 3.2 5.3 2.2 8 2.2Z\" />\n        <path d=\"M8 6c1.5 0 2.9.6 4 1.6l-1.6 1.6A3.4 3.4 0 0 0 8 8.2c-.9 0-1.7.4-2.4 1L4 7.6A5.7 5.7 0 0 1 8 6Z\" />\n        <circle cx=\"8\" cy=\"10.4\" r=\"1.4\" />\n      </svg>\n      <div\n        style={{\n          display: \"flex\",\n          alignItems: \"center\",\n          gap: 1.5,\n        }}\n      >\n        <div\n          style={{\n            width: 20,\n            height: 10,\n            borderRadius: 3,\n            border: \"1px solid rgba(255,255,255,0.5)\",\n            padding: 1.5,\n          }}\n        >\n          <div\n            style={{\n              width: \"42%\",\n              height: \"100%\",\n              borderRadius: 1,\n              background: \"#fff\",\n            }}\n          />\n        </div>\n        <div\n          style={{\n            width: 1.5,\n            height: 4,\n            borderRadius: 1,\n            background: \"rgba(255,255,255,0.5)\",\n          }}\n        />\n      </div>\n    </div>\n  );\n}\n\n/** One stat column in the header. */\nfunction Stat({\n  value,\n  unit,\n  label,\n  accent,\n}: {\n  value: string;\n  unit: string;\n  label: string;\n  accent?: boolean;\n}) {\n  const color = accent ? \"#F0842E\" : \"#FFFFFF\";\n  const dim = accent ? \"rgba(240,132,46,0.85)\" : \"rgba(255,255,255,0.42)\";\n  return (\n    <div style={{ display: \"flex\", flexDirection: \"column\", gap: 3 }}>\n      <div style={{ display: \"flex\", alignItems: \"baseline\", gap: 2 }}>\n        <span\n          style={{\n            fontSize: 15,\n            fontWeight: 700,\n            color,\n            letterSpacing: \"-0.02em\",\n          }}\n        >\n          {value}\n        </span>\n        <span style={{ fontSize: 8, fontWeight: 600, color: dim }}>{unit}</span>\n      </div>\n      <span\n        style={{\n          fontSize: 7,\n          fontWeight: 700,\n          letterSpacing: \"0.06em\",\n          color: dim,\n        }}\n      >\n        {label}\n      </span>\n    </div>\n  );\n}\n\nexport interface ShowcasePose {\n  /** Device orientation (deg), applied inside the perspective. */\n  rotateX: number;\n  rotateY: number;\n  rotateZ: number;\n  /** Camera zoom + pan (screen px), applied outside the perspective. */\n  zoom: number;\n  x: number;\n  y: number;\n}\n\n/**\n * Keyframes of the `showcase` crane move, on normalized progress 0..1:\n * hold low at the bottom-left of the laid-back device → sweep up the screen\n * while zooming in → linger near the top → pull back to a straight frontal\n * view. Ends exactly at the identity pose, so a float loop can take over.\n */\nconst SHOWCASE_STOPS = [0, 0.1, 0.42, 0.62, 0.9, 1];\nconst SHOWCASE_KEYS: Record<keyof ShowcasePose, number[]> = {\n  rotateX: [52, 36, 26, 12, 1.5, 0],\n  rotateY: [28, 20, 10, 6, 1, 0],\n  rotateZ: [30, 14, 2, 0, 0, 0],\n  zoom: [2.6, 1.35, 2.75, 2.3, 1.04, 1],\n  x: [60, 30, 0, -10, 0, 0],\n  y: [-195, -40, 430, 380, 15, 0],\n};\n\n/**\n * Cinematic \"showcase\" pose at `frame`, normalized to the composition length\n * so the move always completes regardless of duration. Each keyframe is a\n * natural rest point, so per-segment ease-in-out reads as one continuous move.\n */\nexport function showcasePose(\n  frame: number,\n  durationInFrames: number,\n): ShowcasePose {\n  const p = Math.max(0, Math.min(1, frame / Math.max(1, durationInFrames - 1)));\n  const easing = Easing.inOut(Easing.cubic);\n  const at = (values: number[]) =>\n    interpolate(p, SHOWCASE_STOPS, values, { easing });\n  return {\n    rotateX: at(SHOWCASE_KEYS.rotateX),\n    rotateY: at(SHOWCASE_KEYS.rotateY),\n    rotateZ: at(SHOWCASE_KEYS.rotateZ),\n    zoom: at(SHOWCASE_KEYS.zoom),\n    x: at(SHOWCASE_KEYS.x),\n    y: at(SHOWCASE_KEYS.y),\n  };\n}\n\nconst CLAMP = { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" } as const;\n\n/** Default screen: a glowing ride-summary map that draws itself with insight pills. */\nfunction RideSummaryDemo({ frame }: { frame: number }) {\n  const ease = Easing.bezier(0.2, 0.6, 0.35, 1);\n  const draw = interpolate(frame, [12, 128], [0, 1], {\n    ...CLAMP,\n    easing: ease,\n  });\n  const fillIn = interpolate(frame, [70, 150], [0, 1], CLAMP);\n  const head = pointAtFraction(draw);\n  const hint = interpolate(frame, [0, 8, 70, 88], [0, 1, 1, 0], CLAMP);\n\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        overflow: \"hidden\",\n        backgroundColor: \"#0A0A0B\",\n        fontFamily: FONT_FAMILY,\n        textRendering: \"geometricPrecision\",\n        color: \"#fff\",\n      }}\n    >\n      {/* Ambient terrain bleed under the route */}\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          opacity: fillIn,\n          background:\n            \"radial-gradient(120% 55% at 54% 90%, rgba(214,124,54,0.32), transparent 60%), radial-gradient(70% 40% at 46% 62%, rgba(104,158,92,0.24), transparent 62%)\",\n        }}\n      />\n\n      {/* Route + glow + draw head */}\n      <svg\n        width={SCREEN_W}\n        height={SCREEN_H}\n        viewBox={`0 0 ${SCREEN_W} ${SCREEN_H}`}\n        style={{ position: \"absolute\", inset: 0 }}\n      >\n        <title>ride route</title>\n        <defs>\n          <linearGradient\n            id=\"pf-elev\"\n            x1=\"0\"\n            y1=\"340\"\n            x2=\"0\"\n            y2=\"470\"\n            gradientUnits=\"userSpaceOnUse\"\n          >\n            <stop offset=\"0\" stopColor=\"#7FA85E\" stopOpacity=\"0.32\" />\n            <stop offset=\"0.55\" stopColor=\"#BE9150\" stopOpacity=\"0.28\" />\n            <stop offset=\"1\" stopColor=\"#DE8138\" stopOpacity=\"0.42\" />\n          </linearGradient>\n          <filter id=\"pf-glow\" x=\"-40%\" y=\"-40%\" width=\"180%\" height=\"180%\">\n            <feGaussianBlur stdDeviation=\"4.5\" />\n          </filter>\n        </defs>\n\n        <path d={ROUTE_PATH} fill=\"url(#pf-elev)\" opacity={fillIn} />\n\n        {/* connectors from pills down to the route */}\n        {PILLS.map((p) => (\n          <line\n            key={`c-${p.title}`}\n            x1={p.anchor[0]}\n            y1={p.anchor[1]}\n            x2={p.x + 22}\n            y2={p.y + 30}\n            stroke=\"rgba(255,255,255,0.28)\"\n            strokeWidth={1}\n            opacity={interpolate(\n              frame,\n              [p.appear, p.appear + 10],\n              [0, 1],\n              CLAMP,\n            )}\n          />\n        ))}\n\n        {/* glow underlay + crisp stroke, both revealed by the draw */}\n        <path\n          d={ROUTE_PATH}\n          fill=\"none\"\n          stroke=\"#FFEBD6\"\n          strokeWidth={7}\n          strokeLinecap=\"round\"\n          opacity={0.55}\n          pathLength={1}\n          strokeDasharray={1}\n          strokeDashoffset={1 - draw}\n          filter=\"url(#pf-glow)\"\n        />\n        <path\n          d={ROUTE_PATH}\n          fill=\"none\"\n          stroke=\"#FFFFFF\"\n          strokeWidth={2.4}\n          strokeLinecap=\"round\"\n          pathLength={1}\n          strokeDasharray={1}\n          strokeDashoffset={1 - draw}\n        />\n\n        {/* anchor dots where pills attach */}\n        {PILLS.map((p) => (\n          <circle\n            key={`a-${p.title}`}\n            cx={p.anchor[0]}\n            cy={p.anchor[1]}\n            r={2.4}\n            fill=\"#EE8348\"\n            opacity={interpolate(\n              frame,\n              [p.appear, p.appear + 8],\n              [0, 0.95],\n              CLAMP,\n            )}\n          />\n        ))}\n\n        {/* draw head */}\n        {draw > 0 && (\n          <>\n            <circle\n              cx={head.x}\n              cy={head.y}\n              r={7}\n              fill=\"#FFFFFF\"\n              filter=\"url(#pf-glow)\"\n            />\n            <circle cx={head.x} cy={head.y} r={3.4} fill=\"#FFFFFF\" />\n          </>\n        )}\n      </svg>\n\n      {/* Insight pills */}\n      {PILLS.map((p) => {\n        const t = interpolate(frame, [p.appear, p.appear + 12], [0, 1], {\n          ...CLAMP,\n          easing: ease,\n        });\n        return (\n          <div\n            key={p.title}\n            style={{\n              position: \"absolute\",\n              left: p.x,\n              top: p.y,\n              display: \"flex\",\n              alignItems: \"center\",\n              gap: 6,\n              padding: \"4px 9px 4px 4px\",\n              borderRadius: 999,\n              background: \"rgba(26,24,23,0.82)\",\n              border: \"1px solid rgba(255,255,255,0.07)\",\n              opacity: t,\n              transform: `translateY(${(1 - t) * 6}px)`,\n            }}\n          >\n            <div\n              style={{\n                width: 18,\n                height: 18,\n                borderRadius: 999,\n                background: \"rgba(255,255,255,0.08)\",\n                display: \"flex\",\n                alignItems: \"center\",\n                justifyContent: \"center\",\n              }}\n            >\n              <PillIcon kind={p.icon} color={p.tint} />\n            </div>\n            <div\n              style={{\n                display: \"flex\",\n                flexDirection: \"column\",\n                lineHeight: 1.15,\n              }}\n            >\n              <span\n                style={{\n                  fontSize: 8.5,\n                  fontWeight: 700,\n                  letterSpacing: \"0.04em\",\n                  color: \"#F4F1EC\",\n                }}\n              >\n                {p.title}\n              </span>\n              <span style={{ fontSize: 8, color: \"rgba(232,227,220,0.6)\" }}>\n                {p.sub}\n              </span>\n            </div>\n          </div>\n        );\n      })}\n\n      {/* Status bar (split around the dynamic island) */}\n      <div\n        style={{\n          position: \"absolute\",\n          top: 14,\n          left: 18,\n          right: 16,\n          display: \"flex\",\n          justifyContent: \"space-between\",\n          alignItems: \"center\",\n        }}\n      >\n        <div style={{ display: \"flex\", alignItems: \"center\", gap: 5 }}>\n          <span style={{ fontSize: 13, fontWeight: 700 }}>9:41</span>\n          <svg\n            aria-hidden\n            width={12}\n            height={12}\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"#fff\"\n            strokeWidth={2}\n            strokeLinecap=\"round\"\n          >\n            <title>alerts off</title>\n            <path d=\"M6 8a6 6 0 0 1 9-5\" />\n            <path d=\"M18 8v5l2 3H8\" />\n            <path d=\"M10.5 19a2 2 0 0 0 3 0\" />\n            <path d=\"M3 3l18 18\" />\n          </svg>\n        </div>\n        <StatusIcons />\n      </div>\n\n      {/* Nav row */}\n      <div\n        style={{\n          position: \"absolute\",\n          top: 40,\n          left: 18,\n          right: 16,\n          display: \"flex\",\n          justifyContent: \"space-between\",\n          alignItems: \"center\",\n        }}\n      >\n        <span style={{ fontSize: 10, color: \"rgba(255,255,255,0.5)\" }}>\n          System voice\n        </span>\n        <span style={{ fontSize: 11, fontWeight: 600, color: \"#fff\" }}>\n          Save\n        </span>\n      </div>\n\n      {/* Athlete selector */}\n      <div\n        style={{\n          position: \"absolute\",\n          top: 60,\n          right: 16,\n          display: \"flex\",\n          alignItems: \"center\",\n          gap: 3,\n          fontSize: 12,\n          fontWeight: 600,\n        }}\n      >\n        Eddy\n        <svg\n          aria-hidden\n          width={9}\n          height={9}\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"#fff\"\n          strokeWidth={3}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        >\n          <title>expand</title>\n          <path d=\"M6 9l6 6 6-6\" />\n        </svg>\n      </div>\n\n      {/* Stats row + reset */}\n      <div\n        style={{\n          position: \"absolute\",\n          top: 86,\n          left: 18,\n          right: 16,\n          display: \"flex\",\n          alignItems: \"flex-start\",\n          justifyContent: \"space-between\",\n        }}\n      >\n        <div style={{ display: \"flex\", gap: 14 }}>\n          <Stat value=\"16.5\" unit=\"mph\" label=\"AVG\" />\n          <Stat value=\"147\" unit=\"bpm\" label=\"AVG HR •\" accent />\n          <Stat value=\"26.0\" unit=\"mph\" label=\"MAX\" />\n          <Stat value=\"606\" unit=\"ft\" label=\"ASCENT\" />\n        </div>\n        <div\n          style={{\n            width: 26,\n            height: 26,\n            borderRadius: 999,\n            background: \"rgba(255,255,255,0.1)\",\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n          }}\n        >\n          <svg\n            aria-hidden\n            width={13}\n            height={13}\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"#fff\"\n            strokeWidth={2}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          >\n            <title>reset</title>\n            <path d=\"M3 12a9 9 0 1 0 3-6.7L3 8\" />\n            <path d=\"M3 3v5h5\" />\n          </svg>\n        </div>\n      </div>\n\n      {/* Scrub hint */}\n      <div\n        style={{\n          position: \"absolute\",\n          bottom: 54,\n          left: 0,\n          right: 0,\n          textAlign: \"center\",\n          fontSize: 9,\n          color: \"rgba(255,255,255,0.34)\",\n          opacity: hint,\n        }}\n      >\n        Scrub along the bottom · Drag to explore\n      </div>\n    </div>\n  );\n}\n\nconst isVideo = (src: string) => /\\.(mp4|webm|mov|m4v)(\\?|$)/i.test(src);\n\n/**\n * A root-relative asset (`/showcase-mobile-videos/x.mp4`) is served at the\n * origin root by Next in the Player, but a server render serves `public/`\n * through `staticFile()` — so rewrite local paths only while rendering, and\n * pass http(s)/data/blob URLs straight through.\n */\nfunction resolveSrc(src: string): string {\n  const isLocal = src.startsWith(\"/\") && !src.startsWith(\"//\");\n  if (isLocal && getRemotionEnvironment().isRendering) {\n    return staticFile(src.replace(/^\\/+/, \"\"));\n  }\n  return src;\n}\n\n/** Fills the screen with an image or a video, fading and un-blurring it in. */\nfunction ScreenMedia({ src, frame }: { src: string; frame: number }) {\n  const t = interpolate(frame, [4, 22], [0, 1], CLAMP);\n  const style: CSSProperties = {\n    position: \"absolute\",\n    inset: 0,\n    width: \"100%\",\n    height: \"100%\",\n    // Tailwind's preflight sets `img { max-width: 100% }`, which can collapse a\n    // media element we size ourselves to 0px wide. Opt out. (motion-quality skill.)\n    maxWidth: \"none\",\n    objectFit: \"cover\",\n    opacity: t,\n    filter: `blur(${(1 - t) * 8}px)`,\n  };\n  const resolved = resolveSrc(src);\n  return isVideo(src) ? (\n    <OffthreadVideo src={resolved} muted style={style} />\n  ) : (\n    <Img src={resolved} style={style} />\n  );\n}\n\nexport function PhoneFrame({\n  children,\n  screenSrc,\n  variant = \"flat\",\n  entrance = \"rise\",\n  bezelColor,\n  screenColor,\n  theme,\n  mode,\n  radius,\n  screenRadius,\n  shadow,\n  scale = 1,\n  showDynamicIsland = true,\n  floatLoop = true,\n  floatAmplitude = 6,\n  tiltAngle = -12,\n  speed = 1,\n  className,\n}: PhoneFrameProps) {\n  const frame = useCurrentFrame() * speed;\n  const { fps, durationInFrames } = useVideoConfig();\n  // The screen carries the app, so it follows the app theme. The body and the\n  // Dynamic Island are a physical object: they take the dark end of the system\n  // whatever mode the screen is in.\n  const t = useSnapCnTheme(theme, mode);\n  const island = useSnapCnTheme(theme, \"dark\");\n  const body = bezelColor ?? t.foreground;\n  const glass = screenColor ?? t.card;\n  const drop = shadow ?? `0 24px 60px ${withAlpha(t.foreground, 0.18)}`;\n\n  const bodyRadius = radius ?? deviceRadius(DEVICE_WIDTH);\n  const innerRadius = screenRadius ?? screenRadiusFor(bodyRadius);\n\n  const pose = entrancePose(frame, entrance);\n  const floatStart = entrance === \"float\" ? 0 : ENTRANCE_FRAMES;\n  const bob = floatLoop\n    ? floatOffset(frame, fps, floatAmplitude, floatStart)\n    : 0;\n\n  const crane =\n    variant === \"showcase\" ? showcasePose(frame, durationInFrames) : null;\n\n  return (\n    <div\n      className={className}\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        fontFamily: FONT_FAMILY,\n      }}\n    >\n      {/* Entrance + float rig */}\n      <div\n        style={{\n          opacity: pose.opacity,\n          translate: `0px ${pose.translateY + bob}px`,\n          rotate: `${pose.rotateDeg}deg`,\n          scale: `${pose.scale * scale}`,\n        }}\n      >\n        {/* Camera: pan + zoom in screen space, outside the perspective (showcase only) */}\n        <div\n          style={{\n            transform: crane\n              ? `translate(${crane.x}px, ${crane.y}px) scale(${crane.zoom})`\n              : undefined,\n          }}\n        >\n          {/* Device orientation: crane pose (showcase), static rotateY (tilt), or flat */}\n          <div\n            style={{\n              transform: `perspective(1400px) rotateX(${\n                crane ? crane.rotateX : 0\n              }deg) rotateY(${\n                crane ? crane.rotateY : variant === \"tilt\" ? tiltAngle : 0\n              }deg) rotateZ(${crane ? crane.rotateZ : 0}deg)`,\n            }}\n          >\n            {/* Device body */}\n            <div\n              style={{\n                position: \"relative\",\n                width: DEVICE_WIDTH,\n                height: DEVICE_HEIGHT,\n                borderRadius: bodyRadius,\n                backgroundColor: body,\n                padding: BEZEL_WIDTH,\n                ...(shadow === \"\" ? {} : { boxShadow: drop }),\n              }}\n            >\n              {/* Side buttons */}\n              {SIDE_BUTTONS.map((button) => (\n                <div\n                  key={`${button.side}-${button.top}`}\n                  style={{\n                    position: \"absolute\",\n                    top: button.top,\n                    [button.side]: -3,\n                    width: 3,\n                    height: button.length,\n                    borderRadius: 2,\n                    backgroundColor: body,\n                  }}\n                />\n              ))}\n\n              {/* Screen */}\n              <div\n                style={{\n                  position: \"relative\",\n                  width: \"100%\",\n                  height: \"100%\",\n                  borderRadius: innerRadius,\n                  backgroundColor: glass,\n                  overflow: \"hidden\",\n                }}\n              >\n                {children ??\n                  (screenSrc ? (\n                    <ScreenMedia src={screenSrc} frame={frame} />\n                  ) : (\n                    <RideSummaryDemo frame={frame} />\n                  ))}\n\n                {/* Dynamic island */}\n                {showDynamicIsland && (\n                  <div\n                    style={{\n                      position: \"absolute\",\n                      top: 10,\n                      left: \"50%\",\n                      translate: \"-50% 0px\",\n                      width: 90,\n                      height: 26,\n                      borderRadius: 999,\n                      backgroundColor: island.background,\n                      zIndex: 10,\n                      display: \"flex\",\n                      alignItems: \"center\",\n                      justifyContent: \"flex-end\",\n                      paddingRight: 9,\n                    }}\n                  >\n                    {/* Camera lens */}\n                    <div\n                      style={{\n                        width: 9,\n                        height: 9,\n                        borderRadius: 999,\n                        backgroundColor: island.card,\n                        border: `1px solid ${island.border}`,\n                      }}\n                    />\n                  </div>\n                )}\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/phone-frame.tsx"
    }
  ],
  "type": "registry:component"
}