Chapter 16 · Operator · Pipeline & CRM patterns

Pipeline & CRM. Move work.

The patterns that make the CRM feel like a CRM. Drag-and-drop kanban for deals. A pipeline bar that doubles as a quick-stage-move. Activity timelines with six row types because real life is messy. Inbox rows you can dispatch with one keystroke. Onboarding lists with playbook structure. The MRR composition chart, finally vanilla SVG. Plus the full-thread email viewer, Gmail-shaped inbox rows, agenda lists with date bands, collapsible sidebar groups, and mini-card linked-entity rows — the rest of the day-to-day CRM vocabulary.

16.1 Kanban column & draggable card

The deals view's spine. Each column = a pipeline stage with header (stage · count · sum value) and a scrollable list of cards. Each card has industry chip, value, company, primary contact, an activity-glyphs row, and the fit/intent rings (from 7.15) so “is this real?” reads at a glance. Drag-drop with mouse and keyboard (focus a card, space to pick up, arrows to move, space to drop). Multi-select with shift/ctrl-click. Reduced motion: hard cuts, no rotation overshoot.

Four-stage pipeline (one card lifted, one column flagged as drop)

.kanban · .kb-col · .kb-card

Showing the visual states: hover (one card raised), selected (one with the strong accent ring), dragging (one rotated and elevated), drop-target (one column highlighted). Real drag-drop wiring is the consumer's job — this is the visual contract.

Qualified 12
$148k pipeline
Healthcare $48k

BlueRock Health

Alicia Chen · CTO

Logistics $32k

Northpeak Logistics

Marcus Reid · Head of Ops

Industrial $68k

Skyhook Manufacturing

Sara Kim · VP Sales

Discovery 7
$92k pipeline
Tech $22k

Verkada Networks

James Park · Product

Finance $70k

DataHub Ledger

Fran McAllister · CFO

Negotiation 4
$184k pipeline
Healthcare $56k

CareSync Clinical

Dr. Liu · Director

Logistics $128k

Globalfreight Co.

Esra Dogan · COO

Won 3
$96k closed
Closed $96k

FastLane Couriers

Won by Alicia · 3d ago

<div class="kanban">
  <div class="kb-col">
    <div class="kb-col-head">
      <span class="kb-col-stage">Qualified</span>
      <span class="kb-col-count">12</span>
    </div>
    <div class="kb-col-sum">$148k pipeline</div>
    <div class="kb-cards">
      <div class="kb-card" tabindex="0">
        <div class="kb-card-row1">
          <span class="kb-card-industry">Healthcare</span>
          <span class="kb-card-value">$48k</span>
        </div>
        <h4 class="kb-card-name">BlueRock Health</h4>
        <p class="kb-card-contact">Alicia Chen · CTO</p>
        <div class="kb-card-meta">
          <span class="kb-card-glyphs">
            <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M2 3h8v6H2zM2 3l4 3l4-3" stroke-linecap="round" stroke-linejoin="round"/></svg>
            <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="6" cy="6" r="4"/><path d="M6 4v2l1.5 1.5" stroke-linecap="round"/></svg>
            <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M2 4l4-2l4 2v4l-4 2l-4-2z" stroke-linejoin="round"/></svg>
          </span>
          <span class="kb-card-rings">
            <span class="score-ring score-ring--xs" data-band="high">
              <svg viewBox="0 0 36 36"><circle class="track" cx="18" cy="18" r="15"/><circle class="fill" cx="18" cy="18" r="15" stroke-dasharray="94.2" stroke-dashoffset="12.2"/></svg>
            </span>
            <span class="score-ring score-ring--xs score-ring--accent">
              <svg viewBox="0 0 36 36"><circle class="track" cx="18" cy="18" r="15"/><circle class="fill" cx="18" cy="18" r="15" stroke-dasharray="94.2" stroke-dashoffset="26.4"/></svg>
            </span>
          </span>
        </div>
      </div>
    </div>
    <button class="kb-col-add">+ Add deal</button>
  </div>

  <!-- Highlight a column as drop target during a drag -->
  <div class="kb-col is-drop-target">
    <div class="kb-col-head">
      <span class="kb-col-stage">Won</span>
      <span class="kb-col-count">6</span>
    </div>
    <div class="kb-col-sum">$284k closed</div>
    <div class="kb-cards"><!-- card.is-dragging is currently over this column --></div>
  </div>
</div>
.kanban { display: grid;
  grid-template-columns: repeat(4, minmax(220px, 1fr));
  gap: var(--s-4); align-items: flex-start; }

.kb-col { background: var(--bg-sunk);
  border: 1px solid var(--hair); border-radius: var(--r-lg);
  padding: var(--s-3); }
.kb-col.is-drop-target { background: var(--accent-soft);
  border-color: var(--accent); border-style: dashed; }

.kb-card { background: var(--bg-paper); cursor: grab;
  border: 1px solid var(--hair); border-radius: var(--r-md);
  padding: var(--s-3) var(--s-4); }
.kb-card:hover { border-color: var(--accent);
  transform: translateY(-2px); box-shadow: var(--sh-2); }
.kb-card.is-selected { box-shadow: var(--ring-accent-strong);
  border-color: var(--accent); }
.kb-card.is-dragging { transform: rotate(-2deg) translateY(-2px);
  box-shadow: var(--sh-3); cursor: grabbing; }

@media (prefers-reduced-motion: reduce) {
  .kb-card.is-dragging { transform: none; }
}
import { useState } from "react";
import { Kanban, KanbanColumn, KanbanCard } from "@magicblocksai/ui";
import type { KanbanCardData } from "@magicblocksai/ui";

const [deals, setDeals] = useState<KanbanCardData[]>(initial);

function moveCard(id: string, fromCol: string, toCol: string) {
  setDeals((ds) => ds.map((d) => d.id === id ? { ...d, columnId: toCol } : d));
}

const stages = ["qualified", "discovery", "negotiation", "won"] as const;

<Kanban onCardMove={moveCard}>
  {stages.map((stage) => {
    const column = deals.filter((d) => d.columnId === stage);
    const sum = column.reduce((s, d) => s + Number(d.value || 0), 0);
    return (
      <KanbanColumn
        key={stage}
        id={stage}
        label={stage}
        count={column.length}
        value={`$${sum}k pipeline`}
      >
        {column.map((d) => (
          <KanbanCard
            key={d.id}
            id={d.id}
            value={d.value}
            company={d.company}
            contact={d.contact}
            industry={d.industry}
            fitScore={d.fitScore}
            intentScore={d.intentScore}
            activities={d.activities}
          />
        ))}
      </KanbanColumn>
    );
  })}
</Kanban>

// Native HTML drag-drop, no react-dnd. Keyboard: focus a card,
// Space to pick up, ←/→ to move, Space to drop, Esc to cancel.
// Multi-select with shift- or ⌘-click; the Kanban surfaces the live
// Set<string> via onSelectionChange so a chapter-08 BulkBar can hang
// off it. Reduced motion drops the rotation overshoot via the kit CSS.

16.2 Pipeline stage segmented bar

Sits in a deal's page header. A thin segment per stage with the label below — unified with the §16.18 <LifecycleBar>: past stages get a soft ink wash; the current segment fills in --accent with a glow; future stages stay hairline. Each stage takes a name plus an optional one-line metric (e.g. 2d spent, Day 4 current), in body type. Click a future stage to advance (with a confirmation modal); click a past stage to revert (also with confirmation).

Five-stage segmented bar

.pip-bar · .pip-stage

Drives both visual feedback (where am I?) and quick action (move me). Pair with the kanban (14.1) on a record-detail page so the user has both the high-level “deals” view AND a per-deal stage indicator.

Default — at "Negotiation" (3 of 5)
Minimal — labels only, current stage highlighted
<div class="pip-bar" role="navigation" aria-label="Pipeline stages">
  <button class="pip-stage" data-state="past">
    <span class="pip-seg"></span>
    <span class="pip-stage-label">Qualified</span>
    <span class="pip-stage-days">3d</span>
  </button>
  <button class="pip-stage" data-state="current">
    <span class="pip-seg"></span>
    <span class="pip-stage-label">Negotiation</span>
    <span class="pip-stage-days">Day 4</span>
  </button>
  <button class="pip-stage" data-state="future">
    <span class="pip-seg"></span>
    <span class="pip-stage-label">Won</span>
  </button>
  <!-- …seg = the 7px bar; label + optional metric sit below -->
</div>
.pip-bar { display: flex; gap: var(--s-1); }
.pip-stage { flex: 1; display: flex; flex-direction: column; gap: var(--s-2);
  padding: 0; border: 0; background: transparent; cursor: pointer; }
.pip-seg { height: 7px; border-radius: var(--r-pill); background: var(--hair-soft); }
.pip-stage-label { font: 600 10px var(--f-body); text-align: center; color: var(--fg-faint); }
.pip-stage-days  { font: 500 9.5px var(--f-body); text-align: center; color: var(--fg-faint); }
.pip-stage[data-state="past"] .pip-seg { background: color-mix(in oklab, var(--ink) 22%, transparent); }
.pip-stage[data-state="past"] .pip-stage-label { color: var(--fg-dim); }
.pip-stage[data-state="current"] .pip-seg {
  background: var(--accent);
  box-shadow: 0 2px 9px color-mix(in oklab, var(--accent) 45%, transparent); }
.pip-stage[data-state="current"] .pip-stage-label { color: var(--accent-text); font-weight: 700; }
import { useState } from "react";
import { PipelineBar, Modal } from "@magicblocksai/ui";
import type { PipelineStage } from "@magicblocksai/ui";

const stages: PipelineStage[] = [
  { id: "qualified",   label: "Qualified",   metric: "3d" },
  { id: "discovery",   label: "Discovery",   metric: "5d" },
  { id: "negotiation", label: "Negotiation", metric: "Day 4" },
  { id: "won",         label: "Won" },
  { id: "renewal",     label: "Renewal" },
];

const [current, setCurrent] = useState(2);
const [pending, setPending] = useState<number | null>(null);

<PipelineBar
  stages={stages}
  current={current}
  onStageClick={(idx) => idx === current ? null : setPending(idx)}
/>

<Modal
  open={pending !== null}
  onClose={() => setPending(null)}
  title={`Move to ${pending !== null ? stages[pending].label : ''}?`}
>
  <p>This will update the deal stage and notify the team.</p>
  <button onClick={() => { setCurrent(pending!); setPending(null); }}>
    Confirm
  </button>
</Modal>

// Past/current/future is auto-classified from `current`. The component
// only emits the intended target — confirmation lives at the consumer.

16.3 Activity timeline

The heart of the contact / company / deal page. A vertical hairline rail with iconified nodes per event. Filter chips at the top scope by type. Date dividers (“Today”, “Yesterday”) keep the chronology readable. Six row variants — email, conversation, meeting, note, stage-change, custom — each with collapsed and expanded states.

All six row variants in one feed

.act-timeline · .act-row[data-type]

Email rows expand to show body + attachments + open/click meters. Conversation rows show transcript snippet. Meeting rows surface attendees + conference link. Notes render markdown. Stage-change shows from→to chips. Custom is the generic catch-all.

Today
Stage moved by Alicia
Qualified Negotiation
2:14 pm
Email · “Re: Renewal proposal” to Alicia Chen
Hi Alicia — thanks for the quick turn. Attached is the proposal we discussed. Happy to walk through it on Thursday if helpful…

From: jay@magicblocks.ai  To: alicia@bluerock.health  CC: marcus@bluerock.health

Hi Alicia — thanks for the quick turn. Attached is the proposal we discussed. Happy to walk through it on Thursday if helpful. Let me know what works for the team.

Best,
Jay

proposal-bluerock-q2.pdf · 412 KB
2 opens 1 click ✓ replied 23m ago
11:42 am
Yesterday · Apr 25
Conversation · 8m on chat with Alicia Chen
Chat · Outcome: Demo booked · Sage handled · escalated to Alicia at min 4

“…does this integrate with our Epic instance?” Sage routed the question to Alicia after recognising it as billing-system-specific. Alicia confirmed integration available, booked a 30-min demo for Thursday.

Open transcript →

4:08 pm
Meeting · “Discovery call · BlueRock x MagicBlocks”
Thu Apr 25 · 2:00–2:30 pm · Attendees: Alicia, Marcus, Jay · meet.google.com/abc-defg
2:00 pm
Note by Jay
Alicia mentioned procurement adds 2 weeks to any signed contract. Plan close-date with that buffer. Their security review usually clears in 5 days — faster than most.
10:15 am
Apr 24
Custom · Renewal reminder triggered
Automation “90-day renewal sequence” fired. Next touchpoint: scheduled Apr 30.
9:00 am
Email · “Quarterly check-in” to Alicia Chen
Quick note as we head into Q2 — wanted to share a couple of patterns we're seeing…

Full body would expand here. Click the row again to collapse.

3:21 pm
<div class="act-timeline">
  <div class="act-filters">
    <button class="act-filter is-active" type="button">All</button>
    <button class="act-filter" type="button">Email</button>
    <button class="act-filter" type="button">MB Conversation</button>
    <button class="act-filter" type="button">Meeting</button>
    <button class="act-filter" type="button">Note</button>
    <button class="act-filter" type="button">Stage change</button>
  </div>

  <div class="act-divider">Today</div>

  <!-- 1. Stage-change row -->
  <div class="act-row" data-type="stage-change">
    <span class="act-icon"></span>
    <div class="act-body">
      <div class="act-title">Stage moved <span class="act-by">by Alicia</span></div>
      <div class="act-meta-row">
        <span class="act-chip">Qualified</span><span class="act-chip">Negotiation</span>
      </div>
    </div>
    <span class="act-time">2:14 pm</span>
  </div>

  <!-- 2. Email row — native <details> for expand/collapse -->
  <div class="act-row" data-type="email" data-expandable="true">
    <span class="act-icon"></span>
    <details open>
      <summary>
        <div class="act-body">
          <div class="act-title">Email · "Re: Renewal proposal" <span class="act-by">to Alicia Chen</span></div>
          <div class="act-snippet">Hi Alicia — thanks for the quick turn. Attached is the proposal we discussed…</div>
        </div>
      </summary>
      <div class="act-expand">
        <p><strong>From:</strong> jay@magicblocks.ai <strong>To:</strong> alicia@bluerock.health</p>
        <p>Hi Alicia — thanks for the quick turn. Attached is the proposal we discussed.</p>
        <div class="act-attachments">
          <span class="act-attachment">proposal-bluerock-q2.pdf · 412 KB</span>
        </div>
        <div class="act-meters"><span><strong>2</strong> opens</span><span><strong>1</strong> click</span><span>✓ replied 23m ago</span></div>
      </div>
    </details>
    <span class="act-time">11:42 am</span>
  </div>

  <div class="act-divider">Yesterday · Apr 25</div>

  <!-- 3. Conversation row (collapsed details) -->
  <div class="act-row" data-type="conversation" data-expandable="true">
    <span class="act-icon"></span>
    <details><summary>
      <div class="act-body">
        <div class="act-title">Conversation · 8m on chat <span class="act-by">with Alicia Chen</span></div>
        <div class="act-meta-row"><span class="act-chip">Chat</span> · Outcome: Demo booked · Sage handled · escalated at min 4</div>
      </div>
    </summary></details>
    <span class="act-time">4:08 pm</span>
  </div>

  <!-- 4. Meeting row -->
  <div class="act-row" data-type="meeting">
    <span class="act-icon"></span>
    <div class="act-body">
      <div class="act-title">Meeting · "Discovery call · BlueRock x MagicBlocks"</div>
      <div class="act-meta-row">Thu Apr 25 · 2:00–2:30 pm · Attendees: Alicia, Marcus, Jay · <a href="#">meet.google.com/abc-defg</a></div>
    </div>
    <span class="act-time">2:00 pm</span>
  </div>

  <!-- 5. Note row -->
  <div class="act-row" data-type="note">
    <span class="act-icon"></span>
    <div class="act-body">
      <div class="act-title">Note <span class="act-by">by Jay</span></div>
      <div class="act-snippet">Alicia mentioned procurement adds <strong>2 weeks</strong> to any signed contract. Plan close-date with that buffer.</div>
    </div>
    <span class="act-time">10:15 am</span>
  </div>

  <div class="act-divider">Apr 24</div>

  <!-- 6. Custom row (catch-all) -->
  <div class="act-row" data-type="custom">
    <span class="act-icon"></span>
    <div class="act-body">
      <div class="act-title">Custom · Renewal reminder triggered</div>
      <div class="act-snippet">Automation "90-day renewal sequence" fired. Next touchpoint: scheduled Apr 30.</div>
    </div>
    <span class="act-time">9:00 am</span>
  </div>
