# Steward — Animated App UI Block for React

> In-app support messenger block: a round launcher with a Tally unread badge that morphs (shared layout) into a 380×600 panel — a full-screen sheet on phones — with Home / Messages / Help tabs. Home has a team avatar stack, reply-time line, system-status strip, highlighted article search, recent conversation and a message CTA. Messages pushes from a thread list into a role="log" conversation with grouped bubbles, typing dots, delivered/read ticks, attachment chips, a hand-off card and a Sluice composer with optimistic send + retry. Help pushes categories → article reader with "Was this helpful?" that collapses into a thanks line. ref.receive() types then delivers, bouncing the launcher once when closed. Escape closes and restores focus; crossfades under reduced motion.

- Page: https://crucibleui.com/components/app/steward
- Category: [app](https://crucibleui.com/components/app)
- Tier: pro (one-time purchase, see https://crucibleui.com/pricing)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/steward
```

Pro install requires adding the token-bearing `@crucible` registry to components.json (issued at https://crucibleui.com/account after purchase).

Import from `@/components/crucible/steward`.

## Props (`StewardProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `team?` | `StewardTeam` | `a seeded three-person team` | Support team: avatar stack, greeting and reply time. |
| `status?` | `StewardStatus \| null` | `all systems operational` | System-status strip on Home. Pass `null` to hide it. |
| `articles?` | `StewardArticle[]` | `seeded articles` | Help-center articles: searchable on Home and Help, grouped by `category`. |
| `conversations?` | `StewardConversation[]` | `seeded sample` | Initial conversations (the widget owns them afterwards). Passing a new array resets them. |
| `onSend?` | `(text: string, conversationId: string) => void \| Promise<unknown>` | — | Fired when the visitor sends a message. The bubble appears optimistically; return a Promise — resolve marks it delivered, reject marks it failed with Retry. |
| `onFeedback?` | `(articleId: string, helpful: boolean) => void \| Promise<unknown>` | — | Fired from "Was this helpful?" on an article. A returned Promise holds the buttons pending; reject shows a retry hint. |
| `inline?` | `boolean` | `false` | Render the panel in place, always open, with no launcher. |
| `defaultOpen?` | `boolean` | `false` | Start open (launcher mode). |
| `defaultTab?` | `StewardTab` | `"home"` | Tab shown first. |
| `onOpenChange?` | `(open: boolean) => void` | — | Fired when the panel opens or closes. |
| `position?` | `"fixed" \| "absolute"` | `"fixed"` | `"fixed"` pins the launcher to the viewport; `"absolute"` pins it to the nearest positioned ancestor (e.g. a preview frame). |
| `launcher?` | `React.ReactNode` | — | Custom launcher icon (replaces the chat glyph). |
| `label?` | `string` | `"Support"` | Accessible name of the panel. |
| `accentColor?` | `string` | `"#38bdf8"` | The single accent: launcher, your bubbles, links, focus rings, active tab. Hex gets auto black/white ink. |
| `ref?` | `React.Ref<StewardHandle>` | — | Imperative handle — `open(tab?)`, `close()`, `receive(msg)`, `replay()`. |
| `className?` | `string` | — | Classes for the root (the panel in `inline` mode). |
| `panelClassName?` | `string` | — | Classes for the panel, e.g. a width or height. |

## Usage example

```tsx
"use client";

import * as React from "react";

import { Steward, type StewardHandle, type StewardIncoming } from "@/components/crucible/steward";

const REPLIES = [
  "Thanks for the message! Let me take a look at that for you.",
  "Good question — I've checked your workspace and everything looks set up correctly.",
  "Done! You should see the change within a minute. Anything else I can help with?",
];

const INCOMING: StewardIncoming[] = [
  { text: "Quick heads-up: your scheduled export is now live and will run every Monday at 9am." },
  { text: "Here's the summary I promised.", attachment: { name: "workspace-audit.pdf", size: 412_000 } },
];

function Bar({ w, className = "" }: { w: string; className?: string }) {
  return <div className={`h-2 rounded-full bg-white/[0.07] ${className}`} style={{ width: w }} />;
}

export default function StewardDemo() {
  const ref = React.useRef<StewardHandle>(null);
  const reply = React.useRef(0);
  const incoming = React.useRef(0);

  const onSend = (_text: string, conversationId: string) =>
    new Promise<void>((resolve) => {
      window.setTimeout(() => {
        resolve();
        window.setTimeout(() => {
          ref.current?.receive({ text: REPLIES[reply.current++ % REPLIES.length]!, conversationId });
        }, 900);
      }, 600);
    });

  return (
    <div className="relative isolate flex h-full min-h-[640px] w-full flex-col items-center justify-center gap-3 overflow-hidden rounded-xl bg-neutral-950 px-3 py-6 sm:px-6">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 -z-10"
        style={{ background: "radial-gradient(50% 45% at 80% 85%, rgba(56,189,248,0.08), transparent 70%)" }}
      />
      {/* A framed fake app page that hosts the launcher. */}
      <div className="relative z-10 h-[600px] w-full max-w-[980px] overflow-hidden rounded-2xl border border-white/[0.08] bg-[#0a0a0b]">
        <div className="flex h-11 items-center gap-2 border-b border-white/[0.06] px-4">
          <span className="size-2.5 rounded-full bg-white/10" />
          <span className="size-2.5 rounded-full bg-white/10" />
          <span className="size-2.5 rounded-full bg-white/10" />
          <div className="mx-auto h-5 w-44 rounded-md bg-white/[0.04]" />
        </div>
        <div className="flex h-[calc(100%-2.75rem)]">
          <aside aria-hidden className="hidden w-48 shrink-0 space-y-3 border-r border-white/[0.06] p-4 sm:block">
            <Bar w="60%" className="h-3 bg-white/[0.1]" />
            <div className="space-y-3 pt-3">
              {["72%", "54%", "66%", "48%", "60%"].map((w, i) => (
                <Bar key={i} w={w} />
              ))}
            </div>
          </aside>
          <main className="min-w-0 flex-1 space-y-5 p-5 sm:p-7">
            <div className="flex flex-wrap items-center justify-between gap-3">
              <div className="space-y-2">
                <p className="text-sm font-semibold text-white/85">Overview</p>
                <Bar w="140px" />
              </div>
              <button
                type="button"
                onClick={() => ref.current?.receive(INCOMING[incoming.current++ % INCOMING.length]!)}
                className="inline-flex h-8 items-center rounded-lg border border-white/10 bg-white/[0.04] px-3 text-xs font-medium text-white/80 transition-colors hover:bg-white/[0.08] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
              >
                Simulate incoming message
              </button>
            </div>
            <div aria-hidden className="grid grid-cols-2 gap-3 lg:grid-cols-3">
              {[0, 1, 2].map((i) => (
                <div key={i} className={`space-y-3 rounded-xl border border-white/[0.06] bg-white/[0.02] p-4 ${i === 2 ? "hidden lg:block" : ""}`}>
                  <Bar w="40%" />
                  <Bar w="65%" className="h-4 bg-white/[0.1]" />
                </div>
              ))}
            </div>
            <div aria-hidden className="space-y-3 rounded-xl border border-white/[0.06] bg-white/[0.02] p-4">
              {["90%", "76%", "84%", "62%", "70%"].map((w, i) => (
                <Bar key={i} w={w} />
              ))}
            </div>
          </main>
        </div>
        <Steward ref={ref} position="absolute" defaultOpen onSend={onSend} />
      </div>
      <p className="relative z-10 text-xs text-white/45">Send a message — close it, then reopen the launcher.</p>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.
