{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "three-dwall-calendar",
  "type": "registry:ui",
  "title": "Three D Wall Calendar",
  "description": "Wall-mounted calendar with physical depth — paper stack layers, page-flip transitions, cells that lift on hover with shadow.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/ruixenui/three-dwall-calendar.tsx",
      "content": "\"use client\";\n\nimport { useRef, useState, useMemo } from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * 3D Wall Calendar — a calendar that hangs on a wall.\n *\n * Paper stack beneath the card suggests physical pages.\n * Month transitions flip like tearing off a sheet.\n * Hovered days lift off the surface — shadow deepens,\n * the cell floats. Today is always slightly raised.\n * Select a day: its events appear below with depth.\n *\n * The depth IS the interface.\n */\n\n/* ── Types ── */\n\nexport interface WallEvent {\n  id: string;\n  title: string;\n  date: string; // \"YYYY-MM-DD\"\n}\n\nexport interface ThreeDWallCalendarProps {\n  events?: WallEvent[];\n  onAdd?: (event: WallEvent) => void;\n  onRemove?: (id: string) => void;\n  sound?: boolean;\n}\n\n/* ── Constants ── */\n\nconst CELL = 42;\nconst GAP = 2;\nconst STEP = CELL + GAP;\nconst DOW = [\"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\", \"Su\"];\n\n/* ── Helpers ── */\n\nfunction pad2(n: number): string {\n  return String(n).padStart(2, \"0\");\n}\n\nfunction toKey(y: number, m: number, d: number): string {\n  return `${y}-${pad2(m + 1)}-${pad2(d)}`;\n}\n\n/* ── Audio ── */\n\nlet _ctx: AudioContext | null = null;\nlet _buf: AudioBuffer | null = null;\n\nfunction audioCtx() {\n  if (!_ctx) {\n    _ctx = new (window.AudioContext ||\n      (window as unknown as { webkitAudioContext: typeof AudioContext })\n        .webkitAudioContext)();\n  }\n  if (_ctx.state === \"suspended\") _ctx.resume();\n  return _ctx;\n}\n\nfunction ensureBuf(ac: AudioContext): AudioBuffer {\n  if (_buf && _buf.sampleRate === ac.sampleRate) return _buf;\n  const rate = ac.sampleRate;\n  const len = Math.floor(rate * 0.003);\n  const buf = ac.createBuffer(1, len, rate);\n  const ch = buf.getChannelData(0);\n  for (let i = 0; i < len; i++) {\n    const t = i / len;\n    ch[i] = (Math.random() * 2 - 1) * (1 - t) ** 4;\n  }\n  _buf = buf;\n  return buf;\n}\n\nfunction playTick(last: React.MutableRefObject<number>) {\n  const now = performance.now();\n  if (now - last.current < 80) return;\n  last.current = now;\n  try {\n    const ac = audioCtx();\n    const buf = ensureBuf(ac);\n    const src = ac.createBufferSource();\n    const gain = ac.createGain();\n    src.buffer = buf;\n    src.playbackRate.value = 1.15;\n    gain.gain.value = 0.03;\n    src.connect(gain);\n    gain.connect(ac.destination);\n    src.start();\n  } catch {\n    /* silent */\n  }\n}\n\n/* ── Component ── */\n\nexport function ThreeDWallCalendar({\n  events: initialEvents = [],\n  onAdd,\n  onRemove,\n  sound = true,\n}: ThreeDWallCalendarProps) {\n  const [events, setEvents] = useState<WallEvent[]>(initialEvents);\n  const [month, setMonth] = useState(() => new Date().getMonth());\n  const [year, setYear] = useState(() => new Date().getFullYear());\n  const [selectedDay, setSelectedDay] = useState<number | null>(null);\n  const [hoveredDay, setHoveredDay] = useState<number | null>(null);\n  const [title, setTitle] = useState(\"\");\n  const [direction, setDirection] = useState(1);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const lastSound = useRef(0);\n\n  function tick() {\n    if (sound) playTick(lastSound);\n  }\n\n  /* ── Calendar math ── */\n\n  const daysInMonth = new Date(year, month + 1, 0).getDate();\n  const firstOffset = (new Date(year, month, 1).getDay() + 6) % 7;\n  const weeks = Math.ceil((firstOffset + daysInMonth) / 7);\n\n  const now = new Date();\n  const todayDay =\n    now.getMonth() === month && now.getFullYear() === year\n      ? now.getDate()\n      : null;\n\n  /* ── Event lookup ── */\n\n  const prefix = `${year}-${pad2(month + 1)}`;\n  const eventsByDay = useMemo(() => {\n    const map = new Map<number, WallEvent[]>();\n    for (const e of events) {\n      if (e.date.startsWith(prefix)) {\n        const d = parseInt(e.date.slice(8), 10);\n        if (d >= 1 && d <= daysInMonth) {\n          const arr = map.get(d) || [];\n          arr.push(e);\n          map.set(d, arr);\n        }\n      }\n    }\n    return map;\n  }, [events, prefix, daysInMonth]);\n\n  const eventDaySet = new Set(eventsByDay.keys());\n\n  /* ── Month label ── */\n\n  const monthName = new Date(year, month).toLocaleDateString(\"en-US\", {\n    month: \"long\",\n  });\n\n  /* ── Navigation ── */\n\n  function goMonth(delta: number) {\n    tick();\n    setDirection(delta);\n    let m = month + delta;\n    let y = year;\n    if (m < 0) {\n      m = 11;\n      y--;\n    } else if (m > 11) {\n      m = 0;\n      y++;\n    }\n    setMonth(m);\n    setYear(y);\n    setSelectedDay(null);\n    setHoveredDay(null);\n    setTitle(\"\");\n  }\n\n  /* ── Selected day ── */\n\n  const selEvents =\n    selectedDay !== null ? eventsByDay.get(selectedDay) || [] : [];\n  const selLabel =\n    selectedDay !== null\n      ? new Date(year, month, selectedDay).toLocaleDateString(\"en-US\", {\n          weekday: \"long\",\n          month: \"short\",\n          day: \"numeric\",\n        })\n      : \"\";\n\n  /* ── Add / Remove ── */\n\n  function handleAdd() {\n    const trimmed = title.trim();\n    if (!trimmed || selectedDay === null) return;\n    tick();\n    const ev: WallEvent = {\n      id: `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,\n      title: trimmed,\n      date: toKey(year, month, selectedDay),\n    };\n    setEvents((prev) => [...prev, ev]);\n    onAdd?.(ev);\n    setTitle(\"\");\n    inputRef.current?.focus();\n  }\n\n  function handleRemove(id: string) {\n    tick();\n    setEvents((prev) => prev.filter((e) => e.id !== id));\n    onRemove?.(id);\n  }\n\n  /* ── Event count ── */\n\n  function eventCount(d: number): number {\n    return eventsByDay.get(d)?.length || 0;\n  }\n\n  const gridW = 7 * STEP - GAP;\n\n  return (\n    <div style={{ position: \"relative\", paddingBottom: 8 }}>\n      {/* ── Paper stack layers ── */}\n      <div\n        className={cn(\n          \"bg-neutral-100 dark:bg-neutral-900\",\n          \"border border-t-0 border-neutral-100 dark:border-neutral-800/50\",\n        )}\n        style={{\n          position: \"absolute\",\n          bottom: 4,\n          left: 5,\n          right: 5,\n          height: 6,\n          borderRadius: \"0 0 16px 16px\",\n        }}\n      />\n      <div\n        className={cn(\n          \"bg-neutral-100 dark:bg-neutral-900\",\n          \"border border-t-0 border-neutral-100/50 dark:border-neutral-800/30\",\n        )}\n        style={{\n          position: \"absolute\",\n          bottom: 0,\n          left: 10,\n          right: 10,\n          height: 6,\n          borderRadius: \"0 0 14px 14px\",\n        }}\n      />\n\n      {/* ── Main card ── */}\n      <div\n        className={cn(\n          \"bg-neutral-50 dark:bg-neutral-950\",\n          \"border border-neutral-200 dark:border-neutral-800\",\n          \"shadow-xl dark:shadow-[0_16px_40px_rgba(0,0,0,0.35)]\",\n        )}\n        style={{\n          position: \"relative\",\n          borderRadius: 20,\n          overflow: \"hidden\",\n          width: \"fit-content\",\n        }}\n      >\n        {/* Header */}\n        <div\n          style={{\n            display: \"flex\",\n            justifyContent: \"space-between\",\n            alignItems: \"baseline\",\n            padding: \"24px 26px 18px\",\n          }}\n        >\n          <motion.button\n            whileTap={{ scale: 0.85 }}\n            onClick={() => goMonth(-1)}\n            className={cn(\n              \"text-neutral-400 dark:text-neutral-600\",\n              \"hover:text-neutral-700 dark:hover:text-neutral-300\",\n              \"hover:bg-neutral-100 dark:hover:bg-neutral-800/50\",\n              \"transition-colors duration-150\",\n            )}\n            style={{\n              background: \"transparent\",\n              border: \"none\",\n              cursor: \"pointer\",\n              fontSize: 16,\n              lineHeight: 1,\n              padding: \"6px 10px\",\n              borderRadius: 8,\n            }}\n          >\n            ‹\n          </motion.button>\n\n          <div\n            style={{\n              display: \"flex\",\n              alignItems: \"baseline\",\n              gap: 8,\n            }}\n          >\n            <span\n              className=\"text-neutral-900 dark:text-neutral-100\"\n              style={{\n                fontSize: 18,\n                fontWeight: 650,\n                letterSpacing: \"-0.02em\",\n              }}\n            >\n              {monthName}\n            </span>\n            <span\n              className=\"text-neutral-400 dark:text-neutral-600\"\n              style={{\n                fontSize: 13,\n                fontWeight: 400,\n              }}\n            >\n              {year}\n            </span>\n          </div>\n\n          <motion.button\n            whileTap={{ scale: 0.85 }}\n            onClick={() => goMonth(1)}\n            className={cn(\n              \"text-neutral-400 dark:text-neutral-600\",\n              \"hover:text-neutral-700 dark:hover:text-neutral-300\",\n              \"hover:bg-neutral-100 dark:hover:bg-neutral-800/50\",\n              \"transition-colors duration-150\",\n            )}\n            style={{\n              background: \"transparent\",\n              border: \"none\",\n              cursor: \"pointer\",\n              fontSize: 16,\n              lineHeight: 1,\n              padding: \"6px 10px\",\n              borderRadius: 8,\n            }}\n          >\n            ›\n          </motion.button>\n        </div>\n\n        {/* Perspective wrapper for page-flip effect */}\n        <div style={{ perspective: 900, padding: \"0 26px 22px\" }}>\n          <AnimatePresence mode=\"wait\" initial={false}>\n            <motion.div\n              key={`${year}-${month}`}\n              initial={{\n                rotateX: direction > 0 ? 20 : -20,\n                opacity: 0,\n                scale: 0.97,\n              }}\n              animate={{ rotateX: 0, opacity: 1, scale: 1 }}\n              exit={{\n                rotateX: direction > 0 ? -20 : 20,\n                opacity: 0,\n                scale: 0.97,\n              }}\n              transition={{\n                type: \"spring\",\n                damping: 30,\n                stiffness: 300,\n              }}\n              style={{\n                transformOrigin: \"center center\",\n                backfaceVisibility: \"hidden\",\n              }}\n            >\n              {/* Day-of-week headers */}\n              <div\n                style={{\n                  display: \"grid\",\n                  gridTemplateColumns: `repeat(7, ${CELL}px)`,\n                  gap: GAP,\n                  marginBottom: 8,\n                }}\n              >\n                {DOW.map((d) => (\n                  <div\n                    key={d}\n                    className=\"text-neutral-300 dark:text-neutral-700\"\n                    style={{\n                      fontSize: 10,\n                      fontWeight: 550,\n                      textAlign: \"center\",\n                      textTransform: \"uppercase\",\n                      letterSpacing: \"0.08em\",\n                    }}\n                  >\n                    {d}\n                  </div>\n                ))}\n              </div>\n\n              {/* Day grid with week dividers */}\n              <div style={{ position: \"relative\" }}>\n                {/* Week dividers — ruled paper lines */}\n                {Array.from({ length: weeks - 1 }).map((_, i) => (\n                  <div\n                    key={`wd-${i}`}\n                    className=\"bg-neutral-100/50 dark:bg-neutral-800/30\"\n                    style={{\n                      position: \"absolute\",\n                      left: 0,\n                      width: gridW,\n                      top: (i + 1) * STEP - GAP / 2,\n                      height: 1,\n                      zIndex: 0,\n                    }}\n                  />\n                ))}\n\n                {/* Day cells */}\n                <div\n                  style={{\n                    display: \"grid\",\n                    gridTemplateColumns: `repeat(7, ${CELL}px)`,\n                    gap: GAP,\n                    position: \"relative\",\n                    zIndex: 1,\n                  }}\n                >\n                  {/* Leading empties */}\n                  {Array.from({ length: firstOffset }).map((_, i) => (\n                    <div key={`e-${i}`} style={{ width: CELL, height: CELL }} />\n                  ))}\n\n                  {/* Days */}\n                  {Array.from({ length: daysInMonth }).map((_, i) => {\n                    const d = i + 1;\n                    const isSel = d === selectedDay;\n                    const isHov = d === hoveredDay;\n                    const isToday = d === todayDay;\n                    const hasEv = eventDaySet.has(d);\n                    const count = eventCount(d);\n\n                    /* Shadow depth based on state */\n                    const shadow = isSel\n                      ? \"0 6px 20px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.08)\"\n                      : isToday\n                        ? \"0 3px 10px rgba(0,0,0,0.08)\"\n                        : isHov\n                          ? \"0 4px 14px rgba(0,0,0,0.1)\"\n                          : \"none\";\n\n                    return (\n                      <motion.button\n                        key={d}\n                        animate={{\n                          y: isSel ? -5 : isHov ? -3 : isToday ? -1 : 0,\n                        }}\n                        whileTap={{ scale: 0.93, y: 0 }}\n                        transition={{\n                          type: \"spring\",\n                          damping: 22,\n                          stiffness: 320,\n                        }}\n                        onClick={() => {\n                          tick();\n                          setSelectedDay(isSel ? null : d);\n                          setTitle(\"\");\n                        }}\n                        onMouseEnter={() => setHoveredDay(d)}\n                        onMouseLeave={() => setHoveredDay(null)}\n                        className={cn(\n                          \"transition-colors duration-150\",\n                          isSel\n                            ? \"bg-neutral-200 dark:bg-neutral-800\"\n                            : isToday\n                              ? \"bg-neutral-100 dark:bg-neutral-900\"\n                              : isHov\n                                ? \"bg-neutral-100/70 dark:bg-neutral-800/50\"\n                                : \"bg-transparent\",\n                        )}\n                        style={{\n                          width: CELL,\n                          height: CELL,\n                          borderRadius: 10,\n                          display: \"flex\",\n                          flexDirection: \"column\",\n                          alignItems: \"center\",\n                          justifyContent: \"center\",\n                          cursor: \"pointer\",\n                          border: \"none\",\n                          boxShadow: shadow,\n                          transition: \"box-shadow 0.2s ease-out\",\n                          position: \"relative\",\n                          padding: 0,\n                          gap: 1,\n                        }}\n                      >\n                        <span\n                          className={cn(\n                            \"transition-colors duration-150\",\n                            isSel\n                              ? \"text-neutral-900 dark:text-neutral-100\"\n                              : isToday\n                                ? \"text-neutral-900 dark:text-neutral-100\"\n                                : hasEv\n                                  ? \"text-neutral-700 dark:text-neutral-300\"\n                                  : isHov\n                                    ? \"text-neutral-500 dark:text-neutral-500\"\n                                    : \"text-neutral-400 dark:text-neutral-600\",\n                          )}\n                          style={{\n                            fontSize: 13,\n                            fontWeight: isToday ? 650 : hasEv ? 500 : 400,\n                            fontVariantNumeric: \"tabular-nums\",\n                            lineHeight: 1,\n                          }}\n                        >\n                          {d}\n                        </span>\n\n                        {/* Event bar — marker line, like a physical calendar */}\n                        {hasEv && (\n                          <div\n                            style={{\n                              position: \"absolute\",\n                              bottom: 5,\n                              display: \"flex\",\n                              gap: 2,\n                            }}\n                          >\n                            {Array.from({\n                              length: Math.min(count, 3),\n                            }).map((_, idx) => (\n                              <div\n                                key={idx}\n                                className={cn(\n                                  \"transition-colors duration-150\",\n                                  isSel || isToday\n                                    ? \"bg-neutral-500 dark:bg-neutral-500\"\n                                    : \"bg-neutral-300 dark:bg-neutral-600\",\n                                )}\n                                style={{\n                                  width: count === 1 ? 8 : 3,\n                                  height: 2,\n                                  borderRadius: 1,\n                                }}\n                              />\n                            ))}\n                          </div>\n                        )}\n                      </motion.button>\n                    );\n                  })}\n                </div>\n              </div>\n            </motion.div>\n          </AnimatePresence>\n        </div>\n\n        {/* ── Selected day panel ── */}\n        <AnimatePresence>\n          {selectedDay !== null && (\n            <motion.div\n              initial={{ opacity: 0, height: 0 }}\n              animate={{ opacity: 1, height: \"auto\" }}\n              exit={{ opacity: 0, height: 0 }}\n              transition={{ type: \"spring\", damping: 28, stiffness: 320 }}\n              style={{ overflow: \"hidden\" }}\n            >\n              <div\n                className=\"border-t border-neutral-100 dark:border-neutral-800/50\"\n                style={{\n                  boxShadow: \"inset 0 1px 6px rgba(0,0,0,0.2)\",\n                  padding: \"14px 26px 20px\",\n                }}\n              >\n                {/* Date label */}\n                <div\n                  className=\"text-neutral-400 dark:text-neutral-600\"\n                  style={{\n                    fontSize: 12,\n                    fontWeight: 550,\n                    marginBottom: 10,\n                    letterSpacing: \"-0.005em\",\n                  }}\n                >\n                  {selLabel}\n                </div>\n\n                {/* Events */}\n                {selEvents.length === 0 && (\n                  <div\n                    className=\"text-neutral-300 dark:text-neutral-700\"\n                    style={{\n                      fontSize: 13,\n                      padding: \"2px 0 8px\",\n                    }}\n                  >\n                    No events\n                  </div>\n                )}\n\n                <div\n                  style={{\n                    maxHeight: 140,\n                    overflowY: \"auto\",\n                    scrollbarWidth: \"none\",\n                    maskImage:\n                      selEvents.length > 3\n                        ? \"linear-gradient(to bottom, black 0%, black calc(100% - 16px), transparent)\"\n                        : undefined,\n                    WebkitMaskImage:\n                      selEvents.length > 3\n                        ? \"linear-gradient(to bottom, black 0%, black calc(100% - 16px), transparent)\"\n                        : undefined,\n                  }}\n                >\n                  <AnimatePresence mode=\"popLayout\">\n                    {selEvents.map((ev) => (\n                      <motion.div\n                        key={ev.id}\n                        layout\n                        initial={{ opacity: 0, y: -6 }}\n                        animate={{ opacity: 1, y: 0 }}\n                        exit={{ opacity: 0, x: -14 }}\n                        transition={{\n                          type: \"spring\",\n                          damping: 25,\n                          stiffness: 350,\n                        }}\n                        className=\"border-b border-neutral-100 dark:border-neutral-800/40\"\n                        style={{\n                          display: \"flex\",\n                          alignItems: \"center\",\n                          padding: \"7px 0\",\n                          gap: 10,\n                        }}\n                      >\n                        {/* Depth accent — shadow line */}\n                        <div\n                          className=\"bg-neutral-200 dark:bg-neutral-700\"\n                          style={{\n                            width: 2,\n                            height: 14,\n                            borderRadius: 1,\n                            boxShadow: \"1px 0 4px rgba(255,255,255,0.04)\",\n                            flexShrink: 0,\n                          }}\n                        />\n                        <span\n                          className=\"text-neutral-600 dark:text-neutral-400\"\n                          style={{\n                            fontSize: 13,\n                            fontWeight: 450,\n                            flex: 1,\n                            overflow: \"hidden\",\n                            textOverflow: \"ellipsis\",\n                            whiteSpace: \"nowrap\",\n                          }}\n                        >\n                          {ev.title}\n                        </span>\n                        <motion.button\n                          whileTap={{ scale: 0.85 }}\n                          onClick={() => handleRemove(ev.id)}\n                          className={cn(\n                            \"text-neutral-200 dark:text-neutral-800\",\n                            \"hover:text-red-500 dark:hover:text-red-400\",\n                            \"transition-colors duration-150\",\n                          )}\n                          style={{\n                            background: \"transparent\",\n                            border: \"none\",\n                            cursor: \"pointer\",\n                            fontSize: 14,\n                            lineHeight: 1,\n                            padding: \"2px 0\",\n                            flexShrink: 0,\n                          }}\n                        >\n                          ×\n                        </motion.button>\n                      </motion.div>\n                    ))}\n                  </AnimatePresence>\n                </div>\n\n                {/* Inline creation */}\n                <div\n                  style={{\n                    display: \"flex\",\n                    alignItems: \"center\",\n                    gap: 10,\n                    marginTop: selEvents.length > 0 ? 8 : 0,\n                  }}\n                >\n                  <input\n                    ref={inputRef}\n                    value={title}\n                    onChange={(e) => setTitle(e.target.value)}\n                    onKeyDown={(e) => {\n                      if (e.key === \"Enter\") {\n                        e.preventDefault();\n                        handleAdd();\n                      }\n                    }}\n                    placeholder=\"Add event…\"\n                    className=\"text-neutral-600 dark:text-neutral-400\"\n                    style={{\n                      flex: 1,\n                      background: \"transparent\",\n                      border: \"none\",\n                      outline: \"none\",\n                      fontSize: 13,\n                      fontFamily: \"inherit\",\n                    }}\n                  />\n                  <motion.button\n                    whileTap={{ scale: 0.9 }}\n                    onClick={handleAdd}\n                    className={cn(\n                      \"transition-colors duration-150\",\n                      title.trim()\n                        ? \"text-neutral-500 dark:text-neutral-500\"\n                        : \"text-neutral-200 dark:text-neutral-800\",\n                    )}\n                    style={{\n                      background: \"transparent\",\n                      border: \"none\",\n                      cursor: title.trim() ? \"pointer\" : \"default\",\n                      fontSize: 13,\n                      fontWeight: 500,\n                      padding: \"4px 0\",\n                    }}\n                  >\n                    Add\n                  </motion.button>\n                </div>\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n\nexport default ThreeDWallCalendar;\n",
      "type": "registry:ui",
      "target": "components/ruixen/three-dwall-calendar.tsx"
    }
  ]
}