</div>
.act-row { display: grid;
  grid-template-columns: 28px 1fr auto;
  gap: var(--s-3); padding: var(--s-3) 0; position: relative; }

/* Vertical rail */
.act-row::before { content: ""; position: absolute;
  left: 13px; top: 0; bottom: 0; width: 1px; background: var(--hair); }
.act-row:last-child::before { bottom: 50%; }

.act-row .act-icon { width: 28px; height: 28px; border-radius: 50%;
  background: var(--bg-paper); border: 2px solid var(--hair);
  position: relative; z-index: 1; }

/* Type-specific icon tints */
.act-row[data-type="email"] .act-icon { background: var(--info-soft); color: var(--info-text); }
.act-row[data-type="conversation"] .act-icon { background: var(--accent-soft); color: var(--accent-text); }
.act-row[data-type="meeting"] .act-icon { background: color-mix(in oklab, var(--ink) 8%, transparent); }
.act-row[data-type="note"] .act-icon { background: var(--warning-soft); color: var(--warning-text); }
.act-row[data-type="stage-change"] .act-icon { background: var(--success-soft); color: var(--success-text); }
import { ActivityTimeline } from "@magicblocksai/ui";
import type { ActivityItem } from "@magicblocksai/ui";

const items: ActivityItem[] = [
  // 1. Stage-change — from→to chips
  { id: "sc1", type: "stage-change", ts: "2026-04-28T14:14:00",
    by: "by Alicia", from: "Qualified", to: "Negotiation" },

  // 2. Email — expanded by default with body, attachments, meters
  { id: "e1", type: "email", ts: "2026-04-28T11:42:00",
    title: "Email · \"Re: Renewal proposal\"",
    by: "to Alicia Chen",
    snippet: "Hi Alicia — thanks for the quick turn. Attached is the proposal we discussed…",
    defaultOpen: true,
    from: "jay@magicblocks.ai", to: "alicia@bluerock.health",
    cc: "marcus@bluerock.health",
    body: "Hi Alicia — thanks for the quick turn. Attached is the proposal we discussed. Happy to walk through it on Thursday if helpful.",
    attachments: [{ name: "proposal-bluerock-q2.pdf", size: "412 KB" }],
    meters: { opens: 2, clicks: 1, replied: "✓ replied 23m ago" } },

  // 3. Conversation — chat transcript snippet (collapsed)
  { id: "c1", type: "conversation", ts: "2026-04-25T16:08:00",
    title: "Conversation · 8m on chat",
    by: "with Alicia Chen",
    meta: "Chat · Outcome: Demo booked · Sage handled · escalated to Alicia at min 4",
    transcript: "…does this integrate with our Epic instance? Sage routed the question to Alicia, who confirmed integration available and booked a 30-min demo for Thursday." },

  // 4. Meeting — attendees + conference link
  { id: "m1", type: "meeting", ts: "2026-04-25T14:00:00",
    title: "Meeting · \"Discovery call · BlueRock x MagicBlocks\"",
    when: "Thu Apr 25 · 2:00–2:30 pm",
    attendees: "Alicia, Marcus, Jay",
    conferenceLabel: "meet.google.com/abc-defg",
    conferenceHref: "https://meet.google.com/abc-defg" },

  // 5. Note — markdown body
  { id: "n1", type: "note", ts: "2026-04-25T10:15:00",
    title: "Note", by: "by Jay",
    body: "Alicia mentioned procurement adds **2 weeks** to any signed contract. Plan close-date with that buffer. Their security review usually clears in 5 days — faster than most." },

  // 6. Custom — generic catch-all
  { id: "x1", type: "custom", ts: "2026-04-24T09:00:00",
    title: "Custom · Renewal reminder triggered",
    body: "Automation \"90-day renewal sequence\" fired. Next touchpoint: scheduled Apr 30." },
];

<ActivityTimeline items={items} defaultFilter={"all"} />

// Filter chips auto-derive from the types in `items`. Date dividers
// (Today / Yesterday / "Apr 24") split the feed by calendar day.
// Email + conversation rows expand via native <details>.

16.4 Inbox row

The unified inbox is the most-touched view. Each row is one task to dispatch: avatar of the related contact, title (the action verb-phrase), sub-line (company · industry · last activity), due time + priority chip on the right, snooze + complete buttons on hover. States: open · snoozed (muted) · overdue (red dot) · done (struck-through, fades). Keyboard: e to complete, s to snooze, click to open.

All four states in one inbox

.inbox · .inbox-row[data-state]

The row honours --row-h so it auto-densifies when body[data-density="compact"] is set (see 13.3). Overdue rows get the red dot indicator on the left. Done rows fade so they're visible-but-de-emphasised before scrolling out.

CV
Follow up on Verkada renewal — pending 8 days
Verkada Networks · tech · last touched Apr 18
overdue · 8d High
BR
Send the renewal proposal to BlueRock
BlueRock Health · healthcare · meeting Thu 2pm
today · 4:30 pm High
NP
Reply to Marcus' question about pricing
Northpeak Logistics · logistics · last activity 4h ago
tomorrow Med
SK
Confirm meeting with Skyhook for next week
Skyhook Manufacturing · industrial · snoozed until Apr 28
snoozed · Apr 28 Low
JS
Send NPS survey to top-10 accounts
internal · sent earlier today
done · 1h ago Med
DH
Renew DataHub contract before May 5
DataHub Ledger · finance · 11d remaining
May 5 High
<div class="inbox">
  <!-- 1. Overdue — red dot indicator on the left -->
  <div class="inbox-row" data-state="overdue" tabindex="0">
    <span class="av">CV</span>
    <div class="ix-body">
      <div class="ix-title">Follow up on Verkada renewal — pending 8 days</div>
      <div class="ix-sub">Verkada Networks · tech · last touched Apr 18</div>
    </div>
    <span class="ix-due">overdue · 8d</span>
    <span class="ix-priority" data-p="high">High</span>
  </div>

  <!-- 2. Open — neutral row (default) -->
  <div class="inbox-row" data-state="open" tabindex="0">
    <span class="av">BR</span>
    <div class="ix-body">
      <div class="ix-title">Send the renewal proposal to BlueRock</div>
      <div class="ix-sub">BlueRock Health · healthcare · meeting Thu 2pm</div>
    </div>
    <span class="ix-due">today · 4:30 pm</span>
    <span class="ix-priority" data-p="high">High</span>
  </div>

  <!-- 3. Snoozed — muted (opacity 0.55) -->
  <div class="inbox-row" data-state="snoozed" tabindex="0">
    <span class="av">SK</span>
    <div class="ix-body">
      <div class="ix-title">Confirm meeting with Skyhook for next week</div>
      <div class="ix-sub">Skyhook Manufacturing · industrial · snoozed until Apr 28</div>
    </div>
    <span class="ix-due">snoozed · Apr 28</span>
    <span class="ix-priority" data-p="low">Low</span>
  </div>

  <!-- 4. Done — title strikethrough, avatar fades -->
  <div class="inbox-row" data-state="done" tabindex="0">
    <span class="av">JS</span>
    <div class="ix-body">
      <div class="ix-title">Send NPS survey to top-10 accounts</div>
      <div class="ix-sub">internal · sent earlier today</div>
    </div>
    <span class="ix-due">done · 1h ago</span>
    <span class="ix-priority" data-p="medium">Med</span>
  </div>

  <!-- Keyboard (when row focused): Enter/Space → open · e → complete · s → snooze -->
</div>
.inbox-row { display: grid;
  grid-template-columns: 36px 1fr auto auto;
  gap: var(--s-3); align-items: center;
  padding: 0 var(--s-4);
  height: var(--row-h, var(--row-h-comfortable));
  /* honours density mode automatically */ }

.inbox-row[data-state="overdue"]::before {
  content: ""; position: absolute; left: 0; top: 50%;
  transform: translateY(-50%);
  width: 6px; height: 6px; border-radius: 50%;
  background: var(--error);
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--error) 20%, transparent); }
.inbox-row[data-state="snoozed"] { opacity: 0.55; }
.inbox-row[data-state="done"] .ix-title {
  text-decoration: line-through; color: var(--fg-faint); }
import { Inbox, InboxRow, DensityProvider } from "@magicblocksai/ui";

<DensityProvider defaultDensity={"comfortable"}>
  <Inbox>
    {/* 1. Overdue — red dot indicator on the left */}
    <InboxRow
      id={"t1"}
      state={"overdue"}
      priority={"high"}
      avatar={"CV"}
      title={"Follow up on Verkada renewal — pending 8 days"}
      sub={"Verkada Networks · tech · last touched Apr 18"}
      due={"overdue · 8d"}
      onOpen={(id) => router.push(`/tasks/${id}`)}
      onComplete={complete}
      onSnooze={snooze}
    />

    {/* 2. Open — neutral row (default) */}
    <InboxRow
      id={"t2"}
      state={"open"}
      priority={"high"}
      avatar={"BR"}
      title={"Send the renewal proposal to BlueRock"}
      sub={"BlueRock Health · healthcare · meeting Thu 2pm"}
      due={"today · 4:30 pm"}
      onComplete={complete}
      onSnooze={snooze}
    />

    {/* 3. Snoozed — muted (opacity 0.55) */}
    <InboxRow
      id={"t3"}
      state={"snoozed"}
      priority={"low"}
      avatar={"SK"}
      title={"Confirm meeting with Skyhook for next week"}
      sub={"Skyhook Manufacturing · industrial · snoozed until Apr 28"}
      due={"snoozed · Apr 28"}
      onComplete={complete}
      onSnooze={snooze}
    />

    {/* 4. Done — title strikethrough, avatar fades */}
    <InboxRow
      id={"t4"}
      state={"done"}
      priority={"medium"}
      avatar={"JS"}
      title={"Send NPS survey to top-10 accounts"}
      sub={"internal · sent earlier today"}
      due={"done · 1h ago"}
    />
  </Inbox>
</DensityProvider>

// Keyboard (when row focused): Enter/Space → open, e → complete, s → snooze.
// Wrapping in <DensityProvider> auto-densifies via the chapter-13 --row-h.

16.5 Onboarding checklist

Used for customer onboarding, renewal, and customer-success playbooks. Vertical list with tickbox · title · description · due-date · assignee · “more” menu. Progress bar at the top. Item states: open · done (struck-through, green tick) · skipped (italic, dimmed). Drag-to-reorder is admin-only.

Customer-onboarding playbook (10 items, 4 done)

.checklist

Click the tickbox to toggle done/open. The progress bar updates from the count of done items. Description wraps; long descriptions get truncated with a “Show more” affordance in real use.

Customer onboarding · BlueRock Health

4 of 10 done
40%
Kickoff call scheduled
30-min intro with the customer's exec sponsor + DRI. Send the agenda 24h ahead.
Apr 8 AC
Account provisioned
Workspace created · admin invited · SAML configured for the customer's IdP.
Apr 9 SK
Product training (live)
90-min interactive walkthrough for the customer's team. Recording shared after.
Apr 12 JS
Integration: Slack connected
Sage notifications routed to #customer-success + per-customer channels.
Apr 14 JP
First Sage workflow live
At least one automation running in production for the customer (lead-routing or follow-up).
overdue · Apr 22 MR
Knowledge base seeded
10+ articles uploaded so Sage can answer customer-specific questions.
May 1 AC
30-day check-in scheduled
First success review with metrics + roadmap discussion.
May 8 JS
Custom domain (skipped)
Customer opted out — will use default subdomain.
First quarterly business review
QBR with customer's exec sponsor. Cover usage, ROI, roadmap, expansion.
Jul 10 JS
Renewal conversation kickoff
90 days before contract end. Pull renewal forecast and surface expansion signals.
Jan 1, 2027 AC
<div class="checklist">
  <div class="checklist-head">
    <h4>Customer onboarding · BlueRock Health</h4>
    <div class="checklist-progress">
      <span><strong>4</strong> of 10 done</span>
      <div class="checklist-progress-bar">
        <div class="checklist-progress-fill" style="width: 40%;"></div>
      </div>
      <span>40%</span>
    </div>
  </div>
  <div class="checklist-items">
    <div class="checklist-item" data-state="done">
      <span class="checklist-tick" role="checkbox" aria-checked="true">
        <svg ...check.../>
      </span>
      <div class="checklist-body">
        <div class="checklist-title">Kickoff call scheduled</div>
        <div class="checklist-desc">30-min intro...</div>
      </div>
      <div class="checklist-item-meta">
        <span class="checklist-due">Apr 8</span>
        <span class="av">AC</span>
      </div>
    </div>

    <!-- states: open | done | skipped -->
  </div>
</div>
.checklist-tick { width: 22px; height: 22px;
  border: 1.8px solid var(--hair); border-radius: 50%;
  background: var(--bg-paper); cursor: pointer; }
.checklist-item[data-state="done"] .checklist-tick {
  background: var(--success); border-color: var(--success);
  color: var(--paper); }
