{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"recording-card","type":"registry:block","title":"Recording Card","description":"Presents any content as a screen recording: wallpaper and scrim behind a window mock, a webcam bubble that doubles as the play control, a running timer with a blinking record dot, and a “tap for sound” pill that slides back behind the bubble once playing. Sized entirely in container-query units, so one card scales from grid thumbnail to full-width hero with no breakpoints — plus an optional aspect-locked theatre view.","author":"UI Lab","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/recording-card.tsx","type":"registry:component","target":"@components/motion/recording-card.tsx","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/recording-card\n\n// The container-query scaling, the hint pill that retracts behind the webcam\n// bubble, and the aspect-locked theatre expand are a clean-room reimplementation\n// of techniques observed on unabyss.com; no upstream source was used.\n\nimport { Maximize2, Volume2, X } from \"lucide-react\";\nimport { useReducedMotion } from \"motion/react\";\nimport { type CSSProperties, type ReactNode, useEffect, useId, useRef, useState } from \"react\";\nimport { EASE_OUT_CSS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface RecordingCardProps {\n  /** What is on the recorded screen — any node; it sits inside the window mock. */\n  children: ReactNode;\n  /** Window title in the mock's toolbar. */\n  title?: string;\n  /**\n   * The webcam circle: an `<img>`, a `<video>`, initials — anything. Omit for a\n   * plain screen recording. It deliberately overlaps the window's lower-left\n   * corner, the way a real recording bubble sits over the screen — so keep that\n   * corner of `children` free of anything that must stay readable.\n   */\n  bubble?: ReactNode;\n  /** Badge in the corner, e.g. which app the recording is in. */\n  skin?: { label: string; icon?: ReactNode };\n  /** Label on the pill that retracts behind the bubble once playing. */\n  hint?: string;\n  /** Any CSS `background` value behind the window. Defaults to a soft spectrum wash. */\n  wallpaper?: string;\n  /** Start already playing (timer running, hint retracted). */\n  defaultPlaying?: boolean;\n  onPlayingChange?: (playing: boolean) => void;\n  /** Offer the aspect-locked theatre view. */\n  expandable?: boolean;\n  className?: string;\n}\n\nconst DEFAULT_WALLPAPER =\n  \"linear-gradient(135deg, #1b1033 0%, #3f1d5c 22%, #8c3b6d 44%, #d1665a 66%, #e8a44f 84%, #f2d69b 100%)\";\n\n/**\n * Chrome text is kept legible with `clamp()` rather than pure `cqw`: the\n * composition should scale with the card, but a timer that shrinks to 5px at\n * small widths is just noise.\n */\nconst CARD_STYLES = `\n.uilab-rec{container-type:inline-size}\n.uilab-rec__hint{clip-path:inset(-30cqw -30cqw -30cqw 0)}\n.uilab-rec__mono{font-size:clamp(9px,1.1cqw,13px)}\n.uilab-rec__badge{font-size:clamp(9px,1.4cqw,14px)}\n.uilab-rec__hintlabel{font-size:clamp(10px,1.95cqw,18px)}\n@keyframes uilab-rec-blink{0%,100%{opacity:1}50%{opacity:.25}}\n.uilab-rec__dot{animation:uilab-rec-blink 1.4s ease-in-out infinite}\n@media (prefers-reduced-motion:reduce){.uilab-rec__dot{animation:none}}\n/* Theatre view: width is capped three ways, then height is derived so the frame\n   stays exactly 16:9 on whole pixels. */\n.uilab-rec--theatre{position:fixed;inset:0;margin:auto;z-index:60;--rec-w:min(88vw,150vh,1280px);width:var(--rec-w);height:round(up,calc(var(--rec-w) * 9 / 16),1px)}\n/* Container units scale everything proportionally, but proportion alone is not\n   good design at both extremes: a bubble that is 18% of a 320px thumbnail is\n   right, and 18% of a 1280px theatre is absurd. So the theatre restates the\n   chrome as a smaller share of a much larger card. Two-class selectors, so they\n   win over the utility classes without !important. */\n.uilab-rec--theatre .uilab-rec__frame{padding:5cqw 5cqw 6cqw}\n.uilab-rec--theatre .uilab-rec__bubble{width:11cqw;height:11cqw;border-width:.34cqw;left:2.6cqw;bottom:2.6cqw}\n.uilab-rec--theatre .uilab-rec__hint{left:8.4cqw;bottom:2.6cqw}\n.uilab-rec--theatre .uilab-rec__hintlabel{padding-left:6.2cqw;padding-right:1.4cqw;padding-top:.8cqw;padding-bottom:.8cqw;gap:.7cqw;font-size:clamp(11px,1.1cqw,16px)}\n.uilab-rec--theatre .uilab-rec__badge{right:2.6cqw;bottom:2.6cqw}\n`;\n\nexport function formatElapsed(seconds: number): string {\n  const m = Math.floor(seconds / 60);\n  const s = Math.floor(seconds % 60);\n  return `${m}:${s.toString().padStart(2, \"0\")}`;\n}\n\n/** Minimal window chrome. Option A: the shell owns a simple mock and leaves the screen to `children`. */\nfunction WindowMock({ title, children }: { title?: string; children: ReactNode }) {\n  return (\n    <div className=\"overflow-hidden rounded-[1.4cqw] border border-white/10 bg-neutral-950/90 shadow-[0_1.6cqw_4cqw_-1.4cqw_rgb(0_0_0/0.55)] backdrop-blur-sm\">\n      <header className=\"flex items-center gap-[1.4cqw] border-b border-white/8 px-[1.8cqw] py-[1.2cqw]\">\n        <span aria-hidden className=\"flex gap-[0.7cqw]\">\n          {[\"#FF5F57\", \"#FEBC2E\", \"#28C840\"].map((fill) => (\n            <i\n              key={fill}\n              className=\"block size-[1.2cqw] min-h-[4px] min-w-[4px] rounded-full\"\n              style={{ background: fill }}\n            />\n          ))}\n        </span>\n        {title ? (\n          <span className=\"uilab-rec__mono truncate font-medium text-white/70\">{title}</span>\n        ) : null}\n      </header>\n      <div className=\"min-h-[8cqw]\">{children}</div>\n    </div>\n  );\n}\n\n/**\n * Presents any content as a screen recording: wallpaper and scrim behind a\n * window mock, a webcam bubble in the corner, a running timer, and a\n * \"tap for sound\" pill that slides back behind the bubble once playing.\n *\n * The whole composition is sized in container-query units, so one card scales\n * from a grid thumbnail to a full-width hero with no breakpoints and no\n * transform hacks — every inner size, radius and gap is a share of the card's\n * own width.\n */\nexport function RecordingCard({\n  children,\n  title,\n  bubble,\n  skin,\n  hint = \"Tap for sound\",\n  wallpaper = DEFAULT_WALLPAPER,\n  defaultPlaying = false,\n  onPlayingChange,\n  expandable = false,\n  className,\n}: RecordingCardProps) {\n  const [playing, setPlaying] = useState(defaultPlaying);\n  const [expanded, setExpanded] = useState(false);\n  const [elapsed, setElapsed] = useState(0);\n  const reducedMotion = useReducedMotion();\n  const triggerRef = useRef<HTMLButtonElement | null>(null);\n  const labelId = useId();\n\n  useEffect(() => {\n    if (!playing) return;\n    const id = setInterval(() => setElapsed((value) => value + 1), 1000);\n    return () => clearInterval(id);\n  }, [playing]);\n\n  // Escape leaves the theatre, and focus goes back to the control that opened it.\n  useEffect(() => {\n    if (!expanded) return;\n    function onKeyDown(event: KeyboardEvent) {\n      if (event.key === \"Escape\") {\n        event.stopPropagation();\n        setExpanded(false);\n      }\n    }\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => document.removeEventListener(\"keydown\", onKeyDown);\n  }, [expanded]);\n\n  useEffect(() => {\n    if (!expanded) triggerRef.current?.focus();\n  }, [expanded]);\n\n  function togglePlaying() {\n    const next = !playing;\n    setPlaying(next);\n    onPlayingChange?.(next);\n  }\n\n  const pillTransition = reducedMotion\n    ? { transition: \"opacity 200ms linear\" }\n    : { transition: `transform 500ms ${EASE_OUT_CSS}, opacity 300ms linear` };\n\n  return (\n    <>\n      <style>{CARD_STYLES}</style>\n      {expanded ? (\n        <button\n          type=\"button\"\n          aria-label=\"Close theatre view\"\n          onClick={() => setExpanded(false)}\n          className=\"fixed inset-0 z-50 h-full w-full cursor-default bg-neutral-950/80 backdrop-blur-sm\"\n        />\n      ) : null}\n\n      <div\n        className={cn(\n          \"uilab-rec relative isolate overflow-hidden rounded-[0.8cqw] border border-white/10\",\n          expanded && \"uilab-rec--theatre\",\n          className,\n        )}\n      >\n        <div\n          aria-hidden\n          className=\"absolute inset-0 scale-[1.04]\"\n          style={{ background: wallpaper }}\n        />\n        {/* Two scrims: a radial to sink the corners, a linear to keep the top from\n            competing with the window's own contrast. */}\n        <div\n          aria-hidden\n          className=\"absolute -inset-px\"\n          style={{\n            background:\n              \"radial-gradient(120% 90% at 50% 0%, rgb(0 0 0 / 0.10), rgb(0 0 0 / 0.42) 70%), linear-gradient(rgb(0 0 0 / 0.15), rgb(0 0 0 / 0.35))\",\n          }}\n        />\n\n        <div className=\"uilab-rec__frame relative z-[1] px-[8.5cqw] pb-[12cqw] pt-[8cqw]\">\n          <WindowMock title={title}>{children}</WindowMock>\n        </div>\n\n        {/* Recording timer */}\n        <span\n          aria-hidden\n          className=\"uilab-rec__mono absolute left-[2.6cqw] top-[2.6cqw] z-[4] inline-flex items-center gap-[0.8cqw] rounded-full border border-white/20 bg-neutral-950/60 px-[1.4cqw] py-[0.7cqw] font-mono tabular-nums leading-none text-white backdrop-blur-sm\"\n        >\n          <i\n            className={cn(\n              \"block size-[0.9cqw] min-h-[4px] min-w-[4px] rounded-full bg-[#f0433a] shadow-[0_0_4px_rgb(240_67_58/0.7)]\",\n              playing && \"uilab-rec__dot\",\n            )}\n          />\n          {formatElapsed(elapsed)}\n        </span>\n\n        {expandable ? (\n          <button\n            ref={triggerRef}\n            type=\"button\"\n            aria-label={expanded ? \"Exit theatre view\" : \"Expand to theatre view\"}\n            onClick={() => setExpanded((value) => !value)}\n            className=\"absolute right-[2.6cqw] top-[2.6cqw] z-[5] grid size-[3cqw] min-h-[24px] min-w-[24px] place-items-center rounded-full border border-white/20 bg-neutral-950/30 text-white opacity-50 backdrop-blur-sm transition-opacity duration-200 hover:bg-neutral-950/80 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60\"\n          >\n            {expanded ? (\n              <X className=\"size-[55%]\" />\n            ) : (\n              <Maximize2 className=\"size-[55%]\" />\n            )}\n          </button>\n        ) : null}\n\n        {/* The pill is clipped on its left edge only, so when it slides left it\n            disappears *behind* the bubble instead of past the card's edge. */}\n        {bubble && hint ? (\n          <span\n            aria-hidden\n            className=\"uilab-rec__hint pointer-events-none absolute bottom-[4cqw] left-[13cqw] z-[2]\"\n          >\n            <span\n              className=\"uilab-rec__hintlabel inline-flex items-center gap-[1cqw] rounded-r-full border border-white/70 bg-white/95 py-[1.3cqw] pl-[10cqw] pr-[2.2cqw] font-medium leading-none whitespace-nowrap text-neutral-950\"\n              style={\n                {\n                  ...pillTransition,\n                  transform: playing ? \"translateX(-120%)\" : \"translateX(0)\",\n                  opacity: playing && reducedMotion ? 0 : 1,\n                } as CSSProperties\n              }\n            >\n              <Volume2 className=\"size-[2.5cqw] min-h-[10px] min-w-[10px]\" />\n              {hint}\n            </span>\n          </span>\n        ) : null}\n\n        {/* Webcam bubble doubles as the play control. */}\n        {bubble ? (\n          <button\n            type=\"button\"\n            id={labelId}\n            onClick={togglePlaying}\n            aria-pressed={playing}\n            aria-label={playing ? \"Pause recording\" : \"Play recording with sound\"}\n            className=\"uilab-rec__bubble absolute bottom-[4cqw] left-[4cqw] z-[3] size-[18cqw] overflow-hidden rounded-full border-[0.6cqw] border-white/90 bg-white/90 shadow-[0_1.2cqw_3cqw_-0.8cqw_rgb(0_0_0/0.7)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80\"\n          >\n            {bubble}\n          </button>\n        ) : null}\n\n        {skin ? (\n          <span\n            className={cn(\n              \"uilab-rec__badge absolute bottom-[4cqw] z-[4] inline-flex items-center gap-[1cqw] rounded-full border border-white/20 bg-neutral-950/60 px-[1.7cqw] py-[0.9cqw] font-medium leading-none whitespace-nowrap text-white backdrop-blur-sm\",\n              bubble ? \"right-[4cqw]\" : \"left-1/2 -translate-x-1/2\",\n            )}\n          >\n            {skin.icon}\n            {skin.label}\n          </span>\n        ) : null}\n      </div>\n    </>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}