# Themeable chat-client shell

> One chat mock that reskins to look like Claude, ChatGPT, Goose, or VS Code — canvas, chrome, bubbles, accent, avatar and serif-vs-sans all swap from a single theme token set.

- **Category:** Marketing
- **Source (best-in-class):** [daysurface.com](https://daysurface.com)
- **Tags:** chat, mock, hero, theme, tokens, css-variables, claude, chatgpt, marketing
- **Dependencies:** react, tailwindcss
- **Status:** stable
- **Live demo:** https://eito.me/ui/chat-shell

## Problem

A landing page that shows a product living “inside your chat client” needs the mock to read as that client — but maintaining four separate chat UIs is four times the markup. This shell carries the whole client identity in a small token set (spread onto the root as CSS custom properties); every element reads the tokens via var(), so switching client is a one-object swap with no re-render and no reflow.

## When to use

- You demo something embedded in Claude / ChatGPT / Copilot and want the frame to look native to each.
- A client toggle should reskin the mock instantly, at first paint, with no flash of the wrong theme.
- You want conversation content (bubbles, assistant lines, tool-call chips) decoupled from the client skin.

## Code — snippet.tsx

```tsx
import type { CSSProperties, ReactNode } from "react";

/**
 * ChatShell — a themeable chat-client mock, ported from the daysurface.com
 * landing page hero (its four-client chat toggle).
 *
 * The idea worth copying: ONE chat component that reskins to look like Claude,
 * ChatGPT, Goose, or VS Code. The entire client identity — canvas, chrome,
 * bubble colour, accent, avatar mark, serif-vs-sans, name, composer
 * placeholder — is carried in a small `ChatTheme` token set. On the real site
 * those tokens are CSS custom properties keyed off `<html data-client>`, so
 * selecting a client anywhere on the page re-skins every chat frame in CSS at
 * first paint, with no JS in the swap.
 *
 * This snippet keeps that shape: the theme is spread onto the shell root as
 * `--chat-*` custom properties, and every nested element reads them via
 * `var(--chat-*)`. Swap the theme object and the whole shell changes identity.
 *
 * A conversation is a list of turns (`user` bubble · `reason` summary line ·
 * `assistant` line · `tool` call chip), so the same shell renders any script.
 * The assistant line uses the theme's `sayFont` (serif for Claude, sans
 * elsewhere) — the small tell that makes each client read as itself.
 *
 * Deps: react + tailwindcss only. Each client's real brand SVG is supplied as a
 * URL (theme.logo) and painted via CSS `mask`, so it tints to the accent and
 * two instances never collide on the ids inside a multi-path logo (VS Code).
 */

/* ---- theme model ----------------------------------------------------------- */

/**
 * How a client draws a completed tool call. Each real client has its own idiom,
 * so this is part of the theme, not a shared chip:
 *   - timeline (Claude) muted activity row — the client mark (or the tool's own
 *                    app icon) + a sans muted label ("Verifying available
 *                    tools and resources", "Gmail status"), no border
 *   - task (ChatGPT) borderless activity title ("Curating Inbox…") + spinner icon,
 *                    sans muted — the human-readable task, not the raw tool id
 *   - pill           bordered mono pill, accent glyph, trailing ✓
 *   - bare           borderless muted "Used <name>" line, no status mark
 *   - card (Goose)   bordered card: filled-accent check disc · name · chevron
 *   - row  (VS Code) bordered row, "Ran <name>" (name in a link colour) · chevron
 */
export interface ToolCallStyle {
  variant: "timeline" | "task" | "pill" | "bare" | "card" | "row";
  /** Verb prefix before the tool name, e.g. "Used" / "Ran". */
  verb?: string;
  /** Trailing status mark: an inline ✓ (Claude) or a filled disc (Goose). */
  check?: "inline" | "disc";
  /** Trailing disclosure chevron (Goose, VS Code). */
  chevron?: boolean;
  /** Override the tool-name colour — VS Code's link blue, distinct from accent. */
  nameColor?: string;
}

export interface ChatTheme {
  /** Client id, also the React key when mapping presets. */
  id: string;
  /** Display name in the header pill. */
  name: string;
  /** Page/message canvas. */
  canvas: string;
  /** Header + composer chrome surface. */
  chrome: string;
  /** Hairline borders. */
  border: string;
  /** Primary text. */
  text: string;
  /** Muted text (placeholder, secondary). */
  muted: string;
  /** Brand accent — avatar tint, send button, tool-chip icon. */
  accent: string;
  /** Foreground drawn ON the accent (send-button arrow). */
  accentFg: string;
  /** User bubble fill. */
  bubble: string;
  /** User bubble text (defaults to `text`). */
  bubbleText?: string;
  /** Assistant copy font — a serif here is the Claude tell. */
  sayFont: string;
  /** Composer placeholder line. */
  placeholder: string;
  /**
   * URL of the client's brand SVG (e.g. the real Claude / OpenAI / Goose /
   * VS Code mark). Rendered via CSS `mask`, so it's tinted to a single colour
   * (the accent) and never collides on ids the way inline multi-path logos do.
   */
  logo: string;
  /** Whether assistant turns show the avatar mark. ChatGPT/Goose render none. */
  assistantAvatar: boolean;
  /** How this client renders a tool call — each has its own idiom. */
  toolCall: ToolCallStyle;
  /**
   * Phrasing this client puts before a reasoning duration, e.g. ChatGPT's
   * "Worked for" (→ "Worked for 14s") or Claude's "Thought for". Absent means
   * this client shows no reasoning summary, and `reason` turns are dropped for
   * it — the same one-conversation-many-idioms trick `label` uses on tool calls.
   */
  reasonVerb?: string;
}

/**
 * The four presets reproduced from the DaySurface hero. Their `logo` paths are
 * root-relative to THIS site's `/icons` library, so copied verbatim into another
 * app the masked marks resolve against that app's root and render blank. When
 * reusing these presets elsewhere, point `logo` at your own asset URLs (the demo
 * prefixes `import.meta.env.BASE_URL` to resolve them under any base path).
 */
export const chatThemes: Record<string, ChatTheme> = {
  claude: {
    id: "claude",
    name: "Claude",
    canvas: "#f5f1e8",
    chrome: "#faf8f2",
    border: "#e5e0d6",
    text: "#2d2a26",
    muted: "#9a9286",
    accent: "#c96442",
    accentFg: "#ffffff",
    bubble: "#e7e1d4",
    sayFont: "Georgia, 'Times New Roman', serif",
    placeholder: "Reply to Claude…",
    logo: "/icons/agents/claude.svg",
    assistantAvatar: true,
    toolCall: { variant: "timeline" },
  },
  chatgpt: {
    id: "chatgpt",
    name: "ChatGPT",
    canvas: "#212121",
    chrome: "#303030",
    border: "#3a3a3a",
    text: "#ececec",
    muted: "#b4b4b4",
    accent: "#ffffff", // current ChatGPT mark is near-white, not the legacy teal
    accentFg: "#0d0d0d",
    bubble: "#2f4a7a", // current ChatGPT user bubble is blue
    bubbleText: "#ffffff",
    sayFont: "system-ui, sans-serif",
    placeholder: "Ask ChatGPT",
    logo: "/icons/agents/chatgpt.svg",
    assistantAvatar: false, // ChatGPT replies are plain full-width text, no avatar
    toolCall: { variant: "task", chevron: true },
    reasonVerb: "Worked for", // collapsed "Worked for 14s ⌄" reasoning summary
  },
  goose: {
    id: "goose",
    name: "goose",
    canvas: "#22252a",
    chrome: "#3f434b",
    border: "#474e57",
    text: "#e3e6ea",
    muted: "#878787",
    accent: "#13bbaf",
    accentFg: "#10221f",
    bubble: "#ffffff",
    bubbleText: "#22252a",
    sayFont: "system-ui, sans-serif",
    placeholder: "Ask goose anything…",
    logo: "/icons/agents/goose.svg",
    assistantAvatar: false, // Goose replies are plain text + timestamp, no avatar
    toolCall: { variant: "card", check: "disc", chevron: true },
  },
  vscode: {
    id: "vscode",
    name: "VS Code",
    canvas: "#1f1f1f",
    chrome: "#181818",
    border: "#3c3c3c",
    text: "#cccccc",
    muted: "#858585",
    accent: "#0078d4",
    accentFg: "#ffffff",
    bubble: "#2b2b2b",
    sayFont: "system-ui, sans-serif",
    placeholder: "Ask Copilot",
    logo: "/icons/agents/vscode.svg",
    assistantAvatar: true,
    toolCall: { variant: "row", verb: "Ran", chevron: true, nameColor: "#4daafc" },
  },
};

/* ---- conversation model ---------------------------------------------------- */

export type Turn =
  | { role: "user"; text: string }
  | { role: "assistant"; text: string }
  // A client's collapsed reasoning summary ("Worked for 14s"). `seconds` is the
  // duration; the verb ("Worked for") is the client's, from `theme.reasonVerb`,
  // so one turn reads natively per client (and vanishes where there's no verb).
  | { role: "reason"; seconds: number }
  // `name` is the raw tool id (shown by Goose/VS Code); `label` is the human
  // activity title Claude/ChatGPT show instead ("Curating Inbox…"). `icon` is an
  // optional full-colour app-icon URL the Claude timeline uses in place of the
  // client mark (e.g. a Gmail mark for a Gmail tool).
  | { role: "tool"; name: string; label?: string; icon?: string; ok?: boolean };

type ToolTurn = Extract<Turn, { role: "tool" }>;

export interface ChatShellProps {
  theme: ChatTheme;
  conversation: Turn[];
  /** Fixed body height so a client swap never reflows the surrounding layout. */
  bodyHeight?: number;
  className?: string;
}

/* ---- marks ----------------------------------------------------------------- */

/**
 * The client's real brand mark — the SVG at `logo`, painted through a CSS mask
 * so it renders as a crisp single-colour silhouette in `color` (default: the
 * accent). Masking (vs. an <img>) lets currentColor logos show on dark canvases
 * and avoids id clashes between two instances of a multi-path logo.
 */
function Mark({ logo, size = 20, color = "var(--chat-accent)" }: { logo: string; size?: number; color?: string }) {
  return (
    <span
      aria-hidden="true"
      className="inline-block shrink-0"
      style={{
        width: size,
        height: size,
        background: color,
        WebkitMask: `url("${logo}") center / contain no-repeat`,
        mask: `url("${logo}") center / contain no-repeat`,
      }}
    />
  );
}

/* ---- tool calls ------------------------------------------------------------ */

/** The "run / return" glyph every client puts before a tool name. */
const RunGlyph = ({ color }: { color: string }) => (
  <svg
    className="size-3 shrink-0"
    viewBox="0 0 24 24"
    fill="none"
    stroke={color}
    strokeWidth={2}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    <path d="M7 8l4 4-4 4m6 0h6" />
  </svg>
);

/** Trailing disclosure chevron (Goose / VS Code). */
const DiscloseChevron = () => (
  <svg
    className="size-3.5 rotate-90 shrink-0"
    viewBox="0 0 24 24"
    fill="none"
    stroke="var(--chat-muted)"
    strokeWidth={2}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    <path d="M9 6l6 6-6 6" />
  </svg>
);

/** Down caret (collapse/expand affordance) — header model pill, ChatGPT task chip and reasoning line. Inherits colour via currentColor. */
const CaretDown = ({ className = "size-3.5 shrink-0" }: { className?: string }) => (
  <svg
    className={className}
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={2}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    <path d="M6 9l6 6 6-6" />
  </svg>
);

/**
 * A completed tool call, drawn in the current client's idiom (theme.toolCall).
 * This is the part that must NOT be uniform — each client renders it differently.
 */
function ToolCall({ turn, style }: { turn: ToolTurn; style: ToolCallStyle }) {
  const label = style.verb ? `${style.verb} ${turn.name}` : turn.name;

  // The Claude "timeline" variant is drawn as connected nodes by the shell
  // (TimelineNode / Row), not here — it needs cross-row rail state.

  if (style.variant === "task") {
    // ChatGPT: borderless activity title ("Curating Inbox…" / "Called tool") in
    // sans muted, an activity glyph before it and a disclosure chevron after.
    return (
      <div className="inline-flex items-center gap-2 text-sm" style={{ color: "var(--chat-muted)" }}>
        {/* ChatGPT's tool-invocation "plug" glyph */}
        <svg
          className="size-4 shrink-0"
          viewBox="32 40 96 100"
          fill="none"
          stroke="currentColor"
          strokeWidth={8}
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden="true"
        >
          <path d="M 98 106 C 109.32 104.74, 120.65 103.48, 125 97 C 130.16 89.32, 125.52 74.29, 118 64 C 110.51 53.74, 100.17 48.19, 89 47 C 77.64 45.79, 65.42 49.10, 56 56 C 46.84 62.72, 40.33 72.83, 38 84 C 35.65 95.25, 37.54 107.56, 44 117 C 50.73 126.84, 62.43 133.56, 74 135 C 85.42 136.42, 96.71 132.71, 108 129" />
          <path d="M 65 92 C 64.40 94.05, 63.79 96.10, 64 98 C 64.39 101.57, 67.62 104.63, 71 107 C 74.19 109.24, 77.52 110.87, 81 111 C 83.98 111.11, 87.08 110.13, 90 109 C 92.41 108.07, 94.71 107.03, 97 106" />
          <path d="M 98 105 C 99.20 102.06, 100.41 99.12, 101 96 C 101.50 93.37, 101.56 90.60, 101 88 C 100.37 85.09, 98.96 82.39, 97 80 C 95.11 77.70, 92.70 75.70, 90 75 C 87.80 74.43, 85.40 74.71, 83 75" />
          <path d="M 65 92 L 83 75" />
          <path d="M 65 92 L 60 85" />
          <path d="M 83 75 L 76 69" />
        </svg>
        <span className="min-w-0 truncate">{turn.label ?? "Called tool"}</span>
        {style.chevron && <CaretDown />}
      </div>
    );
  }

  if (style.variant === "bare") {
    // ChatGPT: borderless muted line, "Used <name>", no status mark.
    return (
      <div
        className="inline-flex items-center gap-1.5 font-mono text-[11px]"
        style={{ color: "var(--chat-muted)" }}
      >
        <RunGlyph color="currentColor" />
        {label}
      </div>
    );
  }

  if (style.variant === "card") {
    // Goose: bordered card — filled-accent check disc · name · chevron.
    return (
      <div
        className="rounded-lg border"
        style={{ borderColor: "var(--chat-border)", background: "var(--chat-canvas)" }}
      >
        <div className="flex items-center gap-2 px-3 py-2">
          {/* Success disc — suppressed on a failed call (turn.ok === false) so a
              failure never reads as done, matching the pill variant's trailing ✓. */}
          {turn.ok !== false && style.check === "disc" && (
            <span
              className="grid size-4 shrink-0 place-items-center rounded-full"
              style={{ background: "var(--chat-accent)", color: "var(--chat-accent-fg)" }}
            >
              <svg
                className="size-2.5"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={3.5}
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
              >
                <path d="M20 6 9 17l-5-5" />
              </svg>
            </span>
          )}
          <span className="min-w-0 flex-1 truncate font-mono text-xs" style={{ color: "var(--chat-text)" }}>
            {turn.name}
          </span>
          {style.chevron && <DiscloseChevron />}
        </div>
      </div>
    );
  }

  if (style.variant === "row") {
    // VS Code: bordered row, "Ran <name>" (name in a link colour) · chevron.
    return (
      <div
        className="flex items-center gap-1.5 rounded border px-2 py-1 text-[11px]"
        style={{ borderColor: "var(--chat-border)", background: "var(--chat-canvas)", color: "var(--chat-muted)" }}
      >
        <RunGlyph color="var(--chat-muted)" />
        <span className="min-w-0 flex-1 truncate font-mono">
          {style.verb ? `${style.verb} ` : ""}
          <span style={{ color: style.nameColor ?? "var(--chat-accent)" }}>{turn.name}</span>
        </span>
        {style.chevron && <DiscloseChevron />}
      </div>
    );
  }

  // Claude (pill): bordered mono pill, accent glyph, trailing ✓.
  return (
    <div
      className="inline-flex items-center gap-1.5 rounded-lg border px-2 py-1 font-mono text-[11px]"
      style={{ background: "var(--chat-chrome)", borderColor: "var(--chat-border)", color: "var(--chat-muted)" }}
    >
      <RunGlyph color="var(--chat-accent)" />
      {turn.name}
      {turn.ok !== false && style.check === "inline" && (
        <span style={{ color: "var(--chat-accent)" }}>✓</span>
      )}
    </div>
  );
}

/* ---- turns ----------------------------------------------------------------- */

/** A rail colour derived from the muted token, kept faint like a thread line. */
const RAIL = "color-mix(in srgb, var(--chat-muted) 55%, transparent)";

/**
 * One node on Claude's activity timeline: a left icon on a vertical rail (drawn
 * above/below as `railTop`/`railBottom` say, so consecutive nodes read as one
 * connected thread) plus the row's content. The icon sits on a canvas-coloured
 * chip so the rail "breaks" cleanly at each node.
 */
function TimelineNode({
  icon,
  railTop,
  railBottom,
  children,
}: {
  icon: ReactNode;
  railTop: boolean;
  railBottom: boolean;
  children: ReactNode;
}) {
  return (
    // Flush rows (no gap) with a min-height give the rail room to run unbroken
    // from one node to the next; the icon chip punches a hole at each node.
    <div className="flex min-h-[44px] gap-2.5">
      <div className="relative flex w-5 shrink-0 items-center justify-center self-stretch">
        {railTop && (
          <span className="absolute left-1/2 top-0 h-1/2 w-px -translate-x-1/2" style={{ background: RAIL }} />
        )}
        {railBottom && (
          <span className="absolute bottom-0 left-1/2 h-1/2 w-px -translate-x-1/2" style={{ background: RAIL }} />
        )}
        <span
          className="relative z-10 grid place-items-center rounded-sm p-0.5"
          style={{ background: "var(--chat-canvas)" }}
        >
          {icon}
        </span>
      </div>
      <div className="min-w-0 flex-1 self-center">{children}</div>
    </div>
  );
}

/**
 * A client's collapsed reasoning summary — ChatGPT's "Worked for 14s ⌄" row: a
 * muted, clickable-looking line with a trailing caret. Reskinned by the theme
 * tokens like everything else; the verb comes from the theme, the seconds from
 * the turn. Only rendered for clients with a `reasonVerb` (filtered upstream).
 */
function ReasonLine({ verb, seconds }: { verb: string; seconds: number }) {
  return (
    <div className="inline-flex items-center gap-1 text-sm" style={{ color: "var(--chat-muted)" }}>
      <span>{`${verb} ${seconds}s`}</span>
      <CaretDown />
    </div>
  );
}

function Row({
  turn,
  theme,
  timeline,
  railTop,
  railBottom,
}: {
  turn: Turn;
  theme: ChatTheme;
  timeline: boolean;
  railTop: boolean;
  railBottom: boolean;
}) {
  if (turn.role === "user") {
    return (
      <div className={`flex justify-end ${timeline ? "py-1" : ""}`}>
        <p
          className="max-w-[80%] rounded-2xl px-4 py-2 text-sm"
          style={{ background: "var(--chat-bubble)", color: "var(--chat-bubble-text)" }}
        >
          {turn.text}
        </p>
      </div>
    );
  }

  // Reasoning summary. `reason` turns only reach here for a non-timeline client
  // with a `reasonVerb` (the rest are filtered out upstream), so the else-branch
  // never renders in practice — it's here to narrow `reasonVerb` to a string
  // (avoiding a non-null assertion), not as a fallback.
  if (turn.role === "reason") {
    return theme.reasonVerb ? <ReasonLine verb={theme.reasonVerb} seconds={turn.seconds} /> : null;
  }

  // Claude activity timeline: assistant + tool turns are connected rail nodes.
  if (timeline) {
    const isTool = turn.role === "tool";
    const icon =
      isTool && turn.icon ? (
        <img src={turn.icon} alt="" aria-hidden="true" className="size-[18px] shrink-0 rounded-sm" />
      ) : (
        <Mark logo={theme.logo} size={isTool ? 18 : 20} />
      );
    return (
      <TimelineNode icon={icon} railTop={railTop} railBottom={railBottom}>
        {isTool ? (
          <span className="block min-w-0 truncate text-[15px]" style={{ color: "var(--chat-muted)" }}>
            {turn.label ?? turn.name}
          </span>
        ) : (
          <p
            className="text-[15px] leading-relaxed"
            style={{ fontFamily: "var(--chat-say-font)", color: "var(--chat-text)" }}
          >
            {turn.text}
          </p>
        )}
      </TimelineNode>
    );
  }

  if (turn.role === "tool") {
    return <ToolCall turn={turn} style={theme.toolCall} />;
  }

  // assistant: serif/sans copy, no bubble — avatar only when the client shows one
  return (
    <div className="flex gap-2.5">
      {theme.assistantAvatar && (
        <span className="mt-0.5">
          <Mark logo={theme.logo} />
        </span>
      )}
      <p
        className="min-w-0 flex-1 text-[15px] leading-relaxed"
        style={{ fontFamily: "var(--chat-say-font)", color: "var(--chat-text)" }}
      >
        {turn.text}
      </p>
    </div>
  );
}

/** Timeline nodes are the rows with a left icon — assistant lines and tool calls. */
const isTimelineNode = (turn: Turn | undefined) =>
  turn?.role === "assistant" || turn?.role === "tool";

/* ---- shell ----------------------------------------------------------------- */

export default function ChatShell({
  theme,
  conversation,
  bodyHeight = 268,
  className = "",
}: ChatShellProps) {
  // Claude renders tool calls as a connected activity rail (see TimelineNode).
  const timeline = theme.toolCall.variant === "timeline";

  // Drop reasoning summaries up front for any client that won't render them as
  // a rail node — verbless clients (no summary at all) AND timeline clients
  // (a `ReasonLine` isn't a timeline node, so leaving one between two nodes
  // would break Claude's connected rail). Only a non-timeline client with a
  // `reasonVerb` keeps its `reason` turns.
  const turns =
    theme.reasonVerb && !timeline ? conversation : conversation.filter((t) => t.role !== "reason");

  // The theme becomes CSS custom properties on the shell root; every nested
  // element reads them via var(). Reskinning is a one-object swap.
  const vars = {
    "--chat-canvas": theme.canvas,
    "--chat-chrome": theme.chrome,
    "--chat-border": theme.border,
    "--chat-text": theme.text,
    "--chat-muted": theme.muted,
    "--chat-accent": theme.accent,
    "--chat-accent-fg": theme.accentFg,
    "--chat-bubble": theme.bubble,
    "--chat-bubble-text": theme.bubbleText ?? theme.text,
    "--chat-say-font": theme.sayFont,
  } as CSSProperties;

  return (
    <div
      className={`w-full max-w-md overflow-hidden rounded-xl border shadow-2xl shadow-black/40 ${className}`}
      style={{
        ...vars,
        background: "var(--chat-canvas)",
        borderColor: "var(--chat-border)",
        color: "var(--chat-text)",
      }}
    >
      {/* header: model pill */}
      <div
        className="flex items-center gap-2 border-b px-3.5 py-2.5"
        style={{ background: "var(--chat-chrome)", borderColor: "var(--chat-border)" }}
      >
        <span
          className="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium"
          style={{ borderColor: "var(--chat-border)", color: "var(--chat-text)" }}
        >
          <Mark logo={theme.logo} size={14} />
          {theme.name}
          <span style={{ color: "var(--chat-muted)" }}>
            <CaretDown className="size-3" />
          </span>
        </span>
      </div>

      {/* conversation — fixed height, scrolls internally so a swap never reflows.
          In timeline mode rows sit flush (their own py) so the rail is unbroken;
          otherwise turns are spaced. */}
      <div
        className={`overflow-y-auto px-3.5 py-3.5 ${timeline ? "" : "space-y-3.5"}`}
        style={{ height: bodyHeight }}
      >
        {turns.map((turn, i) => (
          <Row
            key={i}
            turn={turn}
            theme={theme}
            timeline={timeline}
            railTop={timeline && isTimelineNode(turn) && isTimelineNode(turns[i - 1])}
            railBottom={timeline && isTimelineNode(turn) && isTimelineNode(turns[i + 1])}
          />
        ))}
      </div>

      {/* composer */}
      <div className="px-3.5 pb-3.5">
        <div
          className="flex items-center gap-2 rounded-2xl border px-3.5 py-2.5"
          style={{ background: "var(--chat-chrome)", borderColor: "var(--chat-border)" }}
        >
          <span className="min-w-0 flex-1 truncate text-sm" style={{ color: "var(--chat-muted)" }}>
            {theme.placeholder}
          </span>
          <span
            className="grid size-7 shrink-0 place-items-center rounded-full"
            style={{ background: "var(--chat-accent)", color: "var(--chat-accent-fg)" }}
          >
            <svg
              className="size-4"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth={2.5}
              strokeLinecap="round"
              strokeLinejoin="round"
              aria-hidden="true"
            >
              <path d="M12 19V5M5 12l7-7 7 7" />
            </svg>
          </span>
        </div>
      </div>
    </div>
  );
}
```

## Related

- Machine-readable index (all patterns, with code): https://eito.me/ui/manifest.json
- Plain-HTML mirror: https://eito.me/ui/all.html
- Interactive gallery: https://eito.me/ui