.checklist-item[data-state="done"] .checklist-title {
  text-decoration: line-through; color: var(--fg-faint); }
.checklist-item[data-state="skipped"] {
  opacity: 0.5; }
.checklist-item[data-state="skipped"] .checklist-title {
  font-style: italic; }
import { useState } from "react";
import { Checklist } from "@magicblocksai/ui";
import type { ChecklistItem } from "@magicblocksai/ui";

const [items, setItems] = useState<ChecklistItem[]>([
  { id: "a", state: "done",
    title: "Kickoff call scheduled",
    description: "30-min intro with the customer's exec sponsor + DRI.",
    dueDate: "Apr 8", assignee: "AC" },
  { id: "b", state: "open",
    title: "First Sage workflow live",
    description: "At least one automation running in production.",
    dueDate: "overdue · Apr 22", dueOverdue: true, assignee: "MR" },
  { id: "c", state: "skipped",
    title: "Custom domain (skipped)",
    description: "Customer opted out — will use default subdomain." },
]);

<Checklist
  title={"Customer onboarding · BlueRock Health"}
  items={items}
  onToggle={(id) => setItems(toggle(id))}
  onSkip={(id) => setItems(skip(id))}
  reorderable={user.role === "admin"}
  onReorder={(order) => setItems(reorderBy(order))}
/>

// Progress bar derives from items.filter(i => i.state === "done").length.
// Drag-to-reorder is admin-only; consumers gate via the `reorderable` prop.

16.6 MRR composition chart

A vanilla SVG diverging-bar chart for the revenue dashboard. Positive components (New + Expansion) stack above the zero line; negative components (Contraction + Churn) stack below it. The hero number is the current month’s net; the breakdown row directly under it shows the math. An operator can scan the bars and instantly see which months were good, which had spiking churn, and whether expansion is keeping up with cancellations.

12-month MRR composition

.mrr-chart

Static SVG demo. In production, render the bars from your monthly data — the visual contract is what matters: positive stacks above zero, negative stacks below, bold zero rule, hairline gridlines, mono axis labels.

Net new MRR · Apr 2026
Net = New + Expansion − Contraction − Churn
+$48.2k +12.4% vs Mar
This month’s net new
+58.0k New +12.0k Expansion −7.5k Contraction −14.3k Churn = +48.2k Net
+75k +50k +25k 0 −25k MayJun JulAug SepOct NovDec JanFeb Mar Apr
New (acquisition) Expansion (upgrades) Contraction (downgrades) Churn (cancellations)
<div class="mrr-chart">
  <div class="mrr-chart-head">
    <div>
      <div class="mrr-chart-title">Net new MRR · Last 12 months</div>
    </div>
    <div class="mrr-chart-value">$48.2k
      <span class="mrr-chart-delta">+12.4%</span>
    </div>
  </div>

  <!-- Breakdown row: shows the math under the headline number -->
  <div class="mrr-breakdown">
    <span class="mrr-bd"><i class="sw" style="background:var(--success);"></i>+58.0k <small>New</small></span>
    <span class="mrr-bd"><i class="sw" style="background:var(--accent);"></i>+12.0k <small>Exp</small></span>
    <span class="mrr-bd"><i class="sw" style="background:var(--warning);"></i>−7.5k <small>Contr</small></span>
    <span class="mrr-bd"><i class="sw" style="background:var(--error);"></i>−14.3k <small>Churn</small></span>
    <span class="mrr-bd-equals">=</span>
    <span class="mrr-bd is-net">+48.2k <small>Net</small></span>
  </div>

  <!-- Diverging bars: positives stack ABOVE the zero rule, negatives BELOW.
       Scale: 2 px per $1k. Zero line at y=200. Each month is a 53px column
       with a 36px bar. Per month emit 4 rects in the bar group. -->
  <svg viewBox="0 0 720 320">
    <line x1="60" y1="200" x2="696" y2="200" stroke="var(--hair)" stroke-width="1.2"/> <!-- zero rule -->

    <!-- May · centre x=86 · net +27 -->
    <g><rect x="68"  y="136" width="36" height="64"  rx="2" fill="var(--success)"/><rect x="68"  y="126" width="36" height="10" rx="2" fill="var(--green-500)"/><rect x="68"  y="200" width="36" height="4"  rx="2" fill="var(--warning)"/><rect x="68"  y="204" width="36" height="16" rx="2" fill="var(--error)"/></g>
    <!-- Jun · net +29.5 -->
    <g><rect x="121" y="130" width="36" height="70"  rx="2" fill="var(--success)"/><rect x="121" y="118" width="36" height="12" rx="2" fill="var(--green-500)"/><rect x="121" y="200" width="36" height="5"  rx="2" fill="var(--warning)"/><rect x="121" y="205" width="36" height="18" rx="2" fill="var(--error)"/></g>
    <!-- Jul · net +32 -->
    <g><rect x="174" y="124" width="36" height="76"  rx="2" fill="var(--success)"/><rect x="174" y="110" width="36" height="14" rx="2" fill="var(--green-500)"/><rect x="174" y="200" width="36" height="6"  rx="2" fill="var(--warning)"/><rect x="174" y="206" width="36" height="20" rx="2" fill="var(--error)"/></g>
    <!-- Aug · net +35 -->
    <g><rect x="227" y="118" width="36" height="82"  rx="2" fill="var(--success)"/><rect x="227" y="102" width="36" height="16" rx="2" fill="var(--green-500)"/><rect x="227" y="200" width="36" height="7"  rx="2" fill="var(--warning)"/><rect x="227" y="207" width="36" height="21" rx="2" fill="var(--error)"/></g>
    <!-- Sep · net +37.5 -->
    <g><rect x="280" y="112" width="36" height="88"  rx="2" fill="var(--success)"/><rect x="280" y="95"  width="36" height="17" rx="2" fill="var(--green-500)"/><rect x="280" y="200" width="36" height="8"  rx="2" fill="var(--warning)"/><rect x="280" y="208" width="36" height="22" rx="2" fill="var(--error)"/></g>
    <!-- Oct · net +40 -->
    <g><rect x="333" y="106" width="36" height="94"  rx="2" fill="var(--success)"/><rect x="333" y="88"  width="36" height="18" rx="2" fill="var(--green-500)"/><rect x="333" y="200" width="36" height="9"  rx="2" fill="var(--warning)"/><rect x="333" y="209" width="36" height="23" rx="2" fill="var(--error)"/></g>
    <!-- Nov · net +42.5 -->
    <g><rect x="386" y="100" width="36" height="100" rx="2" fill="var(--success)"/><rect x="386" y="81"  width="36" height="19" rx="2" fill="var(--green-500)"/><rect x="386" y="200" width="36" height="10" rx="2" fill="var(--warning)"/><rect x="386" y="210" width="36" height="24" rx="2" fill="var(--error)"/></g>
    <!-- Dec · net +44 -->
    <g><rect x="439" y="96"  width="36" height="104" rx="2" fill="var(--success)"/><rect x="439" y="76"  width="36" height="20" rx="2" fill="var(--green-500)"/><rect x="439" y="200" width="36" height="11" rx="2" fill="var(--warning)"/><rect x="439" y="211" width="36" height="25" rx="2" fill="var(--error)"/></g>
    <!-- Jan · net +44.5 -->
    <g><rect x="492" y="94"  width="36" height="106" rx="2" fill="var(--success)"/><rect x="492" y="73"  width="36" height="21" rx="2" fill="var(--green-500)"/><rect x="492" y="200" width="36" height="12" rx="2" fill="var(--warning)"/><rect x="492" y="212" width="36" height="26" rx="2" fill="var(--error)"/></g>
    <!-- Feb · net +46 -->
    <g><rect x="545" y="90"  width="36" height="110" rx="2" fill="var(--success)"/><rect x="545" y="68"  width="36" height="22" rx="2" fill="var(--green-500)"/><rect x="545" y="200" width="36" height="13" rx="2" fill="var(--warning)"/><rect x="545" y="213" width="36" height="27" rx="2" fill="var(--error)"/></g>
    <!-- Mar · net +47 -->
    <g><rect x="598" y="88"  width="36" height="112" rx="2" fill="var(--success)"/><rect x="598" y="65"  width="36" height="23" rx="2" fill="var(--green-500)"/><rect x="598" y="200" width="36" height="14" rx="2" fill="var(--warning)"/><rect x="598" y="214" width="36" height="27" rx="2" fill="var(--error)"/></g>
    <!-- Apr · current month · net +48.2 -->
    <g><rect x="651" y="84"  width="36" height="116" rx="2" fill="var(--success)"/><rect x="651" y="60"  width="36" height="24" rx="2" fill="var(--green-500)"/><rect x="651" y="200" width="36" height="15" rx="2" fill="var(--warning)"/><rect x="651" y="215" width="36" height="29" rx="2" fill="var(--error)"/></g>
  </svg>

  <div class="mrr-legend">
    <span class="mrr-legend-item"><span class="mrr-legend-swatch" style="background:var(--success);"></span>New <small>(acquisition)</small></span>
    <span class="mrr-legend-item"><span class="mrr-legend-swatch" style="background:var(--green-500);"></span>Expansion <small>(upgrades)</small></span>
    <span class="mrr-legend-item"><span class="mrr-legend-swatch" style="background:var(--warning);"></span>Contraction <small>(downgrades)</small></span>
    <span class="mrr-legend-item"><span class="mrr-legend-swatch" style="background:var(--error);"></span>Churn <small>(cancellations)</small></span>
  </div>
</div>
.mrr-chart { background: var(--bg-paper); padding: var(--s-5);
  border: 1px solid var(--hair); border-radius: var(--r-lg); }
.mrr-chart svg { width: 100%; height: auto; display: block; }

/* Breakdown — the math under the hero number */
.mrr-breakdown {
  display: flex; align-items: center; gap: var(--s-4);
  padding: var(--s-3) var(--s-4); margin-bottom: var(--s-4);
  background: var(--warm-3); border-radius: var(--r-md);
  font: 500 12px var(--f-mono); font-variant-numeric: tabular-nums;
}
body[data-theme="dark"] .mrr-breakdown { background: var(--bg-sunk); }
.mrr-bd { display: inline-flex; align-items: center; gap: 6px; }
.mrr-bd .sw { width: 10px; height: 10px; border-radius: 2px; }
.mrr-bd small { color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.06em; }
.mrr-bd.is-net { font-weight: 700; }

.mrr-legend-item { display: inline-flex; align-items: center;
  gap: 6px; font: 500 12px var(--f-body); cursor: pointer; }
.mrr-legend-item.is-off { opacity: 0.4; }
.mrr-legend-item.is-off .mrr-legend-swatch { filter: grayscale(0.8); }

/* In real use, render bars from your monthly data: per month, emit
   four rects — two stacked above y=0 (New, Expansion), two stacked
   below (Contraction, Churn). Scale = px-per-dollar. */
import { MrrChart } from "@magicblocksai/ui";
import type { MrrPoint } from "@magicblocksai/ui";

const data: MrrPoint[] = [
  { month: "May", new: 32, expansion: 5,    contraction: 2,   churn: 8    },
  { month: "Jun", new: 35, expansion: 6,    contraction: 2.5, churn: 9    },
  { month: "Jul", new: 38, expansion: 7,    contraction: 3,   churn: 10   },
  // …8 more months…
  { month: "Apr", new: 58, expansion: 12,   contraction: 7.5, churn: 14.3 },
];

<MrrChart
  data={data}
  title={"Net new MRR"}
  delta={"+12.4% vs Mar"}
/>

// Scale, gridlines, and bar coordinates are computed from the data —
// no hardcoded geometry. The hero number defaults to the last month's net,
// and `currentMonth={month}` lets you highlight a different point.

16.7 EmailThread

Full email viewer — full-width rows (no chat bubbles). Latest message expanded with older messages collapsed inline. Per-message header with sender, recipients, and a Details toggle. Quoted history auto-collapses behind a “Show trimmed content” chevron (Gmail pattern). Attachment chips render at the bottom of each expanded body. Email is its own UI primitive — reach for <ConversationPreview> / <MessageBubble> for chat-style threads.

Two-message thread, latest expanded

.email-thread · .email-message

A renewal-proposal thread: older message collapsed to a one-line summary, latest message expanded with the body + an attachment chip + the reply / forward action bar. The kit does NOT sanitise the message HTML — consumers pass pre-sanitised markup (DOMPurify, sanitize-html, etc).

<div class="email-thread">
  <div class="email-thread-subject">Re: Renewal proposal</div>
  <div class="email-thread-messages">
    <button class="email-message email-message-collapsed" aria-expanded="false">
      <span class="av">AC</span>
      <span class="email-message-collapsed-from">Alicia Chen</span>
      <span class="email-message-collapsed-preview">Hi Jay — thanks for the…</span>
      <span class="email-message-collapsed-time">3d ago</span>
    </button>
    <div class="email-message email-message-expanded">
      <div class="email-message-head">
        <span class="av">JS</span>
        <div class="email-message-head-body">
          <div class="email-message-head-line">
            <span class="email-message-from">Jay Stockwell</span>
            <span class="email-message-from-email">&lt;jay@magicblocks.ai&gt;</span>
          </div>
          <div class="email-message-head-meta">
            <span class="email-message-to">to alicia@bluerock.health</span>
          </div>
        </div>
        <div class="email-message-head-side">
          <span class="email-message-time">just now</span>
        </div>
      </div>
      <div class="email-message-body">…sanitised HTML…</div>
      <div class="email-message-attachments">
        <div class="email-message-attachment">
          <span class="email-message-attachment-icon"><svg>…</svg></span>
          <span class="email-message-attachment-name">bluerock-renewal.pdf</span>
          <span class="email-message-attachment-size">248 KB</span>
        </div>
      </div>
    </div>
  </div>
  <div class="email-thread-actions">
    <button class="btn btn-secondary">Reply</button>
    <button class="btn btn-secondary">Forward</button>
  </div>
</div>
.email-thread-row {
  display: grid;
  grid-template-columns: 28px 32px 1fr auto 28px;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-2) var(--s-4);
  border-bottom: 1px solid var(--hair-soft);
  background: transparent;
  cursor: pointer;
  position: relative;
  transition: background var(--dur-2) var(--ease);
}

.email-thread-row:last-child { border-bottom: 0; }

.email-thread-row:hover { background: var(--surface-list-row-hover, var(--bg-warm)); }

.email-thread-row.is-unread { box-shadow: inset 3px 0 0 var(--accent); }

.email-thread-row .av { width: 32px; height: 32px; font-size: 12px;
  font-family: var(--f-mono); font-weight: 500; }

.email-thread-row-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; }

