{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "chat-two-pane",
  "title": "Chat Two Pane",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-svelte/blocks/chat-two-pane/ChatTwoPane.svelte",
      "content": "<script lang=\"ts\">\n  import { tick } from 'svelte'\n  import {\n    ArrowRight,\n    AtSign,\n    CheckCheck,\n    Filter,\n    MoreVertical,\n    Paperclip,\n    Phone,\n    Pin,\n    Search,\n    Smile,\n    SquarePen,\n    Video,\n  } from '@lucide/svelte'\n  import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/ui/avatar'\n  import { Button } from '$lib/components/ui/button'\n  import { Input } from '$lib/components/ui/input'\n  import { Textarea } from '$lib/components/ui/textarea'\n  import { cn } from '$lib/utils'\n\n  interface Conversation {\n    id: string\n    name: string\n    initials: string\n    avatarUrl?: string\n    lastMessage: string\n    lastAt: Date\n    unread: number\n    online: boolean\n    pinned?: boolean\n    muted?: boolean\n  }\n\n  interface Message {\n    id: string\n    author: 'me' | 'them'\n    body: string\n    sentAt: Date\n    status?: 'sent' | 'delivered' | 'read'\n  }\n\n  const now = new Date()\n  const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())\n  const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)\n\n  let conversations: Conversation[] = $state([\n    {\n      id: 'c1',\n      name: 'Marcus Rivera',\n      initials: 'MR',\n      lastMessage: 'Yes! I will bring the kanban deck. Want to push it to 10:30?',\n      lastAt: new Date(now.getTime() - 8 * 60 * 1000),\n      unread: 2,\n      online: true,\n      pinned: true,\n    },\n    {\n      id: 'c2',\n      name: 'Sarah Connor',\n      initials: 'SC',\n      lastMessage: 'Approved — feel free to take Mar 15-18 off.',\n      lastAt: new Date(now.getTime() - 52 * 60 * 1000),\n      unread: 0,\n      online: true,\n    },\n    {\n      id: 'c3',\n      name: 'Design Team',\n      initials: 'DT',\n      lastMessage: 'Priya: pushed the new color tokens to staging.',\n      lastAt: new Date(now.getTime() - 3 * 60 * 60 * 1000),\n      unread: 5,\n      online: false,\n      pinned: true,\n    },\n    {\n      id: 'c4',\n      name: 'Alice Johnson',\n      initials: 'AJ',\n      lastMessage: 'Thanks! Will follow up after lunch.',\n      lastAt: new Date(now.getTime() - 5 * 60 * 60 * 1000),\n      unread: 0,\n      online: false,\n    },\n    {\n      id: 'c5',\n      name: 'Engineering',\n      initials: 'EN',\n      lastMessage: 'Devon: deploy is green, going home.',\n      lastAt: yesterday,\n      unread: 0,\n      online: false,\n      muted: true,\n    },\n    {\n      id: 'c6',\n      name: 'Priya Shah',\n      initials: 'PS',\n      lastMessage: 'Sent the spec — let me know what you think.',\n      lastAt: new Date(now.getTime() - 28 * 60 * 60 * 1000),\n      unread: 0,\n      online: false,\n    },\n    {\n      id: 'c7',\n      name: 'Devon Patel',\n      initials: 'DP',\n      lastMessage: 'You: pushed the fix, should be live in 10.',\n      lastAt: new Date(now.getTime() - 50 * 60 * 60 * 1000),\n      unread: 0,\n      online: true,\n    },\n  ])\n\n  let messagesByConvo: Record<string, Message[]> = $state({\n    c1: [\n      {\n        id: 'm1',\n        author: 'them',\n        body: 'Hey — did you get a chance to look at the onboarding doc?',\n        sentAt: new Date(yesterday.getTime() + 9 * 60 * 60 * 1000),\n      },\n      {\n        id: 'm2',\n        author: 'me',\n        body: 'Yeah, just finished. Two small notes on the time-off flow but otherwise looks solid.',\n        sentAt: new Date(yesterday.getTime() + 9 * 60 * 60 * 1000 + 7 * 60 * 1000),\n        status: 'read',\n      },\n      {\n        id: 'm3',\n        author: 'them',\n        body: 'Perfect. Drop them in the doc and I will action this afternoon.',\n        sentAt: new Date(yesterday.getTime() + 9 * 60 * 60 * 1000 + 9 * 60 * 1000),\n      },\n      {\n        id: 'm4',\n        author: 'me',\n        body: 'Done. Also — are we still on for the dashboard review tomorrow at 10?',\n        sentAt: new Date(now.getTime() - 42 * 60 * 1000),\n        status: 'read',\n      },\n      {\n        id: 'm5',\n        author: 'them',\n        body: 'Yes! I will bring the kanban deck. Want to push it to 10:30 so we can join from the standup?',\n        sentAt: new Date(now.getTime() - 8 * 60 * 1000),\n      },\n    ],\n    c2: [\n      {\n        id: 'm1',\n        author: 'me',\n        body: 'Hey Sarah — submitting time-off for Mar 15-18.',\n        sentAt: new Date(now.getTime() - 95 * 60 * 1000),\n        status: 'read',\n      },\n      {\n        id: 'm2',\n        author: 'them',\n        body: 'Approved — feel free to take Mar 15-18 off.',\n        sentAt: new Date(now.getTime() - 52 * 60 * 1000),\n      },\n    ],\n    c3: [\n      {\n        id: 'm1',\n        author: 'them',\n        body: 'Priya: pushed the new color tokens to staging. Reviews welcome.',\n        sentAt: new Date(now.getTime() - 3 * 60 * 60 * 1000),\n      },\n    ],\n    c4: [\n      {\n        id: 'm1',\n        author: 'them',\n        body: 'Thanks! Will follow up after lunch.',\n        sentAt: new Date(now.getTime() - 5 * 60 * 60 * 1000),\n      },\n    ],\n    c5: [{ id: 'm1', author: 'them', body: 'Devon: deploy is green, going home.', sentAt: yesterday }],\n    c6: [\n      {\n        id: 'm1',\n        author: 'them',\n        body: 'Sent the spec — let me know what you think.',\n        sentAt: new Date(now.getTime() - 28 * 60 * 60 * 1000),\n      },\n    ],\n    c7: [\n      {\n        id: 'm1',\n        author: 'me',\n        body: 'pushed the fix, should be live in 10.',\n        sentAt: new Date(now.getTime() - 50 * 60 * 60 * 1000),\n        status: 'read',\n      },\n    ],\n  })\n\n  let search = $state('')\n  let activeId = $state('c1')\n  let draft = $state('')\n  let scrollRoot: HTMLDivElement | null = $state(null)\n  let peerTyping = $state(false)\n\n  const activeConvo = $derived(conversations.find((c) => c.id === activeId)!)\n  const activeMessages = $derived<Message[]>(messagesByConvo[activeId] ?? [])\n\n  const filteredConvos = $derived.by(() => {\n    const q = search.trim().toLowerCase()\n    const list = q\n      ? conversations.filter((c) => c.name.toLowerCase().includes(q) || c.lastMessage.toLowerCase().includes(q))\n      : conversations\n    return [...list].sort((a, b) => {\n      if (a.pinned !== b.pinned) return a.pinned ? -1 : 1\n      return b.lastAt.getTime() - a.lastAt.getTime()\n    })\n  })\n\n  const groupedMessages = $derived.by(() => {\n    const groups: Array<{ label: string; items: Message[] }> = []\n    for (const m of activeMessages) {\n      const label = dayLabel(m.sentAt)\n      const last = groups[groups.length - 1]\n      if (last && last.label === label) last.items.push(m)\n      else groups.push({ label, items: [m] })\n    }\n    return groups\n  })\n\n  function dayLabel(d: Date): string {\n    if (d.toDateString() === now.toDateString()) return 'Today'\n    if (d.toDateString() === yesterday.toDateString()) return 'Yesterday'\n    return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })\n  }\n\n  function formatTime(d: Date): string {\n    return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })\n  }\n\n  function formatConvoTime(d: Date): string {\n    if (d >= todayStart) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })\n    if (d.toDateString() === yesterday.toDateString()) return 'Yesterday'\n    const diffDays = Math.floor((now.getTime() - d.getTime()) / (24 * 60 * 60 * 1000))\n    if (diffDays < 7) return d.toLocaleDateString(undefined, { weekday: 'short' })\n    return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })\n  }\n\n  function selectConvo(id: string) {\n    activeId = id\n    const c = conversations.find((c) => c.id === id)\n    if (c) c.unread = 0\n    tick().then(scrollToBottom)\n  }\n\n  async function send() {\n    const body = draft.trim()\n    if (!body) return\n    const list = messagesByConvo[activeId] ?? []\n    list.push({ id: `m${Date.now()}`, author: 'me', body, sentAt: new Date(), status: 'sent' })\n    messagesByConvo[activeId] = list\n    draft = ''\n    activeConvo.lastMessage = `You: ${body}`\n    activeConvo.lastAt = new Date()\n    await tick()\n    scrollToBottom()\n    peerTyping = true\n    window.setTimeout(() => (peerTyping = false), 1600)\n  }\n\n  function onComposerKey(e: KeyboardEvent) {\n    if (e.key === 'Enter' && !e.shiftKey) {\n      e.preventDefault()\n      send()\n    }\n  }\n\n  function scrollToBottom() {\n    if (!scrollRoot) return\n    scrollRoot.scrollTop = scrollRoot.scrollHeight\n  }\n\n  $effect(() => {\n    // Re-run on conversation switch so the new thread starts scrolled down.\n    void activeId\n    tick().then(scrollToBottom)\n  })\n</script>\n\n<div\n  data-slot=\"chat-two-pane\"\n  class=\"bg-card text-card-foreground grid h-[42.5rem] w-full grid-cols-[280px_1fr] overflow-hidden rounded-xl border shadow-sm\"\n>\n  <aside class=\"bg-muted/30 flex min-w-0 flex-col border-r\">\n    <div class=\"flex h-14 shrink-0 items-center justify-between gap-2 border-b px-4\">\n      <h2 class=\"text-sm font-semibold tracking-tight\">Messages</h2>\n      <div class=\"flex items-center gap-1\">\n        <Button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Filter\">\n          <Filter class=\"size-4\" />\n        </Button>\n        <Button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"New conversation\">\n          <SquarePen class=\"size-4\" />\n        </Button>\n      </div>\n    </div>\n    <div class=\"px-3 pt-3 pb-2\">\n      <div class=\"relative\">\n        <Search class=\"text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2\" />\n        <Input bind:value={search} placeholder=\"Search\" class=\"h-8 rounded-lg pl-8 text-xs\" />\n      </div>\n    </div>\n    <div class=\"convo-scroll flex-1 overflow-y-auto pb-3\">\n      {#each filteredConvos as c (c.id)}\n        <button\n          type=\"button\"\n          class={cn(\n            'flex w-full items-start gap-3 px-3 py-2.5 text-left transition-colors',\n            c.id === activeId ? 'bg-primary/10' : 'hover:bg-muted/60',\n          )}\n          onclick={() => selectConvo(c.id)}\n        >\n          <div class=\"relative shrink-0\">\n            <Avatar class=\"size-9\">\n              {#if c.avatarUrl}\n                <AvatarImage src={c.avatarUrl} alt={c.name} />\n              {/if}\n              <AvatarFallback class=\"text-xs\">{c.initials}</AvatarFallback>\n            </Avatar>\n            {#if c.online}\n              <span class=\"bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2\"></span>\n            {/if}\n          </div>\n          <div class=\"min-w-0 flex-1\">\n            <div class=\"flex items-center justify-between gap-1.5\">\n              <p class={cn('truncate text-sm', c.unread > 0 ? 'font-semibold' : 'font-medium')}>\n                {c.name}\n              </p>\n              <span class=\"text-muted-foreground shrink-0 text-xs tabular-nums\">\n                {formatConvoTime(c.lastAt)}\n              </span>\n            </div>\n            <div class=\"mt-0.5 flex items-center justify-between gap-1.5\">\n              <p class={cn('truncate text-xs', c.unread > 0 ? 'text-foreground/80' : 'text-muted-foreground')}>\n                {c.lastMessage}\n              </p>\n              <div class=\"flex shrink-0 items-center gap-1\">\n                {#if c.pinned}\n                  <Pin class=\"text-muted-foreground size-3\" />\n                {/if}\n                {#if c.unread > 0}\n                  <span\n                    class=\"bg-primary text-primary-foreground inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-xs font-bold tabular-nums\"\n                  >\n                    {c.unread}\n                  </span>\n                {/if}\n              </div>\n            </div>\n          </div>\n        </button>\n      {/each}\n    </div>\n  </aside>\n\n  <section class=\"flex min-w-0 flex-col\">\n    <header class=\"flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4\">\n      <div class=\"flex min-w-0 items-center gap-3\">\n        <div class=\"relative\">\n          <Avatar class=\"size-9\">\n            <AvatarFallback class=\"text-xs font-medium\">{activeConvo.initials}</AvatarFallback>\n          </Avatar>\n          {#if activeConvo.online}\n            <span class=\"bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2\"></span>\n          {/if}\n        </div>\n        <div class=\"min-w-0\">\n          <p class=\"truncate text-sm font-semibold tracking-tight\">{activeConvo.name}</p>\n          <p class=\"text-muted-foreground truncate text-xs\">\n            {activeConvo.online ? 'Active now' : 'Active 2h ago'}\n          </p>\n        </div>\n      </div>\n      <div class=\"flex items-center gap-1\">\n        <Button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Voice call\">\n          <Phone class=\"size-4\" />\n        </Button>\n        <Button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Video call\">\n          <Video class=\"size-4\" />\n        </Button>\n        <Button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"More options\">\n          <MoreVertical class=\"size-4\" />\n        </Button>\n      </div>\n    </header>\n\n    <div bind:this={scrollRoot} class=\"chat-scroll flex-1 space-y-5 overflow-y-auto px-4 py-4\">\n      {#each groupedMessages as group (group.label)}\n        <div class=\"space-y-3\">\n          <div class=\"flex items-center justify-center\">\n            <span\n              class=\"text-muted-foreground bg-muted/60 rounded-full px-2.5 py-0.5 text-xs font-medium tracking-wide uppercase\"\n            >\n              {group.label}\n            </span>\n          </div>\n          {#each group.items as m (m.id)}\n            <div class={cn('flex items-end gap-2', m.author === 'me' ? 'justify-end' : 'justify-start')}>\n              {#if m.author === 'them'}\n                <Avatar class=\"size-7\">\n                  <AvatarFallback class=\"text-xs\">{activeConvo.initials}</AvatarFallback>\n                </Avatar>\n              {/if}\n              <div class={cn('flex max-w-[80%] flex-col gap-1', m.author === 'me' ? 'items-end' : 'items-start')}>\n                <div\n                  class={cn(\n                    'rounded-2xl px-3.5 py-2 text-sm leading-relaxed shadow-sm',\n                    m.author === 'me'\n                      ? 'bg-primary text-primary-foreground rounded-br-md'\n                      : 'bg-muted text-foreground rounded-bl-md',\n                  )}\n                >\n                  {m.body}\n                </div>\n                <div class=\"text-muted-foreground flex items-center gap-1 px-1 text-xs tabular-nums\">\n                  <span>{formatTime(m.sentAt)}</span>\n                  {#if m.author === 'me' && m.status === 'read'}\n                    <CheckCheck class=\"text-info size-3\" />\n                  {/if}\n                </div>\n              </div>\n            </div>\n          {/each}\n        </div>\n      {/each}\n\n      {#if peerTyping}\n        <div class=\"flex items-end gap-2\">\n          <Avatar class=\"size-7\">\n            <AvatarFallback class=\"text-xs\">{activeConvo.initials}</AvatarFallback>\n          </Avatar>\n          <div class=\"bg-muted text-muted-foreground flex items-center gap-1 rounded-2xl rounded-bl-md px-3 py-2.5\">\n            <span class=\"typing-dot\"></span>\n            <span class=\"typing-dot\" style=\"animation-delay: 0.15s\"></span>\n            <span class=\"typing-dot\" style=\"animation-delay: 0.3s\"></span>\n          </div>\n        </div>\n      {/if}\n    </div>\n\n    <div class=\"bg-muted/20 border-t px-4 py-3\">\n      <div\n        class=\"bg-background border-input/60 focus-within:border-primary/40 focus-within:ring-primary/10 group flex flex-col overflow-hidden rounded-2xl border shadow-sm transition-colors duration-150 focus-within:shadow-md focus-within:ring-4\"\n      >\n        <Textarea\n          bind:value={draft}\n          placeholder={`Message ${activeConvo.name.split(' ')[0]}`}\n          rows={1}\n          class=\"placeholder:text-muted-foreground/70 max-h-40 min-h-[2.75rem] resize-none border-0 bg-transparent px-4 pt-3 pb-1 text-sm leading-relaxed shadow-none focus-visible:ring-0\"\n          onkeydown={onComposerKey}\n        />\n        <div class=\"flex items-center justify-between gap-2 px-2 pb-2\">\n          <div class=\"flex items-center gap-0.5\">\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              class=\"text-muted-foreground hover:text-foreground size-8\"\n              aria-label=\"Attach file\"\n            >\n              <Paperclip class=\"size-4\" />\n            </Button>\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              class=\"text-muted-foreground hover:text-foreground size-8\"\n              aria-label=\"Emoji\"\n            >\n              <Smile class=\"size-4\" />\n            </Button>\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              class=\"text-muted-foreground hover:text-foreground size-8\"\n              aria-label=\"Mention\"\n            >\n              <AtSign class=\"size-4\" />\n            </Button>\n          </div>\n          <div class=\"flex items-center gap-2\">\n            <kbd\n              class=\"bg-muted text-muted-foreground hidden h-5 items-center gap-0.5 rounded border px-1.5 font-mono text-xs sm:inline-flex\"\n            >\n              <span>↵</span>\n            </kbd>\n            <Button\n              class=\"h-8 gap-1.5 rounded-lg px-3 text-xs font-medium\"\n              disabled={!draft.trim()}\n              aria-label=\"Send\"\n              onclick={send}\n            >\n              Send\n              <ArrowRight class=\"size-3.5\" />\n            </Button>\n          </div>\n        </div>\n      </div>\n    </div>\n  </section>\n</div>\n\n<style>\n  .chat-scroll::-webkit-scrollbar,\n  .convo-scroll::-webkit-scrollbar {\n    width: 6px;\n  }\n  .chat-scroll::-webkit-scrollbar-thumb,\n  .convo-scroll::-webkit-scrollbar-thumb {\n    background: var(--border);\n    border-radius: 3px;\n  }\n  .typing-dot {\n    display: inline-block;\n    width: 6px;\n    height: 6px;\n    border-radius: 9999px;\n    background: currentColor;\n    opacity: 0.5;\n    animation: typing-bounce 1s infinite ease-in-out;\n  }\n  @keyframes typing-bounce {\n    0%,\n    80%,\n    100% {\n      transform: translateY(0);\n      opacity: 0.35;\n    }\n    40% {\n      transform: translateY(-3px);\n      opacity: 0.9;\n    }\n  }\n</style>\n",
      "type": "registry:block",
      "target": "~/src/lib/components/blocks/chat-two-pane/ChatTwoPane.svelte"
    }
  ],
  "dependencies": [
    "@lucide/svelte"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/svelte/avatar.json",
    "https://uipkge.dev/r/svelte/button.json",
    "https://uipkge.dev/r/svelte/input.json",
    "https://uipkge.dev/r/svelte/textarea.json"
  ],
  "description": "Two-pane messaging surface. Left rail: searchable conversation list with avatars, online dots, pinned section, unread badges, mute icons, last-message preview, smart timestamps. Right pane: active thread with day separators, own/peer bubbles, read ticks, typing indicator, attach/emoji/send composer. Stateful demo — replace `conversations` and `messagesByConvo` with your data layer.",
  "categories": [
    "communication",
    "layout"
  ]
}