{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "chat-two-pane",
  "title": "Chat Two Pane",
  "type": "registry:block",
  "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"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/blocks/chat-two-pane/chat-two-pane.ts",
      "content": "import { LitElement, css, html } from 'lit'\nimport {\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'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\n// Side-effect imports: register the composed elements for standalone use\n// (consumers who install only this block still get working primitives).\nimport '../../components/avatar/avatar'\nimport '../../components/button/button'\nimport '../../components/input/input'\nimport '../../components/textarea/textarea'\nimport type { UipInput } from '../../components/input/input'\n\ninterface TwoPaneConversation {\n  id: string\n  name: string\n  initials: string\n  lastMessage: string\n  lastAt: Date\n  unread: number\n  online: boolean\n  pinned?: boolean\n  muted?: boolean\n}\n\ninterface TwoPaneMessage {\n  id: string\n  author: 'me' | 'them'\n  body: string\n  sentAt: Date\n  status?: 'sent' | 'delivered' | 'read'\n}\n\nfunction seedConversations(): TwoPaneConversation[] {\n  const now = new Date()\n  const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)\n  return [\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\nfunction seedMessages(): Record<string, TwoPaneMessage[]> {\n  const now = new Date()\n  const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)\n  const yMorning = yesterday.getTime() + 9 * 60 * 60 * 1000\n  return {\n    c1: [\n      { id: 'm1', author: 'them', body: 'Hey — did you get a chance to look at the onboarding doc?', sentAt: new Date(yMorning) },\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(yMorning + 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(yMorning + 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      { id: 'm1', author: 'me', body: 'Hey Sarah — submitting time-off for Mar 15-18.', sentAt: new Date(now.getTime() - 95 * 60 * 1000), status: 'read' },\n      { id: 'm2', author: 'them', body: 'Approved — feel free to take Mar 15-18 off.', sentAt: new Date(now.getTime() - 52 * 60 * 1000) },\n    ],\n    c3: [{ id: 'm1', author: 'them', body: 'Priya: pushed the new color tokens to staging. Reviews welcome.', sentAt: new Date(now.getTime() - 3 * 60 * 60 * 1000) }],\n    c4: [{ id: 'm1', author: 'them', body: 'Thanks! Will follow up after lunch.', sentAt: new Date(now.getTime() - 5 * 60 * 60 * 1000) }],\n    c5: [{ id: 'm1', author: 'them', body: 'Devon: deploy is green, going home.', sentAt: yesterday }],\n    c6: [{ id: 'm1', author: 'them', body: 'Sent the spec — let me know what you think.', sentAt: new Date(now.getTime() - 28 * 60 * 60 * 1000) }],\n    c7: [{ id: 'm1', author: 'me', body: 'pushed the fix, should be live in 10.', sentAt: new Date(now.getTime() - 50 * 60 * 60 * 1000), status: 'read' }],\n  }\n}\n\nfunction formatTime(d: Date): string {\n  return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })\n}\n\nfunction formatConvoTime(d: Date): string {\n  const now = new Date()\n  const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())\n  if (d >= todayStart) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })\n  const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)\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/**\n * <uip-chat-two-pane> — the registry ChatTwoPane block as a web component.\n *\n * Class strings are React's ChatTwoPane verbatim. React state becomes @state\n * fields (the per-conversation message store is a @state record of arrays;\n * sends replace the active array so Lit re-renders). Search/pin sort,\n * day-grouped bubbles, typing indicator, and Enter-to-send all behave as in\n * React. Typing-bounce keyframes are copied from React's injected <style>\n * (allowed exception); scrollbar rules are dropped (banned extra CSS).\n */\nexport class UipChatTwoPane extends LitElement {\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  // :host display can't be a utility class (the host has no template of its own).\n  // typing-bounce is copied from React's injected <style> (keyframes exception).\n  static styles = [\n    tailwind,\n    css`:host { display: block; }`,\n    css`\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%, 80%, 100% { transform: translateY(0); opacity: 0.35; }\n        40% { transform: translateY(-3px); opacity: 0.9; }\n      }\n    `,\n  ]\n\n  static properties = {\n    conversations: { state: true },\n    messagesByConvo: { state: true },\n    search: { state: true },\n    activeId: { state: true },\n    draft: { state: true },\n    peerTyping: { state: true },\n  }\n\n  private conversations: TwoPaneConversation[] = []\n  private messagesByConvo: Record<string, TwoPaneMessage[]> = {}\n  private search = ''\n  private activeId = 'c1'\n  private draft = ''\n  private peerTyping = false\n  private typingTimer?: number\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'chat-two-pane')\n    if (this.conversations.length === 0) this.conversations = seedConversations()\n    if (Object.keys(this.messagesByConvo).length === 0) this.messagesByConvo = seedMessages()\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    if (this.typingTimer) window.clearTimeout(this.typingTimer)\n  }\n\n  updated(changed: Map<string, unknown>) {\n    if (changed.has('activeId') || changed.has('messagesByConvo')) {\n      const root = this.renderRoot.querySelector('[data-scroll-root]')\n      if (root) root.scrollTop = root.scrollHeight\n    }\n  }\n\n  private get activeConvo(): TwoPaneConversation {\n    return this.conversations.find((c) => c.id === this.activeId) ?? this.conversations[0]!\n  }\n\n  private get activeMessages(): TwoPaneMessage[] {\n    return this.messagesByConvo[this.activeId] ?? []\n  }\n\n  private get filteredConvos(): TwoPaneConversation[] {\n    const q = this.search.trim().toLowerCase()\n    const list = q\n      ? this.conversations.filter((c) => c.name.toLowerCase().includes(q) || c.lastMessage.toLowerCase().includes(q))\n      : [...this.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  private dayLabel(d: Date): string {\n    const now = new Date()\n    if (d.toDateString() === now.toDateString()) return 'Today'\n    const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)\n    if (d.toDateString() === yesterday.toDateString()) return 'Yesterday'\n    return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })\n  }\n\n  private get grouped(): Array<{ label: string; items: TwoPaneMessage[] }> {\n    const groups: Array<{ label: string; items: TwoPaneMessage[] }> = []\n    for (const m of this.activeMessages) {\n      const label = this.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  private selectConvo(id: string) {\n    this.activeId = id\n    this.conversations = this.conversations.map((c) => (c.id === id ? { ...c, unread: 0 } : c))\n  }\n\n  private send() {\n    const body = this.draft.trim()\n    if (!body) return\n    const list = [...(this.messagesByConvo[this.activeId] ?? [])]\n    list.push({ id: `m${Date.now()}`, author: 'me', body, sentAt: new Date(), status: 'sent' })\n    this.messagesByConvo = { ...this.messagesByConvo, [this.activeId]: list }\n    this.draft = ''\n    this.conversations = this.conversations.map((c) =>\n      c.id === this.activeId ? { ...c, lastMessage: `You: ${body}`, lastAt: new Date() } : c,\n    )\n    this.peerTyping = true\n    if (this.typingTimer) window.clearTimeout(this.typingTimer)\n    this.typingTimer = window.setTimeout(() => {\n      this.peerTyping = false\n    }, 1600)\n  }\n\n  render() {\n    const active = this.activeConvo\n    return html`\n      <div\n        data-slot=\"chat-two-pane\"\n        class=${cn('bg-card text-card-foreground grid h-[680px] 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              <uip-button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Filter\">\n                ${icon(Filter, 'funnel', 'size-4')}\n              </uip-button>\n              <uip-button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"New conversation\">\n                ${icon(SquarePen, 'square-pen', 'size-4')}\n              </uip-button>\n            </div>\n          </div>\n          <div class=\"px-3 pt-3 pb-2\">\n            <uip-input\n              .value=${this.search}\n              placeholder=\"Search\"\n              class=\"h-8 rounded-lg text-xs\"\n              @input=${(e: Event) => {\n                this.search = (e.target as UipInput).value\n              }}\n            >\n              <span slot=\"prefix\">${icon(Search, 'search', 'size-3.5')}</span>\n            </uip-input>\n          </div>\n          <div class=\"convo-scroll flex-1 overflow-y-auto pb-3\">\n            ${this.filteredConvos.map(\n              (c) => html`\n                <button\n                  class=${cn(\n                    'flex w-full items-start gap-3 px-3 py-2.5 text-left transition-colors',\n                    c.id === this.activeId ? 'bg-primary/10' : 'hover:bg-muted/60',\n                  )}\n                  @click=${() => this.selectConvo(c.id)}\n                >\n                  <div class=\"relative shrink-0\">\n                    <uip-avatar class=\"size-9\">\n                      <uip-avatar-fallback class=\"text-xs\">${c.initials}</uip-avatar-fallback>\n                    </uip-avatar>\n                    ${c.online\n                      ? html`<span\n                          class=\"bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2\"\n                        ></span>`\n                      : ''}\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')}>${c.name}</p>\n                      <span class=\"text-muted-foreground shrink-0 text-xs tabular-nums\"\n                        >${formatConvoTime(c.lastAt)}</span\n                      >\n                    </div>\n                    <div class=\"mt-0.5 flex items-center justify-between gap-1.5\">\n                      <p\n                        class=${cn('truncate text-xs', c.unread > 0 ? 'text-foreground/80' : 'text-muted-foreground')}\n                      >\n                        ${c.lastMessage}\n                      </p>\n                      <div class=\"flex shrink-0 items-center gap-1\">\n                        ${c.pinned ? icon(Pin, 'pin', 'text-muted-foreground size-3') : ''}\n                        ${c.unread > 0\n                          ? html`<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                              >${c.unread}</span\n                            >`\n                          : ''}\n                      </div>\n                    </div>\n                  </div>\n                </button>\n              `,\n            )}\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                <uip-avatar class=\"size-9\">\n                  <uip-avatar-fallback class=\"text-xs font-medium\">${active.initials}</uip-avatar-fallback>\n                </uip-avatar>\n                ${active.online\n                  ? html`<span\n                      class=\"bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2\"\n                    ></span>`\n                  : ''}\n              </div>\n              <div class=\"min-w-0\">\n                <p class=\"truncate text-sm font-semibold tracking-tight\">${active.name}</p>\n                <p class=\"text-muted-foreground truncate text-xs\">${active.online ? 'Active now' : 'Active 2h ago'}</p>\n              </div>\n            </div>\n            <div class=\"flex items-center gap-1\">\n              <uip-button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Voice call\">\n                ${icon(Phone, 'phone', 'size-4')}\n              </uip-button>\n              <uip-button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Video call\">\n                ${icon(Video, 'video', 'size-4')}\n              </uip-button>\n              <uip-button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"More options\">\n                ${icon(MoreVertical, 'ellipsis-vertical', 'size-4')}\n              </uip-button>\n            </div>\n          </header>\n\n          <div data-scroll-root class=\"chat-scroll flex-1 space-y-5 overflow-y-auto px-4 py-4\">\n            ${this.grouped.map(\n              (group) => html`\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                      >${group.label}</span\n                    >\n                  </div>\n                  ${group.items.map(\n                    (m) => html`\n                      <div class=${cn('flex items-end gap-2', m.author === 'me' ? 'justify-end' : 'justify-start')}>\n                        ${m.author === 'them'\n                          ? html`<uip-avatar class=\"size-7\">\n                              <uip-avatar-fallback class=\"text-xs\">${active.initials}</uip-avatar-fallback>\n                            </uip-avatar>`\n                          : ''}\n                        <div\n                          class=${cn('flex max-w-[78%] flex-col gap-1', m.author === 'me' ? 'items-end' : 'items-start')}\n                        >\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                            ${m.author === 'me' && m.status === 'read'\n                              ? icon(CheckCheck, 'check-check', 'text-info size-3')\n                              : ''}\n                          </div>\n                        </div>\n                      </div>\n                    `,\n                  )}\n                </div>\n              `,\n            )}\n            ${this.peerTyping\n              ? html`\n                  <div class=\"flex items-end gap-2\">\n                    <uip-avatar class=\"size-7\">\n                      <uip-avatar-fallback class=\"text-xs\">${active.initials}</uip-avatar-fallback>\n                    </uip-avatar>\n                    <div\n                      class=\"bg-muted text-muted-foreground flex items-center gap-1 rounded-2xl rounded-bl-md px-3 py-2.5\"\n                    >\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                `\n              : ''}\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              <uip-textarea\n                .value=${this.draft}\n                placeholder=${`Message ${active.name.split(' ')[0]}`}\n                rows=\"1\"\n                class=\"placeholder:text-muted-foreground/70 max-h-40 min-h-[44px] resize-none border-0 bg-transparent px-4 pt-3 pb-1 text-sm leading-relaxed shadow-none focus-visible:ring-0\"\n                @value-change=${(e: CustomEvent<{ value: string }>) => {\n                  this.draft = e.detail.value\n                }}\n                @keydown=${(e: KeyboardEvent) => {\n                  if (e.key === 'Enter' && !e.shiftKey) {\n                    e.preventDefault()\n                    this.send()\n                  }\n                }}\n              ></uip-textarea>\n              <div class=\"flex items-center justify-between gap-2 px-2 pb-2\">\n                <div class=\"flex items-center gap-0.5\">\n                  <uip-button\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    class=\"text-muted-foreground hover:text-foreground size-8\"\n                    aria-label=\"Attach file\"\n                  >\n                    ${icon(Paperclip, 'paperclip', 'size-4')}\n                  </uip-button>\n                  <uip-button\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    class=\"text-muted-foreground hover:text-foreground size-8\"\n                    aria-label=\"Emoji\"\n                  >\n                    ${icon(Smile, 'smile', 'size-4')}\n                  </uip-button>\n                  <uip-button\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    class=\"text-muted-foreground hover:text-foreground size-8\"\n                    aria-label=\"Mention\"\n                  >\n                    ${icon(AtSign, 'at-sign', 'size-4')}\n                  </uip-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                  <uip-button\n                    class=\"h-8 gap-1.5 rounded-lg px-3 text-xs font-medium\"\n                    ?disabled=${!this.draft.trim()}\n                    aria-label=\"Send\"\n                    @click=${this.send}\n                  >\n                    Send ${icon(ArrowRight, 'arrow-right', 'size-3.5')}\n                  </uip-button>\n                </div>\n              </div>\n            </div>\n          </div>\n        </section>\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-chat-two-pane') || customElements.define('uip-chat-two-pane', UipChatTwoPane)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-chat-two-pane': UipChatTwoPane\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/blocks/chat-two-pane/chat-two-pane.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/avatar.json",
    "https://uipkge.dev/r/lit/button.json",
    "https://uipkge.dev/r/lit/input.json",
    "https://uipkge.dev/r/lit/lit-core.json",
    "https://uipkge.dev/r/lit/textarea.json"
  ]
}