.email-thread-row-sender-line { font: 500 13px/1.3 var(--f-body); color: var(--fg);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

.email-thread-row-sub-sender { font-weight: 400; color: var(--fg-soft); }

.email-thread-row-subject-line { font: 400 13px/1.35 var(--f-body); color: var(--fg-soft);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

.email-thread-row-subject { color: var(--fg); font-weight: 400; }

.email-thread-row.is-unread .email-thread-row-sender,
.email-thread-row.is-unread .email-thread-row-subject { font-weight: 600; }

.email-thread-row-time { font: 400 12px/1 var(--f-mono); color: var(--fg-faint);
  white-space: nowrap; }

.email-thread-row-star,
.email-thread-row-archive {
  appearance: none;
  background: transparent;
  border: 0;
  padding: 0;
  width: 28px; height: 28px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--r-xs);
  color: var(--fg-faint);
  cursor: pointer;
  transition: color var(--dur-2) var(--ease),
              background var(--dur-2) var(--ease),
              opacity var(--dur-2) var(--ease);
}

.email-thread-row-star:hover,
.email-thread-row-archive:hover { color: var(--fg); background: var(--bg-paper); }

.email-thread-row-star[data-pressed="true"] { color: var(--warning, #F9AD03); }

.email-thread-row-archive { opacity: 0; }

.email-thread-row:hover .email-thread-row-archive,
.email-thread-row:focus-within .email-thread-row-archive { opacity: 1; }

@media (pointer: coarse) {
  .email-thread-row-archive { opacity: 1; }
}

/* …additional rules trimmed for brevity — see _shared.css */
import { EmailThread } from "@magicblocksai/ui";
import type { EmailMessageData } from "@magicblocksai/ui";
import DOMPurify from "isomorphic-dompurify";

// Pre-sanitise on the consumer side — the kit doesn't sanitise.
const messages: EmailMessageData[] = raw.map((m) => ({
  ...m,
  html: DOMPurify.sanitize(m.html, {
    ALLOWED_TAGS: ["p","br","a","strong","em","blockquote","img"],
    ALLOWED_ATTR: ["href","target","rel","src","alt"],
  }),
}));

<EmailThread
  subject="Re: Renewal proposal — BlueRock Health"
  messages={messages}
  defaultExpanded="latest"
  onReply={(latest) => openReplyComposer(latest)}
  onForward={(latest) => openForwardComposer(latest)}
/>

16.8 EmailThreadRow

Gmail-shaped inbox row primitive. Sibling to <InboxRow> (14.4); both compose into <Inbox>. Reach for <EmailThreadRow> for mailbox-shaped surfaces — an inbox of conversations — and <InboxRow> for task / ticket dispatching (action verb-phrase title + priority pill + complete / snooze actions). 5-column grid: star · avatar · sender + subject + preview · time · archive (hover-discoverable on pointer-fine devices).

Four-row mailbox (mixed unread / starred states)

.email-thread-row · .inbox

Pass unread to paint a 3px accent rail on the left edge and bold the sender + subject; pass starred to fill the star button. Enter / Space on a focused row fires onOpen.

<ul class="inbox" role="list">
  <li class="email-thread-row is-unread" tabindex="0" role="button">
    <button class="email-thread-row-star" aria-pressed="true">
      <svg>…</svg>
    </button>
    <span class="av">EW</span>
    <div class="email-thread-row-body">
      <div class="email-thread-row-sender-line">
        <span class="email-thread-row-sender">Eric Wilson</span>
        <span class="email-thread-row-sub-sender"> · Acme</span>
      </div>
      <div class="email-thread-row-subject-line">
        <span class="email-thread-row-subject">Quarterly review next Tuesday?</span>
        <span class="email-thread-row-preview"> — Wanted to align…</span>
      </div>
    </div>
    <span class="email-thread-row-time">2:14 pm</span>
    <button class="email-thread-row-archive" aria-label="Archive">
      <svg>…</svg>
    </button>
  </li>
</ul>
.section-card-body .inbox,
.section-card-body .checklist,
.section-card-body .calendar,
.section-card-body .panel,
.section-card-body > .data-table {
  border: 0;
  border-radius: 0;
  background: transparent;
  overflow: visible;
  box-shadow: none;
}

.section-card-body > .inbox > .inbox-row:last-child,
.section-card-body > .checklist > li:last-child,
.section-card-body > .data-table .data-table-row:last-child {
  border-bottom: 0;
}

.inbox {
  display: flex; flex-direction: column;
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-lg);
  overflow: hidden;
}

.inbox-row {
  /* 1.5.0 — Spark feedback: 52px comfortable felt cramped because the
     two-line content (title + sub) was crammed into a fixed height with
     no vertical breathing room and the avatar pushed against the title.
     Switched from `height` to `min-height` + symmetric vertical padding
     so the row grows when content needs it (long titles wrap, sub-line
     is more breathable). Comfortable now resolves to 60px equivalent;
     compact (data-density) still hits 36-40px. Avatar bumped 28→32 so
     it reads at the same weight as the title. Title-to-sub gap 2→4px. */
  display: grid;
  /* v1.27.0 (Spark Round R25): grid template ships an extra trailing
     `auto` track. <InboxRow> can render up to 6 children when
     ix-select + av + ix-body + ix-due + ix-priority + ix-actions are
     all present; pre-1.27.0 the base template had only 4 explicit
     tracks and grid auto-flow pushed the 6th child onto a NEW row at
     column 1 (the ✓ complete button rendered in the bottom-left
     corner of any row that set due + priority + onComplete). Trailing
     unused `auto` tracks collapse to 0 width — no regression for rows
     with fewer children. The mobile rule below stacks via subgrid so
     this is desktop-only. */
  grid-template-columns: 36px 1fr auto auto auto;
  gap: var(--s-3); align-items: center;
  padding: 10px var(--s-4);
  min-height: var(--row-h, var(--row-h-comfortable));
  border-bottom: 1px solid var(--hair-soft);
  cursor: pointer;
  transition: background var(--dur-1) var(--ease);
  position: relative;
}

.inbox-row.is-selectable {
  grid-template-columns: 22px 36px 1fr auto auto auto;
}

.inbox-row:last-child { border-bottom: 0; }

.inbox-row:hover { background: var(--surface-list-row-hover); }

/* …additional rules trimmed for brevity — see _shared.css */
import { Inbox, EmailThreadRow } from "@magicblocksai/ui";

<Inbox>
  {threads.map((t) => (
    <EmailThreadRow
      key={t.id}
      id={t.id}
      avatar={initialsOf(t.from)}
      sender={t.from_name}
      subSender={t.company ? ` · ${t.company}` : undefined}
      subject={t.subject}
      preview={t.preview}
      time={formatRelative(t.last_message_at)}
      unread={t.unread_count > 0}
      starred={!!t.starred_at}
      onStar={(next) => setStarred(t.id, next)}
      onArchive={(id) => archive(id)}
      onOpen={(id) => router.push(`/inbox/${id}`)}
    />
  ))}
</Inbox>

16.9 AgendaList family

Time-grouped list — date bands plus per-row content. Designed for calendar / upcoming / agenda surfaces (Today + Tomorrow event groups, booking slot listings, activity feeds bucketed by day). The kit ships the family as four pieces: <AgendaList> (root with date-band groups), <AgendaRow> (polymorphic row — anchor when to, button when onClick, div otherwise), and the <AgendaTitle> / <AgendaMeta> slots for the row body. Composes naturally inside a <SectionCard>.

Today + Tomorrow inside a SectionCard

.agenda-list · .agenda-row

First group shows three events with locations; second group shows one event linked to a video conference. The 64px time column on the left flushes mono; the title sits inline with the meta line.

<div class="agenda-list">
  <section class="agenda-group">
    <header class="agenda-group-head">
      <span class="agenda-group-label">Today</span>
      <span class="agenda-group-meta mono">3 events</span>
    </header>
    <ul class="agenda-group-items">
      <li class="agenda-list-item">
        <a class="agenda-row" href="/events/abc">
          <span class="agenda-row-time mono">9:30am</span>
          <span class="agenda-row-body">
            <span class="agenda-row-title">Renewal call — BlueRock Health</span>
            <span class="agenda-row-meta mono">Room 4 · 30 min</span>
          </span>
        </a>
      </li>
    </ul>
  </section>
</div>
.section-card {
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-lg);
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.section-card-head {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-5);
  background: var(--bg-warm);
  border-bottom: 1px solid var(--hair);
}

.section-card-icon {
  display: inline-flex;
  align-items: center;
  color: var(--fg-dim);
  flex-shrink: 0;
}

.section-card-title {
  font: 600 13px/1.3 var(--f-display);
  color: var(--fg);
  margin: 0;
  letter-spacing: -0.005em;
}

.section-card-head-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.section-card-subtitle {
  font: 400 12.5px/1.35 var(--f-body);
  color: var(--fg-soft);
  margin: 0;
  max-width: 60ch;
}

.section-card-count {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 20px;
  height: 18px;
  padding: 0 6px;
  font: 600 11px/1 var(--f-mono);
  color: var(--fg-dim);
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-pill);
}

.section-card-action {
  margin-left: auto;
  appearance: none;
  background: transparent;
  border: 0;
  font: 500 12px/1 var(--f-mono);
  color: var(--accent-text);
  cursor: pointer;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  text-decoration: none;
  transition: color var(--dur-2) var(--ease);
}

.section-card-action:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }

.section-card-action .btn {
  padding: 5px 12px;
  font: 600 12.5px/1 var(--f-display);
  letter-spacing: 0;
  border-radius: var(--r-sm);
}

.section-card-action .btn-primary { box-shadow: none; }

.section-card-body {
  display: block;
}

.section-card-body.is-padded { padding: var(--s-4) var(--s-5); }

.section-card-body .inbox,
.section-card-body .checklist,
.section-card-body .calendar,
.section-card-body .panel,
.section-card-body > .data-table {
  border: 0;
  border-radius: 0;
  background: transparent;
  overflow: visible;
  box-shadow: none;
}

.section-card-body > .data-table > .data-table-grid {
  border: 0;
  border-radius: 0;
  background: transparent;
}

/* …additional rules trimmed for brevity — see _shared.css */
import {
  SectionCard,
  AgendaList,
  AgendaRow,
  AgendaTitle,
  AgendaMeta,
} from "@magicblocksai/ui";

interface Event { id: string; start: string; summary: string; location?: string; meetUrl?: string; }

<SectionCard title="Calendar">
  <AgendaList<Event>
    groups={[
      { label: "Today",    meta: "3 events", items: today    },
      { label: "Tomorrow", meta: "1 event",  items: tomorrow },
    ]}
    renderItem={(ev) => (
      <AgendaRow time={fmtTime(ev.start)} to={ev.meetUrl ?? `/events/${ev.id}`}>
        <AgendaTitle>{ev.summary}</AgendaTitle>
        {ev.location && <AgendaMeta>{ev.location}</AgendaMeta>}
      </AgendaRow>
    )}
    empty="Nothing scheduled."
  />
</SectionCard>

16.10 DateBand

Standalone date-band primitive. The same uppercase-mono band heading <AgendaList> uses internally is reachable as a sibling primitive too: drop above any list to bucket it by date / day, decoupled from the agenda root. Pairs with arbitrary row markup — tasks, deals, notes, activity feeds.

Two bands separating a list

.date-band

A label + optional right-aligned mono meta. Drop between rows in a list to bucket items by day; the band carries the same hairline top + bottom borders as the agenda-group head.

Today 3 events
Renewal call — BlueRock Health
Discovery — Skyhook Manufacturing
CareSync onboarding kickoff
Tomorrow 1 event
Northpeak follow-up
<div class="date-band">
  <span class="date-band-label">Today</span>
  <span class="date-band-meta mono">3 events</span>
</div>
<!-- …rows… -->
<div class="date-band">
  <span class="date-band-label">Tomorrow</span>
  <span class="date-band-meta mono">1 event</span>
</div>
<!-- …rows… -->
.date-band {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--s-3) var(--s-4);
  background: var(--bg-warm);
  border-top: 1px solid var(--hair-soft);
  border-bottom: 1px solid var(--hair-soft);
}

.date-band-label {
  font: 600 11px/1 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fg-dim);
}

.date-band-meta {
  font: 400 11px/1 var(--f-mono);
  color: var(--fg-faint);
}
import { DateBand } from "@magicblocksai/ui";

<DateBand label="Today" meta="3 events" />
{/* …rows… */}
<DateBand label="Tomorrow" meta="1 event" />
{/* …rows… */}

16.11 DashboardNavGroup

Collapsible nav-group wrapper for long product-app sidebars. Wraps a .dash-nav-label heading + a stack of .dash-nav-item rows in a <details> element so the section can be collapsed by clicking the heading. Built on native <details> / <summary> — the no-JS path works out of the box. Pass persistKey for localStorage persistence; pass forceOpen to auto-expand the group containing the active route without disturbing the saved preference.

Two groups (Today open, Pipeline collapsed)

.dash-nav-group

The chevron rotates 90° on toggle (honours prefers-reduced-motion). The first group is open with three nav items; the second is collapsed.

Today Overview Inbox7 Tasks
Pipeline Leads Deals Won
<details class="dash-nav-group" open>
  <summary class="dash-nav-label">Today</summary>
  <a class="dash-nav-item is-active" href="/overview">
    <span class="dash-nav-item-label">Overview</span>
  </a>
  <a class="dash-nav-item" href="/inbox">
    <span class="dash-nav-item-label">Inbox</span>
    <span class="badge">7</span>
  </a>
</details>
<details class="dash-nav-group">
  <summary class="dash-nav-label">Pipeline</summary>
  <a class="dash-nav-item" href="/leads">…</a>
</details>
.dash-nav-label { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--fg-dim); font-weight: 600; margin: 0 10px var(--s-2); }

.dash-nav-label + .dash-nav-item { margin-top: 4px; }

.dash-nav-item {
  display: flex; align-items: center; gap: 10px;
  /* v1.4.0: bumped from 8px → 7px and exposed as a custom property so
     consumers can set their own vertical rhythm without overriding the
     whole padding shorthand. */
  padding-top: var(--dash-nav-item-py, 7px);
  padding-right: 10px;
  padding-bottom: var(--dash-nav-item-py, 7px);
  padding-left: 10px;
  font: 500 13.5px/1 var(--f-body);
  color: var(--fg-soft);
  text-decoration: none;
  border-radius: var(--r-sm);
  margin-bottom: 2px;
  transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease);
}

.dash-nav-item-label { flex: 1; min-width: 0; }

.dash-nav-item:hover { background: var(--bg-paper); color: var(--fg); }

.dash-nav-item.is-active { background: var(--accent-soft); color: var(--accent-text); font-weight: 600; }

.dash-nav-group { margin: 0; padding: 0; }

.dash-nav-group > summary.dash-nav-label {
  /* `<summary>` defaults to list-item display + a disclosure marker —
     reset both. We swap in our own chevron via ::after so it can be
     positioned and rotated independently. */
  display: flex;
  align-items: center;
  cursor: pointer;
  /* The pre-existing `.dash-nav-label` rule sets `margin: 0 10px var(--s-2)`
     — keep that, but the click target should extend across the row, so
     drop `margin: 0 10px` and use padding instead. */
  margin: 0 0 var(--s-2);
  padding: 4px 10px;
  border-radius: var(--r-sm);
  transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease);
  user-select: none;
  /* Hide the default `<summary>` disclosure triangle in every browser. */
  list-style: none;
}

.dash-nav-group > summary.dash-nav-label::-webkit-details-marker { display: none; }

.dash-nav-group > summary.dash-nav-label:hover { background: var(--bg-paper); color: var(--fg); }

.dash-nav-group > summary.dash-nav-label:focus-visible {
  outline: 0;
  box-shadow: var(--sh-focus);
}

/* …additional rules trimmed for brevity — see _shared.css */
import { DashboardNavGroup } from "@magicblocksai/ui";

<DashboardNavGroup label="Today" persistKey="crm.nav.today">
  <a href="/overview" className="dash-nav-item is-active">
    <span className="dash-nav-item-label">Overview</span>
  </a>
  <a href="/inbox" className="dash-nav-item">
    <span className="dash-nav-item-label">Inbox</span>
    <span className="badge">7</span>
  </a>
  <a href="/tasks" className="dash-nav-item">
    <span className="dash-nav-item-label">Tasks</span>
  </a>
</DashboardNavGroup>

<DashboardNavGroup label="Pipeline" persistKey="crm.nav.pipeline" defaultOpen={false}>
  <a href="/leads" className="dash-nav-item">…</a>
</DashboardNavGroup>

// forceOpen — auto-expand the group containing the active route
// without disturbing the user's saved preference.
<DashboardNavGroup
  label="Today"
  persistKey="crm.nav.today"
  forceOpen={pathname.startsWith("/today")}
>
  …
</DashboardNavGroup>

16.13 Deal-detail composition

The Pipeline-&-CRM primitives compose into the canonical deal-detail page: a sidebar of grouped nav (14.11) beside a stack of titled section cards (14.9 + 14.12) under a page header. This isn’t a new component — it’s the operator-facing assembly that the 7 sections above are designed for. Use it as a layout reference when wiring up record-detail routes.

Sidebar + body composition

.dash-nav-group · .agenda-list · .mini-card-list

Left rail: two collapsible nav groups. Right body: a Linked-companies SectionCard with MiniCardLinks, and a Today-and-tomorrow SectionCard with the AgendaList family. The composition reads as a real deal page; the React tab imports every family member used.

<div class="deal-detail">
  <aside>
    <details class="dash-nav-group" open>
      <summary class="dash-nav-label">Today</summary>
      <a class="dash-nav-item is-active" href="/overview">
        <span class="dash-nav-item-label">Overview</span>
      </a>
    </details>
    <details class="dash-nav-group" open>
      <summary class="dash-nav-label">Pipeline</summary>
      <a class="dash-nav-item" href="/deals">…</a>
    </details>
  </aside>
  <div>
    <!-- SectionCard with .mini-card-list rows -->
    <!-- SectionCard with .agenda-list groups -->
  </div>
</div>
.badge {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 3px 8px;
  border-radius: var(--r-pill);
  font: 500 11.5px/1.2 var(--f-body);
  background: var(--hair-soft);
  color: var(--fg);
  border: 1px solid transparent;
}

.badge-row { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--s-2); }

.badge-accent  { background: var(--accent-soft);  color: var(--accent-text); }

.badge-success { background: var(--success-soft); color: var(--success-text); }

.badge-warning { background: var(--warning-soft); color: var(--warning-text); }

.badge-danger  { background: var(--error-soft);   color: var(--error-text); }

.badge-info    { background: var(--info-soft);    color: var(--info-text); }

.badge-count {
  min-width: 20px; height: 20px; padding: 0 6px;
  justify-content: center;
  font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums;
}

.section-card {
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-lg);
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.section-card-head {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-5);
  background: var(--bg-warm);
  border-bottom: 1px solid var(--hair);
}

.section-card-icon {
  display: inline-flex;
  align-items: center;
  color: var(--fg-dim);
  flex-shrink: 0;
}

.section-card-title {
  font: 600 13px/1.3 var(--f-display);
  color: var(--fg);
  margin: 0;
  letter-spacing: -0.005em;
}

.section-card-head-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.section-card-subtitle {
  font: 400 12.5px/1.35 var(--f-body);
  color: var(--fg-soft);
  margin: 0;
  max-width: 60ch;
}

.section-card-count {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 20px;
  height: 18px;
  padding: 0 6px;
  font: 600 11px/1 var(--f-mono);
  color: var(--fg-dim);
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-pill);
}

.section-card-action {
  margin-left: auto;
  appearance: none;
  background: transparent;
  border: 0;
  font: 500 12px/1 var(--f-mono);
  color: var(--accent-text);
  cursor: pointer;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  text-decoration: none;
  transition: color var(--dur-2) var(--ease);
}

/* …additional rules trimmed for brevity — see _shared.css */
import {
  DashboardNavGroup,
  SectionCard,
  MiniCardLink,
  MiniCardLinkList,
  MiniCardMeta,
  AgendaList,
  AgendaRow,
  AgendaTitle,
  AgendaMeta,
  DateBand,
  BuildingIcon,
} from "@magicblocksai/ui";

<div className="deal-detail">
  <aside>
    <DashboardNavGroup label="Today" persistKey="crm.nav.today" forceOpen>
      <a href="/overview" className="dash-nav-item is-active">
        <span className="dash-nav-item-label">Overview</span>
      </a>
      <a href="/inbox" className="dash-nav-item">
        <span className="dash-nav-item-label">Inbox</span>
        <span className="badge">7</span>
      </a>
    </DashboardNavGroup>
    <DashboardNavGroup label="Pipeline" persistKey="crm.nav.pipeline">
      <a href="/deals" className="dash-nav-item">
        <span className="dash-nav-item-label">Deals</span>
      </a>
    </DashboardNavGroup>
  </aside>
  <div>
    <SectionCard title="Linked companies" count={2}>
      <MiniCardLinkList>
        <MiniCardLink to="/companies/bluerock" icon={<BuildingIcon size={16} />}>
          BlueRock Health
          <MiniCardMeta>primary · $12k MRR</MiniCardMeta>
        </MiniCardLink>
      </MiniCardLinkList>
    </SectionCard>
    <SectionCard title="Upcoming">
      {/* DateBand is the sibling primitive; here we use AgendaList. */}
      {/* <DateBand label="This week" /> */}
      <AgendaList
        groups={[{ label: "Today", meta: "2 events", items: events }]}
        renderItem={(ev) => (
          <AgendaRow time={fmtTime(ev.start)} to={`/events/${ev.id}`}>
            <AgendaTitle>{ev.summary}</AgendaTitle>
            <AgendaMeta>{ev.location}</AgendaMeta>
          </AgendaRow>
        )}
      />
    </SectionCard>
  </div>
</div>

16.14 Reviewer inbox

Human-in-the-loop review queue. Each row is one agent response awaiting reviewer approval before it ships to the customer. Filter chips above the list scope by status; rows carry summary copy, originating-pipeline label, SLA countdown, and per-row Approve / Reject / Edit affordances. Pairs with <DetailShell> chrome — consumers compose this in the left rail and the selected response's detail in the main column.

Pending agent responses — six items

.reviewer-inbox

Filter set to “Pending”; three pending responses visible (r2/r3/r4); r2 selected. The escalated row (r1) is hidden by the active filter; approved/rejected rows live in their respective filter tabs.

  1. Pricing question — agent quoted $4k but $4.2k is currentsales-replies2h 14m
  2. Booking confirmation — agent missed the dietary notebookings5h 42m
  3. Renewal nudge — agent's CTA is for the wrong product linesales-replies1d
<div class="reviewer-inbox">
  <div class="reviewer-inbox-filters" role="group" aria-label="Filter by status">
    <button type="button" class="reviewer-inbox-filter" aria-pressed="true">
      Pending <span class="reviewer-inbox-filter-count">3</span>
    </button>
    <!-- … All / Escalated / Approved / Rejected -->
  </div>
  <ol class="reviewer-inbox-list" role="listbox" aria-label="Review queue">
    <li class="reviewer-inbox-row" role="option" aria-selected="true" tabindex="0">
      <span class="reviewer-inbox-row-status is-pending" aria-hidden="true"></span>
      <span class="reviewer-inbox-row-summary">Pricing question — agent quoted $4k…</span>
      <span class="reviewer-inbox-row-pipeline">sales-replies</span>
      <span class="reviewer-inbox-row-sla">2h 14m</span>
    </li>
    <!-- … -->
  </ol>
</div>

<!-- status: "pending" | "escalated" | "approved" | "rejected" -->
<!-- Per-row Approve / Reject / Edit buttons render on hover when the -->
<!-- consumer wires `onApprove` / `onReject` / `onEdit`. -->
.reviewer-inbox {
  display: flex; flex-direction: column;
  gap: var(--s-3);
  background: var(--bg-paper);
  border: 1px solid var(--hair); border-radius: var(--r-md);
  padding: var(--s-4);
}

.reviewer-inbox-filters {
  display: flex; gap: var(--s-2); flex-wrap: wrap;
}

.reviewer-inbox-filter {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 6px 10px; min-height: 32px;
  background: var(--bg-sunk); border: 1px solid var(--hair); border-radius: 999px;
  font: 500 12px/1 var(--f-body); color: var(--fg-soft);
  cursor: pointer;
}

.reviewer-inbox-filter[aria-pressed="true"] {
  background: var(--accent-soft); border-color: var(--accent); color: var(--accent-text);
}

.reviewer-inbox-filter:focus-visible { outline: 0; box-shadow: var(--sh-focus); }

.reviewer-inbox-filter-count {
  font: 600 11px/1 var(--f-mono);
  padding: 2px 6px; border-radius: 999px;
  background: var(--bg-paper); color: var(--fg-faint);
}

.reviewer-inbox-list {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column;
  border: 1px solid var(--hair); border-radius: var(--r-sm);
  overflow: hidden;
}

.reviewer-inbox-row {
  display: grid;
  grid-template-columns: auto 1fr auto;
  grid-template-rows: auto auto;
  column-gap: var(--s-3); row-gap: 2px;
  padding: var(--s-3) var(--s-4);
  border-bottom: 1px solid var(--hair);
  cursor: pointer;
  align-items: center;
  min-height: 56px;
}

.reviewer-inbox-row:last-child { border-bottom: 0; }

.reviewer-inbox-row:hover { background: var(--bg-sunk); }

.reviewer-inbox-row[aria-selected="true"] { background: var(--accent-soft); }

.reviewer-inbox-row:focus-visible { outline: 0; box-shadow: var(--sh-focus); position: relative; z-index: 1; }

.reviewer-inbox-row-status {
  grid-row: 1 / 3;
  width: 8px; height: 8px; border-radius: 50%;
}

.reviewer-inbox-row-status.is-pending   { background: var(--warning); }

.reviewer-inbox-row-status.is-escalated { background: var(--error); }

.reviewer-inbox-row-status.is-approved  { background: var(--success); }

.reviewer-inbox-row-status.is-rejected  { background: var(--fg-faint); }

.reviewer-inbox-row-summary {
  grid-row: 1; grid-column: 2;
  font: 500 13px/1.4 var(--f-body); color: var(--fg);
  min-width: 0;
}

/* …additional rules trimmed for brevity — see _shared.css */
import { ReviewerInbox } from "@magicblocksai/ui";
import type { ReviewerInboxItem } from "@magicblocksai/ui";

const items: ReviewerInboxItem[] = [
  { id: "r1", status: "escalated", summary: "Customer asked for refund — agent proposed credit instead",
    pipeline: "support-tickets", slaCountdown: "overdue", slaOverdue: true },
  { id: "r2", status: "pending",   summary: "Pricing question — agent quoted $4k but $4.2k is current",
    pipeline: "sales-replies",  slaCountdown: "2h 14m" },
  { id: "r3", status: "pending",   summary: "Booking confirmation — agent missed the dietary note",
    pipeline: "bookings",        slaCountdown: "5h 42m" },
  { id: "r4", status: "pending",   summary: "Renewal nudge — agent's CTA is for the wrong product line",
    pipeline: "sales-replies",  slaCountdown: "1d" },
  { id: "r5", status: "approved",  summary: "FAQ deflection — agent answered correctly, low-risk",
    pipeline: "support-tickets", slaCountdown: "—" },
  { id: "r6", status: "rejected",  summary: "Out-of-scope: legal advice — agent should have escalated",
    pipeline: "support-tickets", slaCountdown: "—" },
];

<ReviewerInbox
  items={items}
  defaultFilter="pending"
  defaultSelectedId="r2"
  onApprove={(id) => approve(id)}
  onReject={(id) => reject(id)}
  onEdit={(id) => openComposer(id)}
/>

// Filter chips: All / Pending / Escalated / Approved / Rejected with per-status counts.
// Selection follows ↑/↓ keyboard nav inside the listbox.
// Approve / Reject / Edit affordances render only when the matching handler is wired.

16.15 Contacts list composition

The workspace CDP. Every person who interacts with an agent — identified or anonymous — gets a row here, with their session count, conversion state, last touch, and any segments they belong to. Built on the same kit primitives as the sessions list (19.7): .tbl-wrap.is-flush + .tbl + .tbl-person + .badge + .dot, with a chapter-private page shell (.ct-*) for the head, KPI card, tabs, toolbar, and pagination.

Contacts list — 114 contacts · 7 days new-contacts KPI

.list-screen.ct-list · .tbl-wrap.is-flush

Header carries the page title + helper copy, a green + Add Contact button, and a compact New Contacts KPI card with a date-range selector. The tabs split the list into Contacts and Segments; the toolbar carries the search field, a sync indicator, and the Refresh / Export / Filters / Add-to-segment buttons. The table itself is the kit's standard .tbl — same warm-sunken header, same row contract (zebra · hover · selected) — with cells composed of .tbl-person + sessions count + conversion badge + last-seen timestamp + segment chips. Footer carries pagination.

Contacts

Capture and convert users who interact with your AI. Manage contacts in one place.More on Contacts →

New Contacts Last 7 days
23 ↑ 18%
10 May – 17 May 2026 · vs prior week
Synced right now
Users 114 Sessions Conversion Last seen Segments
NM
Nectar Mint
nectar.mint@example.co · +44 7700 900 142
12 Meeting booked 2m ago Warm Leads
BC
Bessie Cooper
bessie@trailhaus.io
8 Qualified 3d ago Returning
LA
Leslie Alexander
leslie.a@brookfield.com
17 Demo booked 5h ago Warm Leads Returning
RR
Ronald Richards
ronald@kindred.co
14 Not converted 3d ago
JW
Jenny Wilson
jenny.w@northpeak.co
6 Pricing intent 35m ago Warm Leads
A
Anonymous A2891A
first seen via web chat
1 Not converted 2h ago
JJ
Jacob Jones
jacob.jones@ridgepine.co
9 Quote sent 1d ago Returning
A
Anonymous 5F541F
first seen via SMS
1 Not converted 18d ago
1–8 of 114
<!-- Page chrome composes the kit's .list-screen-* primitive
     (v1.66.0; see _shared.css). Chapter-private .ct-* classes
     only carry contacts-specific bits — green CTA, in-head KPI
     card, search/sync/pill toolbar slots, per-row anon/copy/
     segments/sessions-cell helpers, pagination buttons. -->
<div class="list-screen-frame">
  <div class="list-screen ct-list">
    <header class="list-screen-head">
      <div class="list-screen-head-title">
        <h2>Contacts</h2>
        <p>Capture and convert users… <a href="#">More on Contacts →</a></p>
      </div>
      <div class="list-screen-head-actions">
        <button class="ct-add-contact">+ Add Contact</button>
        <div class="ct-kpi-card">
          <div class="ct-kpi-head">New Contacts <span class="ct-kpi-range">Last 7 days</span></div>
          <div class="ct-kpi-value">23 <span class="ct-kpi-delta">↑ 18%</span></div>
          <div class="ct-kpi-meta">10 May – 17 May 2026 · vs prior week</div>
        </div>
      </div>
    </header>

    <div class="list-screen-tabs">
      <button class="is-active">Contacts <span class="list-screen-tab-count">114</span></button>
      <button>Segments <span class="list-screen-tab-count">3</span></button>
    </div>

    <div class="list-screen-toolbar">
    <label class="ct-search"><input placeholder="Search by user name, email…" /></label>
    <span class="ct-sync">Synced right now</span>
    <button class="ct-pill">Refresh</button>
    <button class="ct-pill">Export</button>
    <button class="ct-pill">Filters</button>
    <button class="ct-pill" disabled>Add to segment</button>
  </div>

  <div class="tbl-wrap is-flush">
    <table class="tbl">
      <thead>
        <tr>
          <th class="tbl-check"><input type="checkbox" /></th>
          <th class="tbl-sort is-active">Users</th>
          <th class="tbl-sort">Sessions</th>
          <th>Conversion</th>
          <th class="tbl-sort">Last seen</th>
          <th>Segments</th>
          <th class="tbl-right"></th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td class="tbl-check"><input type="checkbox" /></td>
          <td>
            <div class="tbl-person">
              <span class="av">NM</span>
              <div>
                <div class="tbl-name">Nectar Mint</div>
                <div class="tbl-sub">nectar.mint@example.co</div>
              </div>
            </div>
          </td>
          <td><span class="ct-sessions-cell">12</span></td>
          <td><span class="badge"><span class="dot dot-green"></span> Meeting booked</span></td>
          <td><span class="mono tbl-mono">2m ago</span></td>
          <td><span class="badge"><span class="dot dot-pink"></span> Warm Leads</span></td>
          <td class="tbl-right"><button class="tbl-icon">…</button></td>
        </tr>
        …
      </tbody>
    </table>
  </div>

  <footer class="ct-list-foot">…pagination…</footer>
</div>
/* The .ct-* contacts-list chrome + the .tbl-check select column ship from
   @magicblocksai/css (operator surface) as of v4.3.0 — compose them directly;
   no CSS to copy. The .tbl / content cells use the kit's standard primitives
   (chapter 7). A few of the shipped rules, for reference: */
.ct-add-contact { background: var(--accent); color: var(--on-accent); border-radius: var(--r-md); }
.ct-kpi-card { padding: var(--s-3) var(--s-4); border: 1px solid var(--hair); border-radius: var(--r-md); }
.ct-search input { padding-left: 32px; border-radius: var(--r-pill); }
.ct-pill { padding: 6px 12px; border-radius: var(--r-pill); border: 1px solid var(--hair); }
.ct-list-foot-pages button { min-width: 26px; height: 26px; border-radius: var(--r-sm); }
.tbl-check { width: 1%; text-align: center; }
import {
  ListScreen,
  ListScreenHead,
  ListScreenKpiStrip,
  ListScreenTabs,
  ListScreenTabCount,
  ListScreenFoot,
  Badge,
  Pagination,
  Button,
} from "@magicblocksai/ui";

/* LEGO-piece composition. The .list-screen-* primitive owns the page
   chrome (frame, head, kpi strip, tabs, body, foot). Consumers build
   the page by composing these pieces — the body is whatever the
   section needs (table, card list, kanban). Here it's the kit's Tbl. */

export function ContactsListPage({ contacts, kpiTiles }) {
  return (
    <ListScreen>
      <ListScreenHead
        title="Contacts"
        description="Every visitor your agents have spoken to."
        actions={<Button tone="success">+ Add contact</Button>}
      />
      <ListScreenKpiStrip>
        {kpiTiles.map(t => <KpiTile key={t.label} {...t} />)}
      </ListScreenKpiStrip>
      <ListScreenTabs role="tablist">
        <button className="is-active" role="tab">
          Contacts <ListScreenTabCount>{contacts.length}</ListScreenTabCount>
        </button>
        <button role="tab">
          Segments <ListScreenTabCount>3</ListScreenTabCount>
        </button>
      </ListScreenTabs>
      <ContactsTable rows={contacts} />
      <ListScreenFoot>
        <Pagination total={114} pageSize={8} />
      </ListScreenFoot>
    </ListScreen>
  );
}

16.16 Contact detail composition

The single-contact page — the kit's AI-native CRM contact view. A warm identity hero (avatar + name + status pill + identifiers + quick actions) carries a five-stage lifecycle bar and a 4-tile stat strip; below sit page tabs (Overview / Sessions / Memories / Notes) over a two-column body. Overview pairs contact information, an editable free-form block, and an activity feed with a sidebar of the Sage summary, engagement score, segments, goals hit, and consent + SMS quiet-hours. Composes the contact-page primitives (16.18–16.24) plus the kebab DropdownMenu.

ContactDetailPage

.cd-page · .cd-hero · composes 16.18–16.24

Hero card with a soft accent wash at the top right gives the page its “this is a real person” warmth. The stat strip carries the four numbers an operator scans first (Sessions · Engagement · Last active · Goals hit). The Sessions tab stacks roomy SessionCards; Memories pairs a free-form MemoryList with a structured KeyFactGrid; Notes is a simple log. Composes EngagementScore (ScoreRing + Sparkline), QuietHoursIndicator, and LifecycleBar.

Jay Stockwell VIP

jay@jay.com.au · +1 (512) 555-0148 · Austin, TX
3:42 PM local · Central Time SMS OK now · 8 AM–9 PM
Lifecycle · Follow-up · 2 days in stage
Engage
Qualify
Follow-up
Converted
Re-engage
Sessions32 positive · 1 neutral
Engagement82 /100↑ high intent
Last active4m agoCREFCO Lead Agent
Goals hit2Demo booked · Qualified

Contact information

Email
jay@jay.com.au
Phone
+1 (512) 555-0148
Location
Austin, TX
Country
United States
Time zone
America/Chicago · CT
Owner
John Carter
Source
Manual
Created
May 14, 2026

Additional information

Jay is refinancing to fund a kitchen renovation — happy for agents to reference his 2019 purchase. Works from home Tue/Thu and is easiest to reach in the early afternoon. Comparing us against two other lenders, so lead with the rate-lock guarantee.
Free-form — included in every agent's context.

Activity

  1. Meeting booked · via CREFCO Lead Agent
  2. Conversation completed · web chat · sentiment positive
  3. Key facts captured · loan_purpose · amount · credit_score
  4. Lifecycle changed · Qualify → Follow-up

AI summarySAGE

Returning lead, high intent. Three sessions in 14 days, two positive. Pre-qualified and a meeting is booked with John Carter today at 2 PM — no open objections.

Engagement score
Segments
Warm leadsReturningVIPTexas
Goals hit
Book a demotoday
Qualify lead9:47 AM

Consent & SMS hours

OK to text now8 AM–9 PM
<div class="cd-page">
  <a class="cd-back">‹ Contacts</a>

  <div class="cd-hero">
    <div class="cd-hero-head">
      <span class="cd-hero-avatar">HV</span>
      <div class="cd-hero-body">
        <h2 class="cd-hero-name">Hạ Vi
          <span class="badge"><span class="dot dot-green"></span> Identified</span>
        </h2>
        <div class="cd-hero-ids">email · phone · location</div>
        <div class="cd-hero-meta">First seen … · Last seen 2m ago</div>
      </div>
      <div class="cd-hero-actions">
        <button class="cd-action">+ Note</button>
        <button class="cd-action">Share</button>
        <button class="cd-action">Options ↓</button>
        <button class="cd-action is-primary">Refresh</button>
      </div>
    </div>
    <div class="cd-stats-strip">
      <div class="cd-stat-tile">…Sessions 12…</div>
      <div class="cd-stat-tile">…Last Active 2m…</div>
      <div class="cd-stat-tile">…Lead Score 78/100…</div>
      <div class="cd-stat-tile">…Lifetime Value $1,250…</div>
    </div>
  </div>

  <nav class="cd-tabs">
    <button class="is-active">Overview</button>
    <button>Sessions <span class="cd-tab-count">12</span></button>
    …
  </nav>

  <div class="cd-body">
    <main class="cd-main">
      <div class="cd-card cd-main-card">
        <div class="cd-card-head"><div class="cd-card-title">Activity</div></div>
        <div class="cd-date-divider">Today</div>
        <div class="act-timeline">
          <!-- composes kit .act-row primitives from chapter 14.3 -->
          <div class="act-row" data-type="custom">…Goal: Meeting booked…</div>
          <div class="act-row" data-type="conversation">…Conversation completed…</div>
          …
        </div>
      </div>
    </main>
    <aside class="cd-side">
      <div class="cd-card">…Contact information · 2-col grid…</div>
      <div class="cd-card">…Segments · chip stack…</div>
      <div class="cd-card">…Consent & compliance · switches…</div>
      <div class="cd-card">…Recent notes…</div>
    </aside>
  </div>
</div>
/* Chapter-private page chrome (.cd-*). Main + sidebar grid swaps
   to single-column under 980px. Activity feed composes the kit's
   .act-timeline + .act-row variants (chapter 14.3) — no new
   timeline primitives invented. Consent toggles use .switch (4)
   and segment chips use .badge + .dot (7.4). */
.cd-page { display: flex; flex-direction: column; gap: var(--s-4); }
.cd-hero { background: var(--bg-paper); border: 1px solid var(--hair); border-radius: var(--r-lg); padding: var(--s-5); box-shadow: var(--sh-1); }
.cd-hero::before { background: radial-gradient(420px 280px at 100% -10%, color-mix(in oklab, var(--accent) 7%, transparent), transparent 70%); }
.cd-stats-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--s-3); }
.cd-stat-tile { padding: var(--s-3) var(--s-4); background: var(--bg-warm); border: 1px solid var(--hair); border-radius: var(--r-md); }
.cd-body { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: var(--s-4); }
.cd-card { background: var(--bg-paper); border: 1px solid var(--hair); border-radius: var(--r-lg); padding: var(--s-4); }
.cd-info-grid { display: grid; grid-template-columns: 90px 1fr; gap: var(--s-2) var(--s-3); }
.cd-consent-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; }
import { ContactDetailPage } from "@magicblocksai/ui";

// Renders the Jay-Stockwell reference data with zero props.
<ContactDetailPage />

// …or wired to your data — composes the contact-page primitives (16.18–16.24):
<ContactDetailPage
  name={<>Jay <em>Stockwell</em></>} initials="JS"
  lifecycle={{ current: "follow-up", daysInStage: 2 }}
  stats={stats}
  engagement={{ score: 82, series, label: "Sentiment · last 14 days" }}
  summary={sageSummary}
  sessions={sessions} memories={memories} keyFacts={facts}
  agents={outboundAgents}
  onShare={share} onOptOut={optOut} onDelete={remove}
/>

16.17 Campaigns list composition

Outbound at portfolio scale. Where the production design is a thin two-tab wizard (upload a CSV → pick an agent → send), this composition treats every campaign as a first-class object you can monitor side-by-side. Workspace-level KPIs (active campaigns, messages sent, replies, conversions) sit above status-filtered cards that surface audience, agent, channel, throughput, and reply economics in one row. Live campaigns show a pulsing progress bar; scheduled ones show their next-fire chip; auto-paused ones explain why.

Campaigns portfolio — 6 campaigns · 4,210 messages sent this week · 14.5% reply rate

.list-screen.cp-list · .cp-card · .cp-progress-bar

Six cards cover every operational state — a high-volume live blast at 78% complete, a multi-step drip with an A/B variant, a scheduled re-engagement waiting on tomorrow, an auto-paused holiday promo (it tripped a reply-rate guardrail), an always-on welcome drip, and a completed campaign with a 20% conversion rate already archived. Each .cp-card composes .kpi-delta-tile (for the strip above) and .badge + .dot (for the per-card status pills) from the kit, with a chapter-private progress bar that takes a state modifier (is-live / is-scheduled / is-paused / is-completed / is-drip) so the same primitive carries five distinct meanings.

Campaigns

Outbound conversations at scale — pick an audience, pick an agent, hit send. Live campaigns auto-pause when reply rates drop below your guardrails.

Active Campaigns
3 of 8
2 live · 1 drip
Messages Sent
4,210
22% this week
Replies
612
14.5% reply rate
Conversions
47 booked
$24,500 attributed
Spring Renewals — Insurance Renewal Auto-pause @ <5% reply
Existing policyholders, renewal window opens this month · 2,400 contacts
Insurance Agent — Charlie v3· SMS · Twilio +1 415…· 13 sending now
Progress
1,872 of 2,400 78%
Reply rate
18.3% 42booked
Status
Live ~36m to finish
Marine Insurance Cold Outreach Cold A/B · 2 variants
Brokerage leads in coastal regions · 4-step drip sequence · 412 contacts
SMS — Buy/Sell v6· SMS · Telnyx +61 4…· Step 2 of 4
Drip step
Step 2 of 4 always-on
Reply rate
5.8% 24booked
Status
Live 412 in-flight
Re-engage 90-day inactive Win-back
Contacts last active 90+ days ago · 1,200 contacts · throttled to 50/min
Re-engagement Agent v1· SMS · Twilio +1 415…· Starts tomorrow 9:00 ICT
Schedule
0 of 1,200 in 18h
Est. reply
~6% (historical)
Status
Scheduled 5/26 09:00 ICT
Q3 Holiday Promo (SMS) Promo Auto-paused
Holiday discount push to entire active list · 8,000 contacts
Promo Agent v2· SMS · Twilio +1 415…· Reply rate dropped to 1.2% — guardrail tripped
Progress
540 of 8,000 paused 2d ago
Reply rate
1.2% 3booked
Status
Paused review reply rate
New Buyer Welcome — DRIP Onboarding
Triggered when a new buyer signs up · 4-step welcome sequence · always-on
Onboarding Agent v4· Web chat + SMS· 187 active enrolments
Drip step
187 in-flight always-on
Reply rate
8.4% 62completed
Status
Live drip · always-on
Spring Hot Lead Follow-Up Hot lead
Contacts who clicked the spring email · 480 contacts
Insurance Agent — Charlie v2· SMS · Twilio +1 415…· Ran 14–18 Apr
Progress
480 of 480 100%
Final reply
20.0% 96booked
Status
Completed archived 19 Apr
Showing 6 of 6 · sorted by Last activity
<!-- Page chrome composes the kit's .list-screen-* primitive
     (added in v1.66.0; see _shared.css). Chapter-private .cp-*
     classes only carry chapter-SPECIFIC overrides (taller min-
     height, .cp-card stack body, .cp-progress-bar variants). -->
<div class="list-screen-frame">
  <div class="list-screen cp-list">
    <header class="list-screen-head">
      <div class="list-screen-head-title">
        <h2>Campaigns</h2>
        <p>Outbound conversations at scale…</p>
      </div>
      <div class="list-screen-head-actions">
        <label class="cp-search"><input placeholder="Search…" /></label>
        <button class="cp-new-button">+ New campaign</button>
      </div>
    </header>

    <div class="list-screen-kpi-strip">
      <div class="kpi-delta-tile">…Active Campaigns 3 of 8…</div>
      <div class="kpi-delta-tile">…Messages Sent 4,210…</div>
      <div class="kpi-delta-tile">…Replies 612…</div>
      <div class="kpi-delta-tile">…Conversions 47 booked…</div>
    </div>

    <div class="list-screen-tabs">
      <button class="is-active">All <span class="list-screen-tab-count">6</span></button>
      <button>Live <span class="list-screen-tab-count">2</span></button>
      <button>Drip <span class="list-screen-tab-count">2</span></button>
      <button>Scheduled <span class="list-screen-tab-count">1</span></button>
      <button>Paused <span class="list-screen-tab-count">1</span></button>
      <button>Completed <span class="list-screen-tab-count">1</span></button>
    </div>

  <div class="cp-card-stack">
    <article class="cp-card">
      <span class="cp-card-avatar">…</span>
      <div class="cp-card-body">
        <div class="cp-card-title-row">Spring Renewals — Insurance
          <span class="cp-card-tag">Renewal</span>
          <span class="cp-card-tag is-guard">Auto-pause @ <5% reply</span>
        </div>
        <div class="cp-card-sub">Existing policyholders…</div>
        <div class="cp-card-meta">Insurance Agent · SMS · 13 sending now</div>
      </div>
      <div class="cp-card-progress">
        <div class="cp-progress-bar is-live"><div class="cp-progress-bar-fill" style="width:78%"></div></div>
        <div class="cp-progress-meta">1,872 of 2,400 · 78%</div>
      </div>
      <div class="cp-card-spark">…sparkline + 18.3% / 42 booked…</div>
      <div class="cp-card-status"><span class="badge"><span class="dot dot-green"></span> Live</span></div>
      <div class="cp-card-actions">…Pause / Share / Open…</div>
    </article>
    …more campaign cards…
  </div>
</div>
/* Chapter-private chrome (.cp-*). The KPI strip uses .kpi-delta-tile
   from chapter 7.27; status pills use .badge + .dot from 7.4. The
   progress bar takes a state modifier so one primitive carries
   five distinct meanings (live / drip / scheduled / paused / completed).
   Live progress bars pulse — animation suppressed under reduced motion. */
.cp-card {
  display: grid;
  grid-template-columns: 48px minmax(260px, 1.4fr) minmax(180px, 1fr) auto auto auto;
  align-items: center; gap: var(--s-4);
  padding: var(--s-3) var(--s-4);
  background: var(--bg-paper);
  border: 1px solid var(--hair); border-radius: var(--r-lg);
}
.cp-progress-bar { height: 6px; border-radius: 999px; background: color-mix(in oklab, var(--ink) 8%, transparent); }
.cp-progress-bar.is-live .cp-progress-bar-fill {
  background: linear-gradient(90deg, var(--accent), color-mix(in oklab, var(--accent) 65%, #FFC6A5));
  animation: cp-progress-pulse 2.4s var(--ease) infinite;
}
.cp-progress-bar.is-paused .cp-progress-bar-fill { background: var(--warning); }
.cp-progress-bar.is-completed .cp-progress-bar-fill { background: var(--success-text); }
@keyframes cp-progress-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.78; } }
@media (prefers-reduced-motion: reduce) {
  .cp-progress-bar.is-live .cp-progress-bar-fill { animation: none; }
}
/* PROVISIONAL — composes kit primitives.
   Existing kit exports used: KpiDeltaTile, Badge, Dot. */

export function CampaignsListPage({ campaigns, kpis }) {
  return (
    <div className="list-screen cp-list">
      <CampaignsListHeader title="Campaigns" onNew={...} />
      <div className="list-screen-kpi-strip">
        {kpis.map(k => <KpiDeltaTile {...k} key={k.label} />)}
      </div>
      <StatusFilterTabs items={["All","Live","Drip","Scheduled","Paused","Completed"]} />
      <div className="cp-card-stack">
        {campaigns.map(c => <CampaignCard campaign={c} key={c.id} />)}
      </div>
    </div>
  );
}

function CampaignCard({ campaign: c }) {
  return (
    <article className="cp-card">
      <CampaignAvatar type={c.type} />
      <div className="cp-card-body">
        <TitleRow title={c.name} tags={c.tags} />
        <p className="cp-card-sub">{c.audienceDescription}</p>
        <Meta>{c.agentName} · {c.channel} · {c.statusLine}</Meta>
      </div>
      <ProgressColumn state={c.state} sent={c.sent} total={c.total} />
      <SparklineColumn data={c.replyRateOverTime} replyRate={c.replyRate} booked={c.booked} />
      <StatusCell status={c.status} meta={c.statusMeta} />
      <ActionStrip onPause={...} onResume={...} onShare={...} onOpen={...} />
    </article>
  );
}

16.18 Lifecycle bar

The lead's journey across the kit's default lifecycle — Engage → Qualify → Follow-up → Converted → Re-engage, generic enough for any industry. Past stages get a soft ink wash; the current stage fills in --accent with a glow (dropped under reduced motion); future stages stay hairline. Sits in a contact / deal page header.

LifecycleBar

.lifecycle-bar · .lfb-stage

Five-stage journey with a caption metric. Off-track states (Dormant / Lost) surface as a state pill, not on the linear bar.

Lifecycle · Follow-up · 2 days in stage
Engage
Qualify
Follow-up
Converted
Re-engage
<div class="lifecycle-bar">
  <div class="lfb-caption">Lifecycle · <span class="lfb-current">Follow-up</span> · 2 days in stage</div>
  <div class="lfb-track">
    <div class="lfb-stage is-past"><span class="lfb-seg"></span><span class="lfb-label">Engage</span></div>
    <div class="lfb-stage is-past"><span class="lfb-seg"></span><span class="lfb-label">Qualify</span></div>
    <div class="lfb-stage is-now"><span class="lfb-seg"></span><span class="lfb-label">Follow-up</span></div>
    <div class="lfb-stage"><span class="lfb-seg"></span><span class="lfb-label">Converted</span></div>
    <div class="lfb-stage"><span class="lfb-seg"></span><span class="lfb-label">Re-engage</span></div>
  </div>
</div>
.lifecycle-bar { display: flex; flex-direction: column; gap: var(--s-2); }
.lfb-track { display: flex; gap: var(--s-1); }
.lfb-stage { flex: 1; display: flex; flex-direction: column; gap: var(--s-2); }
.lfb-seg { height: 7px; border-radius: var(--r-pill); background: var(--hair-soft); }
.lfb-stage.is-past .lfb-seg { background: color-mix(in oklab, var(--ink) 22%, transparent); }
.lfb-stage.is-now  .lfb-seg { background: var(--accent); }
.lfb-label { font: 600 10px/1.2 var(--f-body); text-align: center; color: var(--fg-faint); }
import { LifecycleBar } from "@magicblocksai/ui";

<LifecycleBar current="follow-up" daysInStage={2} />

16.19 Quiet-hours indicator

Read-only SMS-safe send window for a contact's timezone, with a live “now” marker. Glanceable OK to text vs Quiet hours — ties into 10DLC / quiet-hours compliance. A compact variant="pill" rides in a contact hero; the full bar sits in the consent card.

QuietHoursIndicator

.quiet-hours · .qh-bar

The green band is the allowed window (8 AM–9 PM here); the marker is the contact's local time. Status derives from whether “now” falls inside the window.

OK to text now 8 AM–9 PM
<div class="quiet-hours">
  <div class="qh-head">
    <span class="qh-status" data-status="ok">OK to text now</span>
    <span class="qh-window">8 AM–9 PM</span>
  </div>
  <div class="qh-bar">
    <span class="qh-win" style="left:33.33%;width:54.17%"></span>
    <span class="qh-now" style="left:65.42%"></span>
  </div>
</div>
.quiet-hours { display: flex; flex-direction: column; gap: var(--s-2); }
.qh-bar { position: relative; height: 9px; border-radius: var(--r-pill); background: var(--bg-sunk); }
.qh-win { position: absolute; inset: 0 auto; top: 0; bottom: 0; background: var(--success-soft); border-radius: var(--r-pill); }
.qh-now { position: absolute; top: -3px; width: 3px; height: 15px; border-radius: 2px; background: var(--success); transform: translateX(-50%); }
.qh-status { font: 600 12px/1 var(--f-body); color: var(--success-text); }
.qh-status[data-status="quiet"] { color: var(--warning-text); }
import { QuietHoursIndicator } from "@magicblocksai/ui";

<QuietHoursIndicator windowStart={8} windowEnd={21} now={15.7} />

16.20 Memory list

Free-form, agent-summarised memories about a contact — the soft knowledge a structured form can't hold. Each row carries the memory, its source, when it was seen, and a confidence chip. The differentiating store of an AI-native CRM; pairs with the key-fact grid (16.21) on a Memories tab.

MemoryList

.memory-list · .memory-row

Confidence tints the chip (green / amber / neutral). An optional leading icon slot takes a kit glyph.

Prefers email over phone calls.
Sageacross 3 sessionshigh
Time-sensitive — wants to close before an expected rate change in July.
CREFCO Lead Agenttodayhigh
Comparing offers with two other lenders.
Renewal Agent2 weeks agomedium
<div class="memory-list">
  <div class="memory-row">
    <div class="memory-body">
      <div class="memory-text">Prefers email over phone calls.</div>
      <div class="memory-meta">
        <span class="memory-source">Sage</span>
        <span class="memory-ts">across 3 sessions</span>
        <span class="memory-conf" data-conf="high">high</span>
      </div>
    </div>
  </div>
</div>
.memory-row { display: flex; gap: var(--s-3); padding: var(--s-3) 0; border-bottom: 1px solid var(--hair-soft); }
.memory-text { font: 400 14px/1.5 var(--f-body); color: var(--fg); }
.memory-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2); margin-top: 4px; font: 500 11px/1 var(--f-body); color: var(--fg-faint); }
.memory-ts::before { content: "·"; margin-right: var(--s-2); }
.memory-conf { font: 600 9px/1 var(--f-mono); text-transform: uppercase; padding: 2px 6px; border-radius: var(--r-pill); background: var(--success-soft); color: var(--success-text); }
.memory-conf[data-conf="medium"] { background: var(--warning-soft); color: var(--warning-text); }
import { MemoryList } from "@magicblocksai/ui";

<MemoryList memories={[
  { id: "m1", text: "Prefers email over phone calls.", source: "Sage", timestamp: "across 3 sessions", confidence: "high" },
]} />

16.21 Key-fact grid

Structured facts an agent captured about a contact, each with provenance (which agent · when). The keys adapt to any industry — nothing hard-coded. layout="grid" is the bordered card; layout="list" is the compact sidebar form that pairs with the memory list.

KeyFactGrid

.key-fact-grid · .kfg-row

Three columns: key (mono), value, and provenance. Switches to a stacked sidebar list via data-layout="list".

loan_purposeRefinanceCREFCO · today
credit_score760CREFCO · today
employmentW2 — 4 yrsPre-Qual · 3d
<div class="key-fact-grid" data-layout="grid">
  <div class="kfg-row"><span class="kfg-key">loan_purpose</span><span class="kfg-val">Refinance</span><span class="kfg-src">CREFCO · today</span></div>
  <div class="kfg-row"><span class="kfg-key">credit_score</span><span class="kfg-val">760</span><span class="kfg-src">CREFCO · today</span></div>
  <div class="kfg-row"><span class="kfg-key">employment</span><span class="kfg-val">W2 — 4 yrs</span><span class="kfg-src">Pre-Qual · 3d</span></div>
</div>
.key-fact-grid { display: flex; flex-direction: column; border: 1px solid var(--hair); border-radius: var(--r-lg); overflow: hidden; background: var(--bg-paper); }
.kfg-row { display: grid; gap: var(--s-3); align-items: center; padding: var(--s-3) var(--s-4); border-bottom: 1px solid var(--hair-soft); }
.key-fact-grid[data-layout="grid"] .kfg-row { grid-template-columns: 170px 1fr auto; }
.key-fact-grid[data-layout="list"] .kfg-row { grid-template-columns: 1fr; gap: 2px; padding: var(--s-3) 0; }
.kfg-key { font: 600 12px/1.3 var(--f-mono); color: var(--fg-dim); }
.kfg-val { font: 600 13px/1.35 var(--f-body); color: var(--fg); }
import { KeyFactGrid } from "@magicblocksai/ui";

<KeyFactGrid facts={[
  { id: "f1", label: "credit_score", value: "760", source: "CREFCO · today" },
]} />

16.22 Engagement score

A contact's engagement as a drop-in molecule: a score ring paired with a sparkline trend and a quiet “computed from…” basis line. Composes the kit's existing ScoreRing (7.15) + Sparkline (7.17) — no new geometry.

EngagementScore

.engagement-score · .eng-trend

Ring band + spark tint follow tone (success / warning / accent). The basis line keeps the calculation honest and legible.

<div class="engagement-score">
  <div class="eng-main">
    <!-- ScoreRing (7.15) -->
    <span class="score-ring score-ring--lg" data-band="high">…82…</span>
    <div class="eng-trend">
      <!-- Sparkline (7.17) -->
      <span class="spark spark--card" data-risk="low">…</span>
      <span class="eng-label">Sentiment · last 14 days</span>
    </div>
  </div>
  <div class="eng-basis">Computed from sentiment · recency · goal completion</div>
</div>
import { EngagementScore } from "@magicblocksai/ui";

<EngagementScore
  score={82}
  series={[74, 73, 76, 79, 80, 82, 82]}
  label="Sentiment · last 14 days"
  basis="Computed from sentiment · recency · goal completion across 3 sessions"
/>

16.23 Session card

A roomy card for a contact's agent conversations — leads with the outcome, carries a sentiment colour-rail and a scannable metric strip, and tucks the transcript behind a toggle. The clear channel chip (Webchat / SMS / Voice / Email) tells you where the conversation happened at a glance.

SessionCard

.session-card · .channel-chip

Resting state (transcript closed). Drop a <ChatTranscript> into the transcript slot; the toggle reveals it.

CL
CREFCO Lead Agent
Today, 2:14 PM
Webchat Meeting booked
18messages
4m 12sduration
2sources
Positivesentiment
Returning lead asked about current refinance rates. Sage confirmed eligibility, captured loan details, and booked a 2 PM call with John Carter. No objections raised.
loan_purpose = Refinance amount = $420,000
<div class="session-card" data-sentiment="positive">
  <div class="session-card-rail"></div>
  <div class="session-card-main">
    <div class="session-card-head">
      <span class="session-card-agent" data-channel="webchat">CL</span>
      <div class="session-card-id">…CREFCO Lead Agent…</div>
      <span class="channel-chip" data-channel="webchat">Webchat</span>
      <span class="session-card-outcome" data-tone="ok">Meeting booked</span>
    </div>
    <div class="session-card-metrics">…</div>
    <div class="session-card-summary">…</div>
    <button class="session-card-toggle">Show transcript (18)</button>
  </div>
</div>
import { SessionCard, ChatTranscript } from "@magicblocksai/ui";

<SessionCard
  agent="CREFCO Lead Agent" agentInitials="CL" channel="webchat"
  timestamp="Today, 2:14 PM" outcome="Meeting booked" sentiment="positive"
  metrics={[{ value: "18", label: "messages" }, { value: "4m 12s", label: "duration" }]}
  summary="Returning lead asked about refinance rates…"
  transcript={<ChatTranscript messages={messages} />} transcriptCount={18}
/>

16.24 Trigger-agent dialog

The contact page's primary action. Instead of “new conversation,” the operator triggers an agent — the launcher opens a dialog of channel-aware outbound agents, each with a Trigger action. Pair the note with the contact's quiet-hours so SMS / Voice respect the compliant window. Composes Modal + Button.

TriggerAgentDialog

.trigger-agent · .trigger-agent-row

The launcher (left) opens a Modal; its body is previewed here. The dialog renders via a portal at runtime.

SF
SMS Follow-up Agent
Sends a personalised text nudge and books a call.
SMS
VO
Voice Outreach Agent
Places an AI call to check in and answer questions.
Voice
SMS is allowed now (8 AM–9 PM CT); a Voice call placed outside 9 AM–6 PM will be queued until morning.
<!-- launcher -->
<button class="btn btn-primary">Trigger Agent</button>

<!-- dialog body (inside a Modal at runtime) -->
<div class="trigger-agent">
  <div class="trigger-agent-row">
    <span class="trigger-agent-av" data-channel="sms">SF</span>
    <div class="trigger-agent-id">…SMS Follow-up Agent…</div>
    <span class="channel-chip" data-channel="sms">SMS</span>
    <button class="btn btn-primary">Trigger</button>
  </div>
  <div class="trigger-agent-note">SMS is allowed now…</div>
</div>
import { TriggerAgentDialog } from "@magicblocksai/ui";

<TriggerAgentDialog
  contactName="Jay Stockwell"
  agents={[
    { id: "sms", name: "SMS Follow-up Agent", channel: "sms", initials: "SF", primary: true, onTrigger: fire },
    { id: "voice", name: "Voice Outreach Agent", channel: "voice", initials: "VO", onTrigger: fire },
  ]}
  note="SMS is allowed now (8 AM–9 PM CT)…"
/>

16.25 Handover & outcome

What happened at the end of a session — the webhooks that fired and where the contact was handed (a journey block or a human). Pairs with the session summary; the same rows double as terminal activity events at the tail of a transcript.

HandoverOutcome

.handover-outcome · .ho-row

Kind tints the row and draws a CSS glyph (webhook = info diamond, handover = accent arrow) — no icon import. Webhooks show endpoint + status; handovers show the target block.

CRM lead createdPOST crm.acme.com/leads
200
Slack notified#sales
sent
Handed to journey blockPost-demo nurture
<div class="handover-outcome">
  <div class="ho-row" data-kind="webhook">
    <span class="ho-ic" aria-hidden="true"></span>
    <div class="ho-body">
      <span class="ho-label">CRM lead created</span>
      <span class="ho-detail">POST crm.acme.com/leads</span>
    </div>
    <span class="ho-status">200</span>
  </div>
  <div class="ho-row" data-kind="handover">
    <span class="ho-ic" aria-hidden="true"></span>
    <div class="ho-body">
      <span class="ho-label">Handed to journey block</span>
      <span class="ho-detail">Post-demo nurture</span>
    </div>
  </div>
</div>
.ho-row { display: flex; align-items: center; gap: var(--s-3); padding: var(--s-3) 0; border-bottom: 1px solid var(--hair-soft); }
.ho-ic { width: 26px; height: 26px; border-radius: var(--r-sm); background: var(--info-soft); position: relative; }
.ho-row[data-kind="handover"] .ho-ic { background: var(--accent-soft); }
.ho-label { font: 600 13px/1.35 var(--f-body); color: var(--fg); }
.ho-detail { font: 500 11px/1.3 var(--f-mono); color: var(--fg-faint); }
.ho-status { font: 600 10px/1 var(--f-mono); text-transform: uppercase; color: var(--success-text); }
import { HandoverOutcome } from "@magicblocksai/ui";

<HandoverOutcome events={[
  { id: "w1", kind: "webhook", label: "CRM lead created", detail: "POST crm.acme.com/leads", status: "200" },
  { id: "h1", kind: "handover", label: "Handed to journey block", detail: "Post-demo nurture" },
]} />

16.26 Memories dialog

The expand-memories popup from the session summary. The launcher opens a Modal listing every memory the agent captured — text, category, confidence, the source quote it was inferred from, and a “history” badge for memories carried across sessions. Composes Modal + MemoryList.

MemoriesDialog

.memories-dialog · .memory-row

The launcher (left) opens a Modal; its body is previewed here. Each memory carries an optional type tag, an evidence quote, and a history badge when carried from an earlier session. The dialog renders via a portal at runtime.

Prefers Slack for team handoffs
CS team handing off to Slack
preferencethis sessionhigh
Raised SLA concerns previously
historysession #S-4102high
These memories persist to the contact's profile.
<!-- launcher -->
<button class="btn btn-secondary">Expand all memories (5)</button>

<!-- dialog body (inside a Modal at runtime) -->
<div class="memories-dialog">
  <div class="memory-list">
    <div class="memory-row"><div class="memory-body">
      <div class="memory-text">Prefers Slack for team handoffs</div>
      <div class="memory-quote">CS team handing off to Slack</div>
      <div class="memory-meta"><span class="memory-type">preference</span><span class="memory-conf" data-conf="high">high</span></div>
    </div></div>
  </div>
</div>
import { MemoriesDialog } from "@magicblocksai/ui";

<MemoriesDialog memories={[
  { id: "m1", text: "Prefers Slack for team handoffs", type: "preference", confidence: "high", sourceQuote: "CS team handing off to Slack" },
  { id: "m2", text: "Raised SLA concerns previously", source: "session #S-4102", crossSession: true },
]} />

16.27 Session summary band

The header atop a completed session — consistent with the contact overview. A Sage AI summary, then Key facts · Memories (preview) · Handover & outcome. Composes KeyFactGrid + MemoryList + HandoverOutcome; the expand-memories control is passed in as a MemoriesDialog.

SessionSummaryBand

.session-summary-band · .ssb-card

Declarative shell. Stacks to one column on a phone. The memories card shows a preview plus an “Expand all” control that opens the MemoriesDialog.

AI summarySAGE

Sarah from Skyhook asked about 5-seat volume pricing for a CS team moving handoffs to Slack. Sage confirmed the Scale plan, qualified the use case, and booked a walkthrough.

Key facts
seats5
planScale
handoffSlack
Memories
Prefers Slack for team handoffs
this sessionhigh
Evaluating for a 5-person CS team
this sessionhigh
Handover & outcome
CRM lead createdPOST crm.acme.com/leads
200
Handed to journey blockPost-demo nurture
<div class="session-summary-band">
  <div class="ssb-summary">
    <div class="ssb-summary-eb">AI summary<span class="ssb-tag">SAGE</span></div>
    <p class="ssb-summary-text">Sarah asked about 5-seat pricing…</p>
  </div>
  <div class="ssb-cards">
    <div class="ssb-card"><div class="ssb-card-eb">Key facts</div> …KeyFactGrid… </div>
    <div class="ssb-card"><div class="ssb-card-eb">Memories</div> …MemoryList… <button class="ssb-expand">Expand all</button></div>
    <div class="ssb-card"><div class="ssb-card-eb">Handover & outcome</div> …HandoverOutcome… </div>
  </div>
</div>
import { SessionSummaryBand, MemoriesDialog } from "@magicblocksai/ui";

<SessionSummaryBand
  summary="Sarah asked about 5-seat pricing…"
  summaryTag="SAGE"
  keyFacts={[{ id: "f1", label: "seats", value: "5" }]}
  memories={memories}
  handover={[{ id: "h1", kind: "handover", label: "Handed to journey block", detail: "Post-demo nurture" }]}
  memoriesAction={<MemoriesDialog memories={memories} />}
/>

16.28 Session page

A completed session, summary-led — a sibling of the contact overview. A hero (identity · channel · agent · Goals chips · stat row), then the SessionSummaryBand, then the transcript + per-message traces collapsed below. The transcript is a slot — pass a composed ChatTranscript of ChatMessages, each with its Trace in the actions slot.

SessionPage

.session-page · .sp-hero

Shown with the transcript collapsed. The hero leads with achieved Goal chips; the summary band's “Expand all” opens a MemoriesDialog.

‹ Sessions

Sarah Kim · Skyhook

WebchatSage agent · v2.3Apr 28, 9:15 AM#S-4821
Completed
Goals achieved
QualifiedMeeting booked
Duration
4m 12s
Messages
18
Sentiment
Positive
AI summarySAGE

Sarah from Skyhook asked about 5-seat volume pricing for a CS team moving handoffs to Slack. Sage confirmed the Scale plan, qualified the use case, and booked a walkthrough.

Key facts
seats5
planScale
handoffSlack
Memories
Prefers Slack for team handoffs
this sessionhigh
Evaluating for a 5-person CS team
this sessionhigh
Handover & outcome
CRM lead createdPOST crm.acme.com/leads
200
Handed to journey blockPost-demo nurture
<div class="session-page">
  <div class="sp-hero"> …identity · Goals chips · stats… </div>
  <div class="session-summary-band"> …AI summary · Key facts · Memories · Handover… </div>
  <div class="sp-transcript">
    <button class="sp-tcollapse"><span class="sp-tcollapse-chevron"></span>Show transcript<span class="sp-tcollapse-count">18</span></button>
  </div>
</div>
import { SessionPage, ChatTranscript, ChatMessage, Trace } from "@magicblocksai/ui";

<SessionPage
  contactName="Sarah Kim" initials="SK" channel="webchat"
  agent="Sage agent · v2.3" startedAt="Apr 28, 9:15 AM"
  goals={[{ id: "g1", label: "Qualified" }, { id: "g2", label: "Meeting booked" }]}
  stats={[{ label: "Duration", value: "4m 12s" }, { label: "Messages", value: "18" }]}
  summary={summary} keyFacts={facts} memories={memories} handover={handover}
  transcript={<ChatTranscript>{messages}</ChatTranscript>} transcriptCount="18"
/>

16.29 Sessions overview

The top-level Sessions page — every agent conversation, filterable, in the same chrome as the Contacts list (.list-screen + .tbl). A date-range, a stat strip, six state tabs, a filter row (FilterPopover + FilterChipGroup per dimension), and the table with Human-takeover (accent) and Needs-attention (amber) flags.

SessionsOverviewPage

.ss-overview · .list-screen · .tbl

Tabs preset the Status filter. A live row shows a Take over action; a flagged completed row shows Review. Stats & table are scoped by the date range.

Sessions

Every conversation your agents have run — live, needing you, queued, or done.

Sessions
1,284 ↑ 12%
Goal completion
38% ↑ 4 pts
487 hit ≥1 goal
Awaiting takeover
2
Human needed now
Needs attention
9 ↑ 3
Flagged to review
ContactStatusChannelOutcomeMsgsLast activity
SO
Sarah Okafor
+1 ••• 4821
Live
WebchatIn progress…12now
MR
Marcus Reid
+1 ••• 7702
LiveHuman takeoverCustomer asked for a human
SMS72m
NC
Nadia Cole
webchat · anon
CompletedNeeds attentionRules monitor blocked 2 replies
Webchat1426m
TB
Tom Bell
+1 ••• 5560
Completed
SMS✓ Qualified95h
1–8 of 52 sessions
<div class="list-screen-frame"><div class="list-screen">
  <header class="list-screen-head"> …title · .ss-daterange… </header>
  <div class="ss-stats"> …4 × .ss-stat… </div>
  <div class="list-screen-tabs"> …All · Live · Human takeover · Needs attention · Queued · Completed… </div>
  <div class="list-screen-toolbar"> ….ct-search + .filter-popover × N… </div>
  <div class="tbl-wrap is-flush"><table class="tbl"> …rows: .ss-flag · .ss-action… </table></div>
  <div class="list-screen-foot"> …pagination… </div>
</div></div>
import { SessionsOverviewPage, FilterPopover, FilterChipGroup } from "@magicblocksai/ui";

<SessionsOverviewPage
  description="Every conversation your agents have run…"
  dateRange={{ options: ranges, value: "7d", onChange: setRange }}
  stats={stats} tabs={tabs} activeTab={tab} onTabChange={setTab}
  filters={
    <FilterPopover triggerLabel="Status" activeCount={2}>
      <FilterChipGroup options={statusOpts} selected={status} onChange={setStatus} showAll={false} />
    </FilterPopover>
  }
  sessions={rows}
  onRowOpen={openSession} onRowAction={takeOverOrReview}
/>