# AI chat app

> AI chat app is a production-ready blocks component from BJORK UI, packaged for shadcn registries.

A shadcn/ui block from the [@bjork-ui](https://ui.isaiahbjork.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/bjork-ui/app-ai-chat).

- Page, with a live preview: https://shadcn.exchange/r/bjork-ui/app-ai-chat
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/bjork-ui/app-ai-chat-light-d253e4be679f4ca3.png
- Registry item: https://ui.isaiahbjork.com/app-ai-chat.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.isaiahbjork.com/app-ai-chat.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `clsx`, `framer-motion`, `lucide-react`, `next-themes`, `tailwind-merge`

## Files

### `components/bjork-ui/blocks/app-ai-chat.tsx`

````tsx
"use client";

import {
  Fragment,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type RefObject,
} from "react";
import {
  ArrowDown,
  ArrowUp,
  Check,
  ChevronDown,
  Copy,
  FileText,
  Menu,
  MessageSquarePlus,
  PanelLeftClose,
  PanelLeftOpen,
  Paperclip,
  Pencil,
  RotateCcw,
  Search,
  Square,
  ThumbsDown,
  ThumbsUp,
  Trash2,
  X,
} from "lucide-react";
import {
  BlockButton,
  BlockDrawer,
  IconButton,
  InitialsAvatar,
  Kbd,
  blockRoot,
  focusRing,
  useAnnouncer,
  useAppBlockTheme,
  useBlockReducedMotion,
  useElementWidth,
  type AppBlockTheme,
} from "@/components/bjork-ui/blocks/app-block-kit";
import { cn } from "@/lib/utils";

/* ----------------------------------------------------------------------------------------------------------
 * Types
 * -------------------------------------------------------------------------------------------------------- */

export interface ChatAttachment {
  name: string;
  size: string;
}

export interface ChatMessage {
  id: string;
  role: "user" | "assistant";
  /** Markdown-lite: paragraphs, `- ` lists, `1. ` lists, **bold**, `code` and ``` fenced blocks. */
  content: string;
  attachments?: ChatAttachment[];
  /** Set when a reply was stopped part-way. */
  stopped?: boolean;
  feedback?: "up" | "down";
}

export interface ChatConversation {
  id: string;
  title: string;
  /** ISO date, for grouping in the sidebar. */
  updated: string;
  messages: ChatMessage[];
}

export interface ChatModel {
  id: string;
  name: string;
  hint: string;
}

export interface ChatRequest {
  conversationId: string;
  model: string;
  messages: ChatMessage[];
  signal: AbortSignal;
}

export interface AppAiChatProps {
  conversations?: ChatConversation[];
  models?: ChatModel[];
  defaultModel?: string;
  /** Conversation open on first render. Defaults to the most recent; pass null for a new chat. */
  defaultConversationId?: string | null;
  /**
   * Streams the assistant reply. Yield text chunks; stop when `signal` aborts. Defaults to a local
   * simulator so the block works without a backend.
   */
  respond?: (request: ChatRequest) => AsyncIterable<string>;
  onConversationsChange?: (conversations: ChatConversation[]) => void;
  onFeedback?: (messageId: string, feedback: "up" | "down" | undefined) => void;
  user?: { name: string };
  assistantName?: string;
  suggestions?: { title: string; prompt: string }[];
  /** "Now" for the Today / Previous 7 days grouping. Defaults to the newest conversation. */
  now?: string;
  theme?: AppBlockTheme;
  className?: string;
}

/* ----------------------------------------------------------------------------------------------------------
 * Sample data and the offline responder
 * -------------------------------------------------------------------------------------------------------- */

const SAMPLE_MODELS: ChatModel[] = [
  { id: "swift", name: "Swift", hint: "Fast answers for everyday questions" },
  { id: "deep", name: "Deep", hint: "Slower, thinks through multi-step problems" },
  { id: "code", name: "Code", hint: "Tuned for reading and writing code" },
];

const SAMPLE_SUGGESTIONS = [
  { title: "Explain a concept", prompt: "Explain how a debounce differs from a throttle, with a small example." },
  { title: "Draft a message", prompt: "Draft a short note to my team moving Thursday's review to 2pm." },
  { title: "Review some code", prompt: "What could go wrong with this?\n\nuseEffect(() => { setCount(count + 1) }, [count])" },
  { title: "Plan something", prompt: "Plan a three-step rollout for a new dashboard to 40 users." },
];

const SAMPLE_CONVERSATIONS: ChatConversation[] = [
  {
    id: "c1",
    title: "Debounce vs throttle for search",
    updated: "2026-10-08T15:20:00Z",
    messages: [
      { id: "c1m1", role: "user", content: "Should the dashboard search input be debounced or throttled?" },
      {
        id: "c1m2",
        role: "assistant",
        content:
          "**Debounce it.** You want one query after the person stops typing, not a query every N milliseconds while they type.\n\n- **Debounce** waits for a pause, then fires once. Right for search, autosave and resize-driven layout.\n- **Throttle** fires at most once per interval while events keep coming. Right for scroll position and pointer tracking.\n\nA small version:\n\n```ts\nfunction debounce<T extends unknown[]>(fn: (...args: T) => void, ms = 200) {\n  let timer: ReturnType<typeof setTimeout> | undefined;\n  return (...args: T) => {\n    clearTimeout(timer);\n    timer = setTimeout(() => fn(...args), ms);\n  };\n}\n```\n\nFor a filter over data already in memory, skip both and filter on every keystroke; it's fast enough and feels more direct.",
        feedback: "up",
      },
    ],
  },
  {
    id: "c2",
    title: "Rollout plan for the Northwind pilot",
    updated: "2026-10-07T18:02:00Z",
    messages: [
      { id: "c2m1", role: "user", content: "Help me plan the Northwind pilot. 40 seats, two warehouses, starts Nov 3." },
      {
        id: "c2m2",
        role: "assistant",
        content:
          "Here's a three-phase plan that keeps risk low:\n\n1. **Week 1, one warehouse, 8 people.** Shadow two shifts, fix the top three papercuts.\n2. **Week 2, the rest of that warehouse.** Turn on scheduled CSV exports and per-store permissions.\n3. **Week 3, second warehouse.** Run a 20-minute training and keep a feedback channel open.\n\nDecide upfront what “success” means, for example 80% weekly active seats by the end of week 4.",
      },
    ],
  },
  { id: "c3", title: "Offer note for Kai", updated: "2026-10-06T10:00:00Z", messages: [{ id: "c3m1", role: "user", content: "Help me write two sentences for an offer note." }, { id: "c3m2", role: "assistant", content: "Kai's systems interview stood out for how calmly they reasoned about failure: every design came with a plan for the slow, partial and wrong cases. We think that instinct will make our dashboard sturdier from week one." }] },
  { id: "c4", title: "Colour contrast for chart lines", updated: "2026-10-02T09:00:00Z", messages: [{ id: "c4m1", role: "user", content: "Is 30% ink enough contrast for a comparison line?" }, { id: "c4m2", role: "assistant", content: "For a secondary series, yes, as long as it's not the only way to tell the series apart. Pair it with a dash pattern and a legend, and keep the primary line at full strength." }] },
  { id: "c5", title: "SQL for weekly active seats", updated: "2026-09-24T14:00:00Z", messages: [{ id: "c5m1", role: "user", content: "SQL for weekly active seats?" }, { id: "c5m2", role: "assistant", content: "```sql\nselect date_trunc('week', seen_at) as week,\n       count(distinct user_id) as active_seats\nfrom events\ngroup by 1\norder by 1;\n```" }] },
];

const delay = (ms: number, signal: AbortSignal) =>
  new Promise<void>((resolve) => {
    const t = setTimeout(resolve, ms);
    signal.addEventListener("abort", () => {
      clearTimeout(t);
      resolve();
    });
  });

/** Picks a canned answer for the prompt and streams it word by word. */
async function* simulateResponse({ messages, model, signal }: ChatRequest): AsyncIterable<string> {
  const prompt = messages[messages.length - 1]?.content.toLowerCase() ?? "";
  let answer: string;
  if (prompt.includes("debounce") || prompt.includes("throttle")) {
    answer =
      "A **debounce** waits until events stop for a set time, then runs once. A **throttle** runs at most once per interval while events keep arriving.\n\n- Typing in a search box: debounce, so you query once after the pause.\n- Tracking scroll position: throttle, so you update steadily without flooding.\n\n```ts\nconst onSearch = debounce((q: string) => fetchResults(q), 250);\n```\n\nRule of thumb: if only the final value matters, debounce. If the in-between values matter, throttle.";
  } else if (prompt.includes("draft") || prompt.includes("note") || prompt.includes("message")) {
    answer =
      "Here's a short version you can paste:\n\n> Hi all, I'm moving Thursday's design review to **2pm** so Theo can join from Lisbon. Same room, same doc. Agenda stays the same: chart legend, empty states, and the orders table on phones.\n\nWant it warmer, or shorter for Slack?";
  } else if (prompt.includes("useeffect") || prompt.includes("code") || prompt.includes("wrong")) {
    answer =
      "That effect runs **forever**. It sets `count`, which changes `count`, which is in the dependency list, so the effect runs again.\n\nIf you meant to bump it once on mount:\n\n```tsx\nuseEffect(() => {\n  setCount((c) => c + 1);\n}, []);\n```\n\nIf you meant to derive something from `count`, compute it during render instead of storing it in state.";
  } else if (prompt.includes("plan") || prompt.includes("rollout")) {
    answer =
      "A three-step rollout:\n\n1. **Pilot with 5 people** for a week. Watch them use it; fix the top three papercuts.\n2. **Expand to half** (about 20). Turn on feedback in the app and review it twice a week.\n3. **Everyone.** Send a two-minute walkthrough video and keep office hours open for the first week.\n\nSet the success metric before step one, for example *weekly active seats above 80%*.";
  } else {
    answer = `Good question. Here's how I'd think about it:\n\n- Start from what you need to decide, then work back to the information that would change the decision.\n- Prefer the smallest experiment that gives you a real answer.\n- Write the result down where the team will see it.\n\nTell me more about the context and I can get specific. (Answered by the **${model}** model, simulated locally.)`;
  }
  await delay(model === "deep" ? 900 : 380, signal);
  const tokens = answer.match(/\s*\S+/g) ?? [answer];
  for (const token of tokens) {
    if (signal.aborted) return;
    yield token;
    await delay(model === "swift" ? 14 : 26, signal);
  }
}

/* ----------------------------------------------------------------------------------------------------------
 * Markdown-lite rendering
 * -------------------------------------------------------------------------------------------------------- */

type MdBlock =
  | { kind: "p"; text: string }
  | { kind: "quote"; text: string }
  | { kind: "ul" | "ol"; items: string[] }
  | { kind: "code"; lang: string; code: string; open: boolean };

function parseBlocks(src: string): MdBlock[] {
  const blocks: MdBlock[] = [];
  const lines = src.split("\n");
  let i = 0;
  while (i < lines.length) {
    const line = lines[i];
    const fence = line.match(/^```(\w*)\s*$/);
    if (fence) {
      const body: string[] = [];
      i += 1;
      while (i < lines.length && !/^```\s*$/.test(lines[i])) body.push(lines[i++]);
      const open = i >= lines.length;
      blocks.push({ kind: "code", lang: fence[1] || "text", code: body.join("\n"), open });
      i += 1;
      continue;
    }
    if (!line.trim()) {
      i += 1;
      continue;
    }
    const listKind = /^\s*[-*] /.test(line) ? "ul" : /^\s*\d+\. /.test(line) ? "ol" : null;
    if (listKind) {
      const items: string[] = [];
      const re = listKind === "ul" ? /^\s*[-*] (.*)$/ : /^\s*\d+\. (.*)$/;
      while (i < lines.length && re.test(lines[i])) items.push(lines[i++].replace(re, "$1"));
      blocks.push({ kind: listKind, items });
      continue;
    }
    if (line.startsWith("> ")) {
      const body: string[] = [];
      while (i < lines.length && lines[i].startsWith("> ")) body.push(lines[i++].slice(2));
      blocks.push({ kind: "quote", text: body.join(" ") });
      continue;
    }
    const para: string[] = [];
    while (i < lines.length && lines[i].trim() && !/^```/.test(lines[i]) && !/^\s*([-*]|\d+\.) /.test(lines[i]) && !lines[i].startsWith("> ")) {
      para.push(lines[i++]);
    }
    blocks.push({ kind: "p", text: para.join(" ") });
  }
  return blocks;
}

function Inline({ text: raw }: { text: string }) {
  // Close a bold run that is still streaming, so a half-sent "**word" never shows raw asterisks.
  const text = (raw.split("**").length - 1) % 2 === 1 ? `${raw}**` : raw;
  const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`|\*[^*\s][^*]*\*)/g);
  return (
    <>
      {parts.map((part, i) => {
        if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
          return (
            <strong key={i} className="font-semibold text-[color:var(--bjork-text)]">
              {part.slice(2, -2)}
            </strong>
          );
        }
        if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
          return (
            <code key={i} className="rounded-[5px] border border-[color:var(--bjork-border)] bg-[var(--bjork-field-inset)] px-1 py-px font-mono text-[0.88em]">
              {part.slice(1, -1)}
            </code>
          );
        }
        if (part.startsWith("*") && part.endsWith("*") && part.length > 2) return <em key={i}>{part.slice(1, -1)}</em>;
        return <Fragment key={i}>{part}</Fragment>;
      })}
    </>
  );
}

function CodeBlock({ lang, code }: { lang: string; code: string }) {
  const [copied, setCopied] = useState(false);
  const timer = useRef<number | undefined>(undefined);
  useEffect(() => () => window.clearTimeout(timer.current), []);
  return (
    <div className="my-3 overflow-hidden rounded-[12px] border border-[color:var(--bjork-border)] bg-[var(--bjork-field-inset)]">
      <div className="flex h-8 items-center justify-between border-b border-[color:var(--bjork-border)] pl-3 pr-1">
        <span className="font-mono text-[11px] text-[color:var(--bjork-text-soft)]">{lang}</span>
        <button
          type="button"
          onClick={() => {
            void navigator.clipboard?.writeText(code);
            setCopied(true);
            window.clearTimeout(timer.current);
            timer.current = window.setTimeout(() => setCopied(false), 1600);
          }}
          className={cn(
            "inline-flex h-6 items-center gap-1 rounded-[6px] px-1.5 text-[11.5px] text-[color:var(--bjork-text-muted)] transition-colors hover:bg-[var(--bjork-surface-hover)] hover:text-[color:var(--bjork-text)]",
            focusRing,
          )}
        >
          {copied ? <Check aria-hidden="true" className="size-3.5" /> : <Copy aria-hidden="true" className="size-3.5" />}
          <span aria-live="polite">{copied ? "Copied" : "Copy"}</span>
          <span className="sr-only"> code</span>
        </button>
      </div>
      <pre className="overflow-x-auto px-3.5 py-3 font-mono text-[12.5px] leading-[1.6] text-[color:var(--bjork-text-strong)]">
        <code>{code}</code>
      </pre>
    </div>
  );
}

function Markdown({ content }: { content: string }) {
  const blocks = useMemo(() => parseBlocks(content), [content]);
  return (
    <div className="text-[14px] leading-[1.7] text-[color:var(--bjork-text-strong)]">
      {blocks.map((b, i) => {
        const gap = i > 0 ? "mt-3" : "";
        if (b.kind === "code") return <CodeBlock key={i} lang={b.lang} code={b.code} />;
        if (b.kind === "quote") {
          return (
            <blockquote key={i} className={cn(gap, "border-l-2 border-[color:var(--bjork-accent)] pl-3.5 text-[color:var(--bjork-text-medium)]")}>
              <Inline text={b.text} />
            </blockquote>
          );
        }
        if (b.kind !== "p") {
          const List = b.kind;
          return (
            <List key={i} className={cn(gap, "flex flex-col gap-1.5 pl-5", b.kind === "ul" ? "list-disc marker:text-[color:var(--bjork-text-soft)]" : "list-decimal marker:font-mono marker:text-[12px] marker:text-[color:var(--bjork-text-soft)]")}>
              {b.items.map((item, j) => (
                <li key={j} className="pl-1">
                  <Inline text={item} />
                </li>
              ))}
            </List>
          );
        }
        return (
          <p key={i} className={gap}>
            <Inline text={b.text} />
          </p>
        );
      })}
    </div>
  );
}

/* ----------------------------------------------------------------------------------------------------------
 * Helpers
 * -------------------------------------------------------------------------------------------------------- */

const DAY = 86_400_000;

function groupConversations(list: ChatConversation[], now: number) {
  const groups: { label: string; items: ChatConversation[] }[] = [
    { label: "Today", items: [] },
    { label: "Previous 7 days", items: [] },
    { label: "Older", items: [] },
  ];
  const startOfToday = Math.floor(now / DAY) * DAY;
  for (const c of [...list].sort((a, b) => b.updated.localeCompare(a.updated))) {
    const t = new Date(c.updated).getTime();
    groups[t >= startOfToday ? 0 : t >= startOfToday - 7 * DAY ? 1 : 2].items.push(c);
  }
  return groups.filter((g) => g.items.length > 0);
}

let idCounter = 0;
const newId = (prefix: string) => `${prefix}${Date.now().toString(36)}${(idCounter += 1)}`;

function titleFrom(text: string) {
  const clean = text.replace(/\s+/g, " ").trim();
  return clean.length > 42 ? `${clean.slice(0, 40).trimEnd()}…` : clean || "New chat";
}

const DRAWER_BELOW = 760;

/* ----------------------------------------------------------------------------------------------------------
 * Block
 * -------------------------------------------------------------------------------------------------------- */

export function AppAiChat({
  conversations: initial = SAMPLE_CONVERSATIONS,
  models = SAMPLE_MODELS,
  defaultModel,
  defaultConversationId,
  respond = simulateResponse,
  onConversationsChange,
  onFeedback,
  user = { name: "Rhea Castillo" },
  assistantName = "Assistant",
  suggestions = SAMPLE_SUGGESTIONS,
  now,
  theme = "auto",
  className,
}: AppAiChatProps) {
  const { style } = useAppBlockTheme(theme);
  const reduce = useBlockReducedMotion();
  const rootRef = useRef<HTMLDivElement>(null);
  const width = useElementWidth(rootRef);
  const narrow = width > 0 && width < DRAWER_BELOW;
  const { announce, region } = useAnnouncer();

  const [conversations, setConversations] = useState(initial);
  const [activeId, setActiveId] = useState<string | null>(() =>
    defaultConversationId === undefined
      ? ([...initial].sort((a, b) => b.updated.localeCompare(a.updated))[0]?.id ?? null)
      : defaultConversationId,
  );
  const [model, setModel] = useState(defaultModel ?? models[0]?.id ?? "default");
  const [collapsed, setCollapsed] = useState(false);
  const [drawerOpen, setDrawerOpen] = useState(false);
  const [streamingId, setStreamingId] = useState<string | null>(null);
  const [draft, setDraft] = useState("");
  const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
  const [error, setError] = useState<string | null>(null);
  const abortRef = useRef<AbortController | null>(null);
  // Latest list, for code that runs after an await and must not read a stale closure.
  const latest = useRef(initial);
  useEffect(() => {
    latest.current = conversations;
  }, [conversations]);
  const composerRef = useRef<HTMLTextAreaElement>(null);

  const nowMs = useMemo(
    () => (now ? new Date(now).getTime() : Math.max(0, ...initial.map((c) => new Date(c.updated).getTime()))),
    [now, initial],
  );
  const active = conversations.find((c) => c.id === activeId) ?? null;
  const streaming = streamingId !== null;

  const commit = (next: ChatConversation[]) => {
    setConversations(next);
    onConversationsChange?.(next);
  };

  useEffect(() => () => abortRef.current?.abort(), []);

  /** Streams a reply into `conversationId`, given the history it should answer. */
  const stream = async (conversationId: string, history: ChatMessage[], base: ChatConversation[]) => {
    const controller = new AbortController();
    abortRef.current = controller;
    const replyId = newId("a");
    const reply: ChatMessage = { id: replyId, role: "assistant", content: "" };
    const working = base.map((c) =>
      c.id === conversationId ? { ...c, messages: [...history, reply], updated: new Date(Math.max(Date.now(), nowMs)).toISOString() } : c,
    );
    latest.current = working;
    setConversations(working);
    setStreamingId(replyId);
    setError(null);
    let text = "";
    let frame = 0;
    const flush = () => {
      frame = 0;
      setConversations((list) =>
        list.map((c) =>
          c.id === conversationId ? { ...c, messages: c.messages.map((m) => (m.id === replyId ? { ...m, content: text } : m)) } : c,
        ),
      );
    };
    try {
      for await (const chunk of respond({ conversationId, model, messages: history, signal: controller.signal })) {
        if (controller.signal.aborted) break;
        text += chunk;
        // Batch chunks into one render per frame.
        if (!frame) frame = window.requestAnimationFrame(flush);
      }
    } catch (err) {
      if (!controller.signal.aborted) setError(err instanceof Error ? err.message : "The reply failed. Try again.");
    } finally {
      if (frame) window.cancelAnimationFrame(frame);
      const stopped = controller.signal.aborted;
      const next = latest.current.map((c) =>
        c.id === conversationId
          ? { ...c, messages: c.messages.map((m) => (m.id === replyId ? { ...m, content: text, stopped: stopped || undefined } : m)) }
          : c,
      );
      latest.current = next;
      commit(next);
      setStreamingId(null);
      if (abortRef.current === controller) abortRef.current = null;
      announce(stopped ? "Response stopped" : `${assistantName} replied`);
    }
  };

  const send = (text: string) => {
    const content = text.trim();
    if (!content || streaming) return;
    const message: ChatMessage = { id: newId("u"), role: "user", content, attachments: attachments.length ? attachments : undefined };
    let id = active?.id;
    let base = conversations;
    if (!id) {
      id = newId("c");
      base = [{ id, title: titleFrom(content), updated: new Date(Math.max(Date.now(), nowMs)).toISOString(), messages: [] }, ...conversations];
      setActiveId(id);
    }
    const history = [...(base.find((c) => c.id === id)?.messages ?? []), message];
    setDraft("");
    setAttachments([]);
    void stream(id, history, base);
  };

  const stop = () => abortRef.current?.abort();

  const regenerate = () => {
    if (!active || streaming) return;
    const lastUser = active.messages.map((m) => m.role).lastIndexOf("user");
    if (lastUser < 0) return;
    void stream(active.id, active.messages.slice(0, lastUser + 1), conversations);
  };

  const editLast = () => {
    if (!active || streaming) return;
    const lastUser = active.messages.map((m) => m.role).lastIndexOf("user");
    if (lastUser < 0) return;
    setDraft(active.messages[lastUser].content);
    commit(conversations.map((c) => (c.id === active.id ? { ...c, messages: c.messages.slice(0, lastUser) } : c)));
    window.requestAnimationFrame(() => {
      const el = composerRef.current;
      el?.focus();
      el?.setSelectionRange(el.value.length, el.value.length);
    });
  };

  const newChat = () => {
    if (streaming) stop();
    setActiveId(null);
    setDrawerOpen(false);
    setDraft("");
    setError(null);
    window.requestAnimationFrame(() => composerRef.current?.focus());
  };

  const selectConversation = (id: string) => {
    if (streaming) stop();
    setActiveId(id);
    setDrawerOpen(false);
    setError(null);
  };

  const deleteConversation = (id: string) => {
    const target = conversations.find((c) => c.id === id);
    if (id === activeId) {
      if (streaming) stop();
      setActiveId(null);
    }
    commit(conversations.filter((c) => c.id !== id));
    announce(`Deleted “${target?.title ?? "conversation"}”`);
  };

  const setFeedback = (messageId: string, value: "up" | "down") => {
    if (!active) return;
    const current = active.messages.find((m) => m.id === messageId)?.feedback;
    const next = current === value ? undefined : value;
    commit(
      conversations.map((c) =>
        c.id === active.id ? { ...c, messages: c.messages.map((m) => (m.id === messageId ? { ...m, feedback: next } : m)) } : c,
      ),
    );
    onFeedback?.(messageId, next);
    announce(next ? "Thanks for the feedback" : "Feedback removed");
  };

  const onRootKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    const mod = event.metaKey || event.ctrlKey;
    if (mod && event.shiftKey && event.key.toLowerCase() === "o") {
      event.preventDefault();
      newChat();
    } else if (event.key === "Escape" && streaming) {
      event.preventDefault();
      stop();
    }
  };

  const sidebar = (mode: "rail" | "drawer") => (
    <ChatSidebar
      mode={mode}
      collapsed={mode === "rail" && collapsed}
      conversations={conversations}
      activeId={activeId}
      now={nowMs}
      onSelect={selectConversation}
      onNew={newChat}
      onDelete={deleteConversation}
      onToggleCollapsed={() => setCollapsed((v) => !v)}
      onClose={() => setDrawerOpen(false)}
      user={user}
    />
  );

  return (
    <div ref={rootRef} className={cn(blockRoot, "h-full", className)} style={style} onKeyDown={onRootKeyDown}>
      {!narrow && (
        <aside
          aria-label="Chat history"
          className={cn(
            "relative z-20 flex h-full shrink-0 flex-col border-r border-[color:var(--bjork-border)] bg-[var(--bjork-panel)] transition-[width] duration-[260ms] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none",
            collapsed ? "w-[60px]" : "w-[264px]",
          )}
        >
          {sidebar("rail")}
        </aside>
      )}

      <div className="flex min-w-0 flex-1 flex-col" inert={narrow && drawerOpen}>
        <header className="flex h-14 shrink-0 items-center gap-2 border-b border-[color:var(--bjork-border)] px-3 @3xl:px-4">
          {narrow && (
            <IconButton label="Open chat history" onClick={() => setDrawerOpen(true)} aria-expanded={drawerOpen}>
              <Menu />
            </IconButton>
          )}
          <ModelPicker models={models} value={model} onChange={setModel} disabled={streaming} />
          <h1 className="sr-only">{active ? active.title : "New chat"}</h1>
          <p aria-hidden="true" className="hidden min-w-0 flex-1 truncate text-center text-[13px] text-[color:var(--bjork-text-muted)] @2xl:block">
            {active?.title}
          </p>
          <div className="ml-auto flex items-center gap-1 @2xl:ml-0">
            {narrow && (
              <IconButton label="New chat" onClick={newChat}>
                <MessageSquarePlus />
              </IconButton>
            )}
          </div>
        </header>

        <Thread
          key={active?.id ?? "new"}
          conversation={active}
          streamingId={streamingId}
          reduce={reduce}
          user={user}
          assistantName={assistantName}
          suggestions={suggestions}
          error={error}
          onSuggestion={(prompt) => send(prompt)}
          onRetry={regenerate}
          onRegenerate={regenerate}
          onEditLast={editLast}
          onFeedback={setFeedback}
          onCopied={() => announce("Copied to clipboard")}
        />

        <Composer
          inputRef={composerRef}
          value={draft}
          onChange={setDraft}
          attachments={attachments}
          onAttach={(files) => setAttachments((a) => [...a, ...files])}
          onRemoveAttachment={(name) => setAttachments((a) => a.filter((x) => x.name !== name))}
          streaming={streaming}
          onSend={() => send(draft)}
          onStop={stop}
          canEditLast={Boolean(active?.messages.some((m) => m.role === "user"))}
          onEditLast={editLast}
          assistantName={assistantName}
        />
      </div>

      {narrow && (
        <BlockDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} label="Chat history">
          {sidebar("drawer")}
        </BlockDrawer>
      )}
      {region}
    </div>
  );
}

/* ----------------------------------------------------------------------------------------------------------
 * Sidebar
 * -------------------------------------------------------------------------------------------------------- */

function ChatSidebar({
  mode,
  collapsed,
  conversations,
  activeId,
  now,
  onSelect,
  onNew,
  onDelete,
  onToggleCollapsed,
  onClose,
  user,
}: {
  mode: "rail" | "drawer";
  collapsed: boolean;
  conversations: ChatConversation[];
  activeId: string | null;
  now: number;
  onSelect: (id: string) => void;
  onNew: () => void;
  onDelete: (id: string) => void;
  onToggleCollapsed: () => void;
  onClose: () => void;
  user: { name: string };
}) {
  const [query, setQuery] = useState("");
  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return conversations;
    return conversations.filter(
      (c) => c.title.toLowerCase().includes(q) || c.messages.some((m) => m.content.toLowerCase().includes(q)),
    );
  }, [conversations, query]);
  const groups = groupConversations(filtered, now);

  return (
    <>
      <div className={cn("flex h-14 shrink-0 items-center gap-1.5 border-b border-[color:var(--bjork-border)]", collapsed ? "justify-center px-2" : "px-3")}>
        {collapsed ? (
          <IconButton label="New chat" onClick={onNew}>
            <MessageSquarePlus />
          </IconButton>
        ) : (
          <>
            <BlockButton variant="secondary" size="md" className="flex-1 justify-start" onClick={onNew}>
              <MessageSquarePlus aria-hidden="true" />
              New chat
              <span className="ml-auto flex gap-0.5">
                <Kbd>⌘</Kbd>
                <Kbd>⇧</Kbd>
                <Kbd>O</Kbd>
              </span>
            </BlockButton>
            {mode === "drawer" && (
              <IconButton label="Close chat history" size="sm" onClick={onClose}>
                <X />
              </IconButton>
            )}
          </>
        )}
      </div>

      {!collapsed && (
        <div className="px-3 pt-3">
          <label className="relative flex items-center">
            <span className="sr-only">Search chats</span>
            <Search aria-hidden="true" className="pointer-events-none absolute left-2.5 size-[14px] text-[color:var(--bjork-text-soft)]" />
            <input
              type="search"
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === "Escape" && query) {
                  e.stopPropagation();
                  setQuery("");
                }
              }}
              placeholder="Search chats"
              className="h-8 w-full rounded-[9px] border border-[color:var(--bjork-border)] bg-[var(--bjork-field)] pl-8 pr-2 text-[12.5px] text-[color:var(--bjork-text)] outline-none transition-[border-color,box-shadow] placeholder:text-[color:var(--bjork-text-soft)] focus-visible:border-[color:var(--bjork-accent)] focus-visible:ring-[3px] focus-visible:ring-[color:var(--bjork-accent-soft)] [&::-webkit-search-cancel-button]:hidden"
            />
          </label>
        </div>
      )}

      <nav aria-label="Conversations" className={cn("hide-scrollbar flex-1 overflow-y-auto py-3", collapsed ? "hidden" : "px-2")}>
        {groups.length === 0 && (
          <p className="px-3 py-6 text-center text-[12.5px] text-[color:var(--bjork-text-muted)]">
            {query ? `No chats match “${query}”.` : "No chats yet."}
          </p>
        )}
        {groups.map((g, gi) => (
          <div key={g.label} className={cn(gi > 0 && "mt-4")}>
            <h2 className="mb-1 px-2.5 font-mono text-[10.5px] uppercase tracking-[0.1em] text-[color:var(--bjork-text-soft)]">{g.label}</h2>
            <ul className="flex flex-col gap-px">
              {g.items.map((c) => {
                const isActive = c.id === activeId;
                return (
                  <li key={c.id} className="group relative">
                    <a
                      href={`#${c.id}`}
                      aria-current={isActive ? "page" : undefined}
                      onClick={(e) => {
                        e.preventDefault();
                        onSelect(c.id);
                      }}
                      className={cn(
                        "flex h-8 items-center rounded-[8px] pl-2.5 pr-8 text-[13px] transition-colors duration-150",
                        isActive
                          ? "bg-[var(--bjork-surface)] font-medium text-[color:var(--bjork-text)] shadow-[var(--bjork-shadow-soft),0_0_0_1px_var(--bjork-border)]"
                          : "text-[color:var(--bjork-text-medium)] hover:bg-[var(--bjork-surface-hover)] hover:text-[color:var(--bjork-text)]",
                        focusRing,
                      )}
                    >
                      <span className="truncate">{c.title}</span>
                    </a>
                    <button
                      type="button"
                      onClick={() => onDelete(c.id)}
                      aria-label={`Delete “${c.title}”`}
                      title="Delete chat"
                      className={cn(
                        "absolute right-1 top-1 grid size-6 place-items-center rounded-[6px] text-[color:var(--bjork-text-soft)] opacity-0 transition-[opacity,color] hover:text-[color:var(--blk-error)] focus-visible:opacity-100 group-hover:opacity-100",
                        isActive && "opacity-100",
                        focusRing,
                      )}
                    >
                      <Trash2 aria-hidden="true" className="size-3.5" />
                    </button>
                  </li>
                );
              })}
            </ul>
          </div>
        ))}
      </nav>
      {collapsed && <div className="flex-1" />}

      <div className={cn("flex shrink-0 items-center gap-1 border-t border-[color:var(--bjork-border)] p-2.5", collapsed && "flex-col px-2")}>
        <span className={cn("flex min-w-0 items-center gap-2.5 p-1", !collapsed && "flex-1")} title={collapsed ? user.name : undefined}>
          <InitialsAvatar name={user.name} size={28} />
          {!collapsed && <span className="truncate text-[12.5px] font-medium">{user.name}</span>}
          {collapsed && <span className="sr-only">{user.name}</span>}
        </span>
        {mode === "rail" && (
          <IconButton label={collapsed ? "Expand sidebar" : "Collapse sidebar"} size="sm" onClick={onToggleCollapsed} aria-expanded={!collapsed}>
            {collapsed ? <PanelLeftOpen /> : <PanelLeftClose />}
          </IconButton>
        )}
      </div>
    </>
  );
}

/* ----------------------------------------------------------------------------------------------------------
 * Model picker: a menu button with a radio list.
 * -------------------------------------------------------------------------------------------------------- */

function ModelPicker({
  models,
  value,
  onChange,
  disabled,
}: {
  models: ChatModel[];
  value: string;
  onChange: (id: string) => void;
  disabled?: boolean;
}) {
  const [open, setOpen] = useState(false);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const wrapRef = useRef<HTMLDivElement>(null);
  const menuId = useId();
  const current = models.find((m) => m.id === value) ?? models[0];

  useEffect(() => {
    if (!open) return;
    const onDown = (e: PointerEvent) => {
      if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener("pointerdown", onDown);
    const index = Math.max(0, models.findIndex((m) => m.id === value));
    itemRefs.current[index]?.focus();
    return () => document.removeEventListener("pointerdown", onDown);
  }, [open, models, value]);

  const close = (refocus = true) => {
    setOpen(false);
    if (refocus) buttonRef.current?.focus();
  };

  const onMenuKey = (event: ReactKeyboardEvent, i: number) => {
    let next = -1;
    if (event.key === "ArrowDown") next = (i + 1) % models.length;
    else if (event.key === "ArrowUp") next = (i - 1 + models.length) % models.length;
    else if (event.key === "Home") next = 0;
    else if (event.key === "End") next = models.length - 1;
    else if (event.key === "Escape") {
      event.preventDefault();
      event.stopPropagation();
      close();
      return;
    } else if (event.key === "Tab") {
      setOpen(false);
      return;
    }
    if (next < 0) return;
    event.preventDefault();
    itemRefs.current[next]?.focus();
  };

  return (
    <div ref={wrapRef} className="relative">
      <button
        ref={buttonRef}
        type="button"
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={open ? menuId : undefined}
        disabled={disabled}
        onClick={() => setOpen((v) => !v)}
        onKeyDown={(e) => {
          if (e.key === "ArrowDown" && !open) {
            e.preventDefault();
            setOpen(true);
          }
        }}
        className={cn(
          "inline-flex h-8 items-center gap-1.5 rounded-[9px] px-2.5 text-[14px] font-semibold tracking-[-0.01em] transition-colors hover:bg-[var(--bjork-surface-hover)] disabled:opacity-60",
          focusRing,
        )}
      >
        <span className="sr-only">Model: </span>
        {current?.name}
        <ChevronDown aria-hidden="true" className={cn("size-3.5 text-[color:var(--bjork-text-muted)] transition-transform duration-150", open && "rotate-180")} />
      </button>
      {open && (
        <div
          id={menuId}
          role="menu"
          aria-label="Model"
          className="absolute left-0 top-[calc(100%+6px)] z-40 w-[260px] rounded-[14px] border border-[color:var(--bjork-border)] bg-[var(--bjork-menu)] p-1.5 shadow-[var(--bjork-shadow-menu)] backdrop-blur-xl"
        >
          {models.map((m, i) => {
            const checked = m.id === value;
            return (
              <button
                key={m.id}
                ref={(el) => {
                  itemRefs.current[i] = el;
                }}
                type="button"
                role="menuitemradio"
                aria-checked={checked}
                tabIndex={-1}
                onKeyDown={(e) => onMenuKey(e, i)}
                onClick={() => {
                  onChange(m.id);
                  close();
                }}
                className="flex w-full items-start gap-2.5 rounded-[9px] px-2.5 py-2 text-left outline-none transition-colors hover:bg-[var(--bjork-surface-hover)] focus-visible:bg-[var(--bjork-surface-active)]"
              >
                <span className="min-w-0 flex-1">
                  <span className="block text-[13px] font-medium">{m.name}</span>
                  <span className="block text-[12px] leading-[1.4] text-[color:var(--bjork-text-muted)]">{m.hint}</span>
                </span>
                <Check aria-hidden="true" className={cn("mt-0.5 size-4 shrink-0 text-[color:var(--blk-accent-ink)]", !checked && "invisible")} />
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
}

/* ----------------------------------------------------------------------------------------------------------
 * Thread
 * -------------------------------------------------------------------------------------------------------- */

function Thread({
  conversation,
  streamingId,
  reduce,
  user,
  assistantName,
  suggestions,
  error,
  onSuggestion,
  onRetry,
  onRegenerate,
  onEditLast,
  onFeedback,
  onCopied,
}: {
  conversation: ChatConversation | null;
  streamingId: string | null;
  reduce: boolean;
  user: { name: string };
  assistantName: string;
  suggestions: { title: string; prompt: string }[];
  error: string | null;
  onSuggestion: (prompt: string) => void;
  onRetry: () => void;
  onRegenerate: () => void;
  onEditLast: () => void;
  onFeedback: (id: string, value: "up" | "down") => void;
  onCopied: () => void;
}) {
  const scrollRef = useRef<HTMLElement>(null);
  const pinned = useRef(true);
  const [atBottom, setAtBottom] = useState(true);
  const messages = conversation?.messages ?? [];
  const lastContent = messages[messages.length - 1]?.content;

  // Follow the stream while the reader is at the bottom; stop following once they scroll up.
  useLayoutEffect(() => {
    const el = scrollRef.current;
    if (!el || !pinned.current) return;
    el.scrollTop = el.scrollHeight;
  }, [messages.length, lastContent, streamingId]);

  const onScroll = () => {
    const el = scrollRef.current;
    if (!el) return;
    const near = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
    pinned.current = near;
    setAtBottom(near);
  };

  const jump = () => {
    const el = scrollRef.current;
    if (!el) return;
    pinned.current = true;
    el.scrollTo({ top: el.scrollHeight, behavior: reduce ? "auto" : "smooth" });
  };

  if (!conversation || messages.length === 0) {
    return (
      <div className="@container/thread flex min-h-0 flex-1 flex-col items-center justify-center overflow-y-auto px-4 py-8">
        <div className="w-full max-w-[640px]">
          <AssistantMark className="mx-auto size-11" />
          <h2 className="mt-4 text-center font-bjork-display text-[26px] font-bold italic leading-tight tracking-[-0.02em] @xl/thread:text-[30px]">
            What are we working on?
          </h2>
          <p className="mt-2 text-center text-[13.5px] text-[color:var(--bjork-text-muted)]">
            Ask anything, or start from one of these.
          </p>
          <ul className="mt-7 grid grid-cols-1 gap-2 @xl/thread:grid-cols-2">
            {suggestions.map((s) => (
              <li key={s.title}>
                <button
                  type="button"
                  onClick={() => onSuggestion(s.prompt)}
                  className={cn(
                    "group flex h-full w-full flex-col items-start gap-1 rounded-[14px] border border-[color:var(--bjork-border)] bg-[var(--bjork-surface)] p-3.5 text-left shadow-[var(--bjork-shadow-soft)] transition-[border-color,background-color,transform] duration-150 hover:border-[color:var(--bjork-border-strong)] hover:bg-[var(--bjork-surface-hover)] active:scale-[0.99] motion-reduce:active:scale-100",
                    focusRing,
                  )}
                >
                  <span className="text-[13px] font-medium">{s.title}</span>
                  <span className="line-clamp-2 text-[12.5px] leading-[1.45] text-[color:var(--bjork-text-muted)]">{s.prompt.split("\n")[0]}</span>
                </button>
              </li>
            ))}
          </ul>
        </div>
      </div>
    );
  }

  const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
  const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user");

  return (
    <div className="relative min-h-0 flex-1">
      <section
        ref={scrollRef}
        onScroll={onScroll}
        className="@container/thread h-full overflow-y-auto overscroll-contain"
        aria-label="Conversation"
        aria-busy={streamingId !== null}
      >
        <ol className="mx-auto flex w-full max-w-[760px] flex-col gap-7 px-4 pb-8 pt-6 @2xl/thread:px-6">
          {messages.map((m, i) =>
            m.role === "user" ? (
              <li key={m.id} className="group flex flex-col items-end gap-1.5">
                <h3 className="sr-only">{user.name} said</h3>
                {m.attachments && m.attachments.length > 0 && (
                  <ul className="flex flex-wrap justify-end gap-1.5" aria-label="Attachments">
                    {m.attachments.map((a) => (
                      <li key={a.name}>
                        <AttachmentChip file={a} />
                      </li>
                    ))}
                  </ul>
                )}
                <div className="max-w-[85%] whitespace-pre-wrap rounded-[18px] rounded-br-[6px] border border-[color:var(--bjork-border)] bg-[var(--bjork-surface-active)] px-4 py-2.5 text-[14px] leading-[1.6]">
                  {m.content}
                </div>
                {i === lastUserIndex && streamingId === null && (
                  <button
                    type="button"
                    onClick={onEditLast}
                    className={cn(
                      "inline-flex h-7 items-center gap-1 rounded-[7px] px-2 text-[12px] text-[color:var(--bjork-text-muted)] opacity-0 transition-opacity hover:bg-[var(--bjork-surface-hover)] hover:text-[color:var(--bjork-text)] focus-visible:opacity-100 group-hover:opacity-100",
                      focusRing,
                    )}
                  >
                    <Pencil aria-hidden="true" className="size-3.5" />
                    Edit
                  </button>
                )}
              </li>
            ) : (
              <li key={m.id} className="flex gap-3">
                <AssistantMark className="mt-0.5 size-7 shrink-0" pulsing={m.id === streamingId && !reduce} />
                <div className="min-w-0 flex-1">
                  <h3 className="sr-only">{assistantName} said</h3>
                  {m.id === streamingId && !m.content ? (
                    <TypingDots reduce={reduce} />
                  ) : (
                    <>
                      <Markdown content={m.content} />
                      {m.id === streamingId && (
                        <span aria-hidden="true" className="ml-0.5 inline-block h-[1.05em] w-[7px] translate-y-[3px] animate-pulse rounded-[2px] bg-[var(--bjork-accent)] motion-reduce:animate-none" />
                      )}
                    </>
                  )}
                  {m.stopped && (
                    <p className="mt-2 inline-flex items-center gap-1.5 rounded-[7px] bg-[var(--bjork-surface-active)] px-2 py-0.5 text-[11.5px] text-[color:var(--bjork-text-muted)]">
                      <Square aria-hidden="true" className="size-2.5 fill-current" />
                      Stopped
                    </p>
                  )}
                  {m.id !== streamingId && m.content && (
                    <MessageActions
                      message={m}
                      canRegenerate={m.id === lastAssistant?.id && streamingId === null}
                      onRegenerate={onRegenerate}
                      onFeedback={onFeedback}
                      onCopied={onCopied}
                    />
                  )}
                </div>
              </li>
            ),
          )}
          {error && (
            <li role="alert" className="flex items-center justify-between gap-3 rounded-[12px] border border-[color:color-mix(in_oklab,var(--blk-error)_35%,var(--bjork-border))] bg-[var(--blk-error-soft)] px-3.5 py-2.5 text-[13px] text-[color:var(--blk-error)]">
              {error}
              <BlockButton size="sm" variant="secondary" onClick={onRetry}>
                <RotateCcw aria-hidden="true" />
                Retry
              </BlockButton>
            </li>
          )}
        </ol>
      </section>
      <div
        className={cn(
          "pointer-events-none absolute inset-x-0 bottom-3 flex justify-center transition-[opacity,transform] duration-200 motion-reduce:transition-opacity",
          atBottom ? "translate-y-2 opacity-0" : "translate-y-0 opacity-100",
        )}
      >
        <button
          type="button"
          onClick={jump}
          tabIndex={atBottom ? -1 : 0}
          aria-hidden={atBottom}
          aria-label="Scroll to latest"
          className={cn(
            "grid size-8 place-items-center rounded-full border border-[color:var(--bjork-border-strong)] bg-[var(--bjork-menu)] text-[color:var(--bjork-text-medium)] shadow-[var(--bjork-shadow-menu)] backdrop-blur-xl hover:text-[color:var(--bjork-text)]",
            !atBottom && "pointer-events-auto",
            focusRing,
          )}
        >
          <ArrowDown aria-hidden="true" className="size-4" />
        </button>
      </div>
    </div>
  );
}

function MessageActions({
  message,
  canRegenerate,
  onRegenerate,
  onFeedback,
  onCopied,
}: {
  message: ChatMessage;
  canRegenerate: boolean;
  onRegenerate: () => void;
  onFeedback: (id: string, value: "up" | "down") => void;
  onCopied: () => void;
}) {
  const [copied, setCopied] = useState(false);
  const timer = useRef<number | undefined>(undefined);
  useEffect(() => () => window.clearTimeout(timer.current), []);
  return (
    <div role="group" aria-label="Message actions" className="-ml-1.5 mt-2 flex items-center gap-0.5">
      <IconButton
        label={copied ? "Copied" : "Copy response"}
        size="sm"
        onClick={() => {
          void navigator.clipboard?.writeText(message.content);
          setCopied(true);
          onCopied();
          window.clearTimeout(timer.current);
          timer.current = window.setTimeout(() => setCopied(false), 1600);
        }}
      >
        {copied ? <Check /> : <Copy />}
      </IconButton>
      <IconButton label="Good response" size="sm" pressed={message.feedback === "up"} onClick={() => onFeedback(message.id, "up")}>
        <ThumbsUp fill={message.feedback === "up" ? "currentColor" : "none"} fillOpacity={0.18} />
      </IconButton>
      <IconButton label="Bad response" size="sm" pressed={message.feedback === "down"} onClick={() => onFeedback(message.id, "down")}>
        <ThumbsDown fill={message.feedback === "down" ? "currentColor" : "none"} fillOpacity={0.18} />
      </IconButton>
      {canRegenerate && (
        <IconButton label="Regenerate response" size="sm" onClick={onRegenerate}>
          <RotateCcw />
        </IconButton>
      )}
    </div>
  );
}

function AssistantMark({ className, pulsing }: { className?: string; pulsing?: boolean }) {
  return (
    <span
      aria-hidden="true"
      className={cn(
        "relative grid place-items-center rounded-[9px] bg-[var(--blk-accent-fill)] shadow-[inset_0_1px_0_rgba(255,255,255,0.28),0_1px_2px_rgba(0,0,0,0.2)]",
        className,
      )}
    >
      <svg viewBox="0 0 20 20" className="size-[62%]" fill="none">
        <path
          d="M10 2.5c.5 3.6 1.9 5 5.5 5.5-3.6.5-5 1.9-5.5 5.5-.5-3.6-1.9-5-5.5-5.5 3.6-.5 5-1.9 5.5-5.5Z"
          fill="var(--blk-accent-fill-ink)"
          className={cn(pulsing && "origin-center animate-[spin_2.4s_linear_infinite]")}
          style={{ transformBox: "fill-box" }}
        />
        <circle cx="15.5" cy="15" r="1.5" fill="var(--blk-accent-fill-ink)" opacity="0.7" />
      </svg>
    </span>
  );
}

function TypingDots({ reduce }: { reduce: boolean }) {
  return (
    <span className="inline-flex h-6 items-center gap-1" aria-label="Thinking" role="img">
      {[0, 1, 2].map((i) => (
        <span
          key={i}
          className={cn("size-1.5 rounded-full bg-[var(--bjork-text-muted)]", !reduce && "animate-bounce")}
          style={{ animationDelay: `${i * 140}ms`, animationDuration: "900ms" }}
        />
      ))}
    </span>
  );
}

function AttachmentChip({ file, onRemove }: { file: ChatAttachment; onRemove?: () => void }) {
  return (
    <span className="inline-flex max-w-[220px] items-center gap-2 rounded-[10px] border border-[color:var(--bjork-border)] bg-[var(--bjork-surface)] py-1.5 pl-2 pr-2.5 text-[12px] shadow-[var(--bjork-shadow-soft)]">
      <span aria-hidden="true" className="grid size-6 shrink-0 place-items-center rounded-[6px] bg-[var(--bjork-accent-soft)] text-[color:var(--blk-accent-ink)]">
        <FileText className="size-3.5" />
      </span>
      <span className="min-w-0">
        <span className="block truncate font-medium">{file.name}</span>
        <span className="block font-mono text-[11px] text-[color:var(--bjork-text-soft)]">{file.size}</span>
      </span>
      {onRemove && (
        <button
          type="button"
          onClick={onRemove}
          aria-label={`Remove ${file.name}`}
          className={cn("-mr-1 grid size-5 shrink-0 place-items-center rounded-[5px] text-[color:var(--bjork-text-soft)] hover:text-[color:var(--bjork-text)]", focusRing)}
        >
          <X aria-hidden="true" className="size-3" />
        </button>
      )}
    </span>
  );
}

/* ----------------------------------------------------------------------------------------------------------
 * Composer
 * -------------------------------------------------------------------------------------------------------- */

function formatSize(bytes: number) {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}

function Composer({
  inputRef,
  value,
  onChange,
  attachments,
  onAttach,
  onRemoveAttachment,
  streaming,
  onSend,
  onStop,
  canEditLast,
  onEditLast,
  assistantName,
}: {
  inputRef: RefObject<HTMLTextAreaElement | null>;
  value: string;
  onChange: (value: string) => void;
  attachments: ChatAttachment[];
  onAttach: (files: ChatAttachment[]) => void;
  onRemoveAttachment: (name: string) => void;
  streaming: boolean;
  onSend: () => void;
  onStop: () => void;
  canEditLast: boolean;
  onEditLast: () => void;
  assistantName: string;
}) {
  const fileRef = useRef<HTMLInputElement>(null);
  const hintId = useId();
  const [dragging, setDragging] = useState(false);

  // Grow with the text up to a cap, then scroll.
  useLayoutEffect(() => {
    const el = inputRef.current;
    if (!el) return;
    el.style.height = "auto";
    el.style.height = `${Math.min(el.scrollHeight, 220)}px`;
  }, [value, inputRef]);

  const addFiles = (list: FileList | null) => {
    if (!list || list.length === 0) return;
    onAttach(Array.from(list).map((f) => ({ name: f.name, size: formatSize(f.size) })));
  };

  return (
    <div className="shrink-0 px-3 pb-3 @2xl:px-6 @2xl:pb-5">
      <form
        onSubmit={(e) => {
          e.preventDefault();
          onSend();
        }}
        onDragOver={(e) => {
          if (e.dataTransfer.types.includes("Files")) {
            e.preventDefault();
            setDragging(true);
          }
        }}
        onDragLeave={() => setDragging(false)}
        onDrop={(e) => {
          e.preventDefault();
          setDragging(false);
          addFiles(e.dataTransfer.files);
        }}
        className={cn(
          "mx-auto w-full max-w-[760px] rounded-[20px] border bg-[var(--bjork-surface)] p-2 shadow-[var(--bjork-shadow-surface)] transition-[border-color,box-shadow] duration-150 focus-within:border-[color:var(--bjork-border-strong)]",
          dragging ? "border-dashed border-[color:var(--bjork-accent)] ring-[3px] ring-[color:var(--bjork-accent-soft)]" : "border-[color:var(--bjork-border)]",
        )}
      >
        {attachments.length > 0 && (
          <ul className="flex flex-wrap gap-1.5 px-1 pb-2 pt-1" aria-label="Attached files">
            {attachments.map((a) => (
              <li key={a.name}>
                <AttachmentChip file={a} onRemove={() => onRemoveAttachment(a.name)} />
              </li>
            ))}
          </ul>
        )}
        <label className="sr-only" htmlFor={`${hintId}-input`}>
          Message {assistantName}
        </label>
        <textarea
          ref={inputRef}
          id={`${hintId}-input`}
          rows={1}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
              e.preventDefault();
              onSend();
            } else if (e.key === "ArrowUp" && !value && canEditLast && !streaming) {
              e.preventDefault();
              onEditLast();
            }
          }}
          onPaste={(e) => {
            if (e.clipboardData.files.length > 0) {
              e.preventDefault();
              addFiles(e.clipboardData.files);
            }
          }}
          aria-describedby={hintId}
          placeholder={`Message ${assistantName}`}
          className="block max-h-[220px] min-h-[44px] w-full resize-none bg-transparent px-2.5 py-2.5 text-[14.5px] leading-[1.55] text-[color:var(--bjork-text)] outline-none placeholder:text-[color:var(--bjork-text-soft)]"
        />
        <div className="flex items-center gap-1.5">
          <IconButton label="Attach files" size="sm" onClick={() => fileRef.current?.click()}>
            <Paperclip />
          </IconButton>
          <input
            ref={fileRef}
            type="file"
            multiple
            className="sr-only"
            tabIndex={-1}
            aria-hidden="true"
            onChange={(e) => {
              addFiles(e.target.files);
              e.target.value = "";
            }}
          />
          <p id={hintId} className="hidden min-w-0 flex-1 truncate text-[11.5px] text-[color:var(--bjork-text-soft)] @xl:block">
            <Kbd>↵</Kbd> send · <Kbd>⇧</Kbd> <Kbd>↵</Kbd> new line · <Kbd>↑</Kbd> edit last
          </p>
          <span className="flex-1 @xl:hidden" />
          {streaming ? (
            <button
              type="button"
              onClick={onStop}
              aria-label="Stop generating (Esc)"
              title="Stop generating (Esc)"
              className={cn(
                "grid size-9 place-items-center rounded-full bg-[var(--bjork-text)] text-[color:var(--bjork-bg)] transition-transform active:scale-95 motion-reduce:active:scale-100",
                focusRing,
              )}
            >
              <Square aria-hidden="true" className="size-3 fill-current" />
            </button>
          ) : (
            <button
              type="submit"
              disabled={!value.trim()}
              aria-label="Send message"
              title="Send (Enter)"
              className={cn(
                "grid size-9 place-items-center rounded-full bg-[var(--blk-accent-fill)] text-[color:var(--blk-accent-fill-ink)] shadow-[inset_0_1px_0_rgba(255,255,255,0.22)] transition-[transform,opacity,filter] hover:brightness-110 active:scale-95 disabled:cursor-not-allowed disabled:opacity-35 motion-reduce:active:scale-100",
                focusRing,
              )}
            >
              <ArrowUp aria-hidden="true" className="size-[18px]" strokeWidth={2.2} />
            </button>
          )}
        </div>
      </form>
      <p className="mx-auto mt-2 max-w-[760px] text-center text-[11px] text-[color:var(--bjork-text-soft)]">
        Answers can be wrong. Check anything important.
      </p>
    </div>
  );
}
````

### `components/bjork-ui/blocks/app-block-kit.tsx`

```tsx
"use client";

import {
  forwardRef,
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  useSyncExternalStore,
  type ButtonHTMLAttributes,
  type CSSProperties,
  type InputHTMLAttributes,
  type ReactNode,
  type RefObject,
} from "react";
import { useReducedMotion } from "framer-motion";
import { useBjorkTone } from "@/components/bjork-ui/_core/tone";
import type { BjorkTone } from "@/components/bjork-ui/_core/palette";
import { cn } from "@/lib/utils";

// Shared plumbing for the application blocks (app-*). Every block is a full layout that sizes itself to its
// container, not the viewport, so it behaves the same pasted into a page, a dialog or a docs preview.

export type AppBlockTheme = "light" | "dark" | "auto";

// The library tokens, so a forced theme works even when the page itself is in the other mode.
const TOKENS: Record<BjorkTone, Record<string, string>> = {
  light: {
    "--bjork-bg": "#f7f3ea",
    "--bjork-surface": "#fffcf6",
    "--bjork-surface-muted": "rgba(250, 246, 237, 0.94)",
    "--bjork-surface-hover": "#f5efe3",
    "--bjork-surface-active": "#efe7d8",
    "--bjork-panel": "#f8f2e7",
    "--bjork-menu": "rgba(255, 252, 246, 0.98)",
    "--bjork-field": "rgba(255, 252, 246, 0.96)",
    "--bjork-field-muted": "rgba(248, 242, 231, 0.82)",
    "--bjork-field-inset": "rgba(239, 231, 216, 0.72)",
    "--bjork-error-bg": "#fff0e8",
    "--bjork-text": "#171717",
    "--bjork-text-strong": "rgba(23, 23, 23, 0.9)",
    "--bjork-text-medium": "rgba(23, 23, 23, 0.72)",
    "--bjork-text-muted": "rgba(23, 23, 23, 0.56)",
    "--bjork-text-soft": "rgba(23, 23, 23, 0.46)",
    "--bjork-text-faint": "rgba(23, 23, 23, 0.22)",
    "--bjork-inverted-text": "#fff8f0",
    "--bjork-border": "#eee6db",
    "--bjork-border-muted": "#f5ede2",
    "--bjork-border-strong": "#e1d7c8",
    "--bjork-thumb": "#a49b8e",
    "--bjork-track": "#ded6ca",
    "--bjork-accent": "#ec7d43",
    "--bjork-accent-hover": "#f0935f",
    "--bjork-accent-soft": "rgba(236, 125, 67, 0.1)",
    "--bjork-accent-muted": "rgba(236, 125, 67, 0.2)",
    "--bjork-accent-foreground": "#3f2112",
    "--bjork-shadow-surface":
      "inset 0 7px 14px rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.92), inset 1px 0 0 rgba(88, 72, 49, 0.026), inset -1px 0 0 rgba(255, 255, 255, 0.68), 0 14px 22px -9px rgba(66, 52, 33, 0.11)",
    "--bjork-shadow-soft": "inset 0 1px 0 rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.86)",
    "--bjork-shadow-panel":
      "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.64), 0 18px 38px -30px rgba(66, 52, 33, 0.2)",
    "--bjork-shadow-menu":
      "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 0 12px 24px rgba(88, 72, 49, 0.022), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.62), 0 22px 44px -26px rgba(66, 52, 33, 0.22)",
    "--bjork-shadow-inset": "inset 0 1px 10px rgba(88, 72, 49, 0.12), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.72)",
    "--bjork-ring-offset": "#f7f3ea",
  },
  dark: {
    "--bjork-bg": "#111111",
    "--bjork-surface": "#121212",
    "--bjork-surface-muted": "rgba(22, 22, 22, 0.92)",
    "--bjork-surface-hover": "#181818",
    "--bjork-surface-active": "#202020",
    "--bjork-panel": "#0d0d0d",
    "--bjork-menu": "rgba(18, 18, 18, 0.98)",
    "--bjork-field": "rgba(18, 18, 18, 0.95)",
    "--bjork-field-muted": "rgba(18, 18, 18, 0.55)",
    "--bjork-field-inset": "rgba(9, 9, 9, 0.75)",
    "--bjork-error-bg": "#130d0a",
    "--bjork-text": "#ededed",
    "--bjork-text-strong": "rgba(237, 237, 237, 0.9)",
    "--bjork-text-medium": "rgba(237, 237, 237, 0.72)",
    "--bjork-text-muted": "rgba(237, 237, 237, 0.6)",
    "--bjork-text-soft": "rgba(237, 237, 237, 0.4)",
    "--bjork-text-faint": "rgba(237, 237, 237, 0.22)",
    "--bjork-inverted-text": "#080808",
    "--bjork-border": "#232323",
    "--bjork-border-muted": "#1c1c1c",
    "--bjork-border-strong": "#343434",
    "--bjork-thumb": "#626262",
    "--bjork-track": "#090909",
    "--bjork-accent": "#ec5c13",
    "--bjork-accent-hover": "#f07832",
    "--bjork-accent-soft": "rgba(236, 92, 19, 0.12)",
    "--bjork-accent-muted": "rgba(236, 92, 19, 0.24)",
    "--bjork-accent-foreground": "#fff2ea",
    "--bjork-shadow-surface":
      "inset 0 7px 14px rgba(255, 255, 255, 0.03), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.06), 0 14px 20px -6px rgba(0, 0, 0, 0.45)",
    "--bjork-shadow-soft": "inset 0 1px 0 rgba(255, 255, 255, 0.045)",
    "--bjork-shadow-panel": "inset 0 1px 0 rgba(255, 255, 255, 0.035), 0 18px 36px -28px rgba(0, 0, 0, 0.9)",
    "--bjork-shadow-menu":
      "inset 0 1px 0 rgba(255, 255, 255, 0.055), inset 0 12px 24px rgba(255, 255, 255, 0.018), inset 0 -18px 26px rgba(0, 0, 0, 0.24), 0 22px 42px -22px rgba(0, 0, 0, 0.9)",
    "--bjork-shadow-inset": "inset 0 1px 10px rgba(0, 0, 0, 0.45)",
    "--bjork-ring-offset": "#080808",
  },
};

// Block-only tokens the global sheet does not carry: status inks, a readable accent ink and the scrim.
const EXTRAS: Record<BjorkTone, Record<string, string>> = {
  light: {
    "--blk-accent-ink": "#b4531f",
    "--blk-accent-fill": "#e0692c",
    "--blk-accent-fill-ink": "#fffaf5",
    "--blk-success": "#1f8a55",
    "--blk-success-soft": "rgba(31, 138, 85, 0.1)",
    "--blk-warning": "#9a6200",
    "--blk-warning-soft": "rgba(168, 107, 0, 0.1)",
    "--blk-error": "#c8361f",
    "--blk-error-soft": "rgba(200, 54, 31, 0.08)",
    "--blk-scrim": "rgba(40, 32, 22, 0.28)",
    "--blk-chart-ghost": "rgba(23, 23, 23, 0.28)",
  },
  dark: {
    "--blk-accent-ink": "#f07832",
    "--blk-accent-fill": "#c94f12",
    "--blk-accent-fill-ink": "#fff2ea",
    "--blk-success": "#4cc38a",
    "--blk-success-soft": "rgba(76, 195, 138, 0.12)",
    "--blk-warning": "#f2b544",
    "--blk-warning-soft": "rgba(242, 181, 68, 0.12)",
    "--blk-error": "#ff6a5c",
    "--blk-error-soft": "rgba(255, 92, 77, 0.1)",
    "--blk-scrim": "rgba(0, 0, 0, 0.55)",
    "--blk-chart-ghost": "rgba(237, 237, 237, 0.3)",
  },
};

const subscribeNoop = () => () => {};

/** True after hydration. Server and first client render agree on false. */
export function useHydrated() {
  return useSyncExternalStore(
    subscribeNoop,
    () => true,
    () => false,
  );
}

/** Reduced motion, read only after hydration so the first render matches the server. */
export function useBlockReducedMotion() {
  const hydrated = useHydrated();
  const reduced = useReducedMotion();
  return hydrated && reduced === true;
}

/**
 * Resolves a block's theme to inline custom properties. "auto" leaves the library tokens to the page
 * (no flash on load) and only adds the block extras for the resolved tone; "light" and "dark" pin every token.
 */
export function useAppBlockTheme(theme: AppBlockTheme = "auto") {
  const forced = theme === "auto" ? undefined : theme;
  const tone = useBjorkTone(forced);
  const style = {
    ...(forced ? TOKENS[forced] : {}),
    ...EXTRAS[tone],
    colorScheme: tone,
  } as CSSProperties;
  return { tone, style };
}

/** Width of an element, tracked with ResizeObserver. 0 until measured. */
export function useElementWidth(ref: RefObject<HTMLElement | null>) {
  const [width, setWidth] = useState(0);
  useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    setWidth(el.getBoundingClientRect().width);
    if (typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
    ro.observe(el);
    return () => ro.disconnect();
  }, [ref]);
  return width;
}

export const focusRing =
  "outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--bjork-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bjork-ring-offset)]";

export const blockRoot =
  "@container relative isolate flex min-h-0 w-full overflow-hidden bg-[var(--bjork-bg)] font-bjork-alpha text-[color:var(--bjork-text)] antialiased selection:bg-[color:var(--bjork-accent-muted)]";

type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "outline";
type ButtonSize = "sm" | "md" | "lg";

const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
  primary:
    "bg-[var(--blk-accent-fill)] text-[color:var(--blk-accent-fill-ink)] shadow-[inset_0_1px_0_rgba(255,255,255,0.22),0_1px_2px_rgba(0,0,0,0.18)] hover:brightness-[1.08]",
  secondary:
    "border border-[color:var(--bjork-border)] bg-[var(--bjork-surface)] text-[color:var(--bjork-text)] shadow-[var(--bjork-shadow-soft)] hover:bg-[var(--bjork-surface-hover)]",
  outline:
    "border border-[color:var(--bjork-border-strong)] bg-transparent text-[color:var(--bjork-text)] hover:bg-[var(--bjork-surface-hover)]",
  ghost:
    "bg-transparent text-[color:var(--bjork-text-medium)] hover:bg-[var(--bjork-surface-hover)] hover:text-[color:var(--bjork-text)]",
  danger:
    "bg-[var(--blk-error)] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] hover:brightness-[1.08]",
};

const BUTTON_SIZES: Record<ButtonSize, string> = {
  sm: "h-8 gap-1.5 rounded-[9px] px-2.5 text-[12.5px]",
  md: "h-9 gap-2 rounded-[10px] px-3.5 text-[13px]",
  lg: "h-11 gap-2 rounded-[12px] px-4 text-[14px]",
};

export interface BlockButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant;
  size?: ButtonSize;
}

export const BlockButton = forwardRef<HTMLButtonElement, BlockButtonProps>(function BlockButton(
  { variant = "secondary", size = "md", className, type = "button", ...props },
  ref,
) {
  return (
    <button
      ref={ref}
      type={type}
      className={cn(
        "inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap font-medium tracking-[-0.01em] transition-[background-color,color,filter,transform,border-color] duration-150 ease-out active:scale-[0.97] disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none motion-reduce:active:scale-100 [&_svg]:size-4 [&_svg]:shrink-0",
        focusRing,
        BUTTON_VARIANTS[variant],
        BUTTON_SIZES[size],
        className,
      )}
      {...props}
    />
  );
});

export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  /** Accessible name. Also used as the native tooltip. */
  label: string;
  size?: "sm" | "md";
  pressed?: boolean;
}

export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
  { label, size = "md", pressed, className, type = "button", children, ...props },
  ref,
) {
  return (
    <button
      ref={ref}
      type={type}
      aria-label={label}
      title={label}
      aria-pressed={pressed}
      className={cn(
        "inline-grid shrink-0 cursor-pointer place-items-center rounded-[9px] text-[color:var(--bjork-text-muted)] transition-[background-color,color,transform] duration-150 ease-out hover:bg-[var(--bjork-surface-hover)] hover:text-[color:var(--bjork-text)] active:scale-[0.94] disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none motion-reduce:active:scale-100 aria-pressed:text-[color:var(--blk-accent-ink)] [&_svg]:shrink-0",
        size === "sm" ? "size-7 [&_svg]:size-[15px]" : "size-9 [&_svg]:size-[17px]",
        focusRing,
        className,
      )}
      {...props}
    >
      {children}
    </button>
  );
});

export function Kbd({ children, className }: { children: ReactNode; className?: string }) {
  return (
    <kbd
      className={cn(
        "inline-flex h-5 min-w-5 items-center justify-center rounded-[5px] border border-[color:var(--bjork-border-strong)] bg-[var(--bjork-surface)] px-1 font-mono text-[11px] font-medium leading-none text-[color:var(--bjork-text-muted)] shadow-[0_1px_0_var(--bjork-border-strong)]",
        className,
      )}
    >
      {children}
    </kbd>
  );
}

export const inputClass =
  "h-10 w-full min-w-0 rounded-[10px] border border-[color:var(--bjork-border)] bg-[var(--bjork-field)] px-3 text-[13.5px] text-[color:var(--bjork-text)] shadow-[var(--bjork-shadow-soft)] outline-none transition-[border-color,box-shadow,background-color] duration-150 placeholder:text-[color:var(--bjork-text-soft)] hover:border-[color:var(--bjork-border-strong)] focus-visible:border-[color:var(--bjork-accent)] focus-visible:ring-[3px] focus-visible:ring-[color:var(--bjork-accent-soft)] aria-[invalid=true]:border-[color:var(--blk-error)] aria-[invalid=true]:focus-visible:ring-[color:var(--blk-error-soft)] disabled:cursor-not-allowed disabled:opacity-50";

export interface BlockFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "children"> {
  label: string;
  hint?: ReactNode;
  error?: string | null;
  /** Rendered at the right of the label row, e.g. a "Forgot?" link. */
  labelAside?: ReactNode;
  /** Rendered inside the field on the right, e.g. a show/hide toggle. */
  trailing?: ReactNode;
  className?: string;
  inputClassName?: string;
}

/** Label, input, hint and error wired together with ids. The error replaces the hint and is announced. */
export const BlockField = forwardRef<HTMLInputElement, BlockFieldProps>(function BlockField(
  { label, hint, error, labelAside, trailing, className, inputClassName, id, ...props },
  ref,
) {
  const autoId = useId();
  const inputId = id ?? autoId;
  const noteId = `${inputId}-note`;
  const hasNote = Boolean(error) || hint !== undefined;
  return (
    <div className={cn("flex min-w-0 flex-col gap-1.5", className)}>
      <div className="flex items-baseline justify-between gap-3">
        <label htmlFor={inputId} className="text-[12.5px] font-medium text-[color:var(--bjork-text-medium)]">
          {label}
        </label>
        {labelAside}
      </div>
      <div className="relative">
        <input
          ref={ref}
          id={inputId}
          aria-invalid={error ? true : undefined}
          aria-describedby={hasNote ? noteId : undefined}
          className={cn(inputClass, trailing ? "pr-10" : undefined, inputClassName)}
          {...props}
        />
        {trailing && <div className="absolute inset-y-0 right-1 flex items-center">{trailing}</div>}
      </div>
      {hasNote && (
        <p
          id={noteId}
          role={error ? "alert" : undefined}
          className={cn(
            "flex items-start gap-1.5 text-[12px] leading-[1.45]",
            error ? "text-[color:var(--blk-error)]" : "text-[color:var(--bjork-text-soft)]",
          )}
        >
          {error ? (
            <>
              <svg viewBox="0 0 16 16" aria-hidden="true" className="mt-[2px] size-3 shrink-0">
                <circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" strokeWidth="1.5" />
                <path d="M8 4.75v3.75M8 10.9v.1" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
              </svg>
              {error}
            </>
          ) : (
            hint
          )}
        </p>
      )}
    </div>
  );
});

/** Initials avatar on a quiet tinted disc. Hue comes from the name so a person keeps their colour. */
export function InitialsAvatar({
  name,
  size = 28,
  className,
}: {
  name: string;
  size?: number;
  className?: string;
}) {
  const initials = name
    .split(/\s+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((part) => part[0]?.toUpperCase())
    .join("");
  let hash = 0;
  for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
  const hue = [22, 38, 150, 200, 260, 330][hash % 6];
  return (
    <span
      aria-hidden="true"
      className={cn(
        "inline-grid shrink-0 place-items-center rounded-full font-semibold tracking-[-0.02em] text-[color:var(--bjork-text)]",
        className,
      )}
      style={{
        width: size,
        height: size,
        fontSize: Math.max(11, Math.round(size * 0.38)),
        background: `color-mix(in oklab, hsl(${hue} 70% 55%) 22%, var(--bjork-surface-active))`,
        boxShadow: `inset 0 0 0 1px color-mix(in oklab, hsl(${hue} 70% 55%) 24%, transparent)`,
      }}
    >
      {initials}
    </span>
  );
}

const FOCUSABLE =
  'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

/**
 * Keeps focus inside `ref` while `active`, closes on Escape, and hands focus back to whatever had it
 * before. Used by the in-block drawers and dialogs, which are positioned inside the block rather than portalled,
 * so a forced theme reaches them.
 */
export function useFocusTrap(ref: RefObject<HTMLElement | null>, active: boolean, onEscape: () => void) {
  const escapeRef = useRef(onEscape);
  useLayoutEffect(() => {
    escapeRef.current = onEscape;
  });
  useEffect(() => {
    if (!active) return;
    const node = ref.current;
    if (!node) return;
    const previous = document.activeElement as HTMLElement | null;
    const first = node.querySelector<HTMLElement>("[data-autofocus]") ?? node.querySelector<HTMLElement>(FOCUSABLE);
    (first ?? node).focus({ preventScroll: true });
    const onKey = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        event.stopPropagation();
        escapeRef.current();
        return;
      }
      if (event.key !== "Tab") return;
      const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
        (el) => el.offsetParent !== null || el === document.activeElement,
      );
      if (items.length === 0) return;
      const head = items[0];
      const tail = items[items.length - 1];
      if (event.shiftKey && document.activeElement === head) {
        event.preventDefault();
        tail.focus();
      } else if (!event.shiftKey && document.activeElement === tail) {
        event.preventDefault();
        head.focus();
      }
    };
    node.addEventListener("keydown", onKey);
    return () => {
      node.removeEventListener("keydown", onKey);
      if (previous && document.contains(previous)) previous.focus({ preventScroll: true });
    };
  }, [active, ref]);
}

export interface BlockDrawerProps {
  open: boolean;
  onClose: () => void;
  side?: "left" | "right";
  /** Accessible name of the dialog. */
  label: string;
  children: ReactNode;
  className?: string;
}

/**
 * A modal drawer anchored to the block, not the viewport. Under reduced motion it fades instead of sliding.
 * The block's own content should be marked `inert` while it is open.
 */
export function BlockDrawer({ open, onClose, side = "left", label, children, className }: BlockDrawerProps) {
  const panelRef = useRef<HTMLDivElement>(null);
  const reduce = useBlockReducedMotion();
  useFocusTrap(panelRef, open, onClose);
  const hidden = side === "left" ? "-translate-x-full" : "translate-x-full";
  return (
    <div className={cn("absolute inset-0 z-40", open ? "pointer-events-auto" : "pointer-events-none")} aria-hidden={!open}>
      <div
        className={cn(
          "absolute inset-0 bg-[var(--blk-scrim)] transition-opacity duration-200 ease-out",
          open ? "opacity-100" : "opacity-0",
        )}
        onClick={onClose}
      />
      <div
        ref={panelRef}
        role="dialog"
        aria-modal="true"
        aria-label={label}
        tabIndex={-1}
        inert={!open}
        className={cn(
          "absolute inset-y-0 flex w-[min(300px,86%)] flex-col bg-[var(--bjork-surface)] shadow-[var(--bjork-shadow-menu)] outline-none",
          side === "left" ? "left-0 border-r" : "right-0 border-l",
          "border-[color:var(--bjork-border)]",
          reduce
            ? cn("transition-opacity duration-150", open ? "opacity-100" : "opacity-0")
            : cn(
                "transition-transform duration-[280ms] ease-[cubic-bezier(0.32,0.72,0,1)]",
                open ? "translate-x-0" : hidden,
              ),
          className,
        )}
      >
        {children}
      </div>
    </div>
  );
}

/** Polite status text for screen readers; changes are announced once. */
export function useAnnouncer() {
  const [message, setMessage] = useState("");
  const announce = useCallback((next: string) => {
    // Clearing first makes a repeated message announce again.
    setMessage("");
    window.requestAnimationFrame(() => setMessage(next));
  }, []);
  const region = (
    <span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
      {message}
    </span>
  );
  return { announce, region };
}

/** A small toast pinned to the bottom of the block. Purely visual; pair it with `useAnnouncer`. */
export function BlockToast({ message, tone = "neutral" }: { message: string | null; tone?: "neutral" | "success" | "error" }) {
  const reduce = useBlockReducedMotion();
  const dot =
    tone === "success" ? "var(--blk-success)" : tone === "error" ? "var(--blk-error)" : "var(--bjork-accent)";
  return (
    <div className="pointer-events-none absolute inset-x-0 bottom-4 z-50 flex justify-center px-4" aria-hidden="true">
      <div
        className={cn(
          "flex max-w-full items-center gap-2.5 rounded-full border border-[color:var(--bjork-border)] bg-[var(--bjork-menu)] py-2 pl-3 pr-4 text-[12.5px] font-medium text-[color:var(--bjork-text)] shadow-[var(--bjork-shadow-menu)] backdrop-blur-xl",
          reduce ? "transition-opacity duration-150" : "transition-[opacity,transform] duration-200 ease-out",
          message ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0",
        )}
      >
        <span className="size-1.5 shrink-0 rounded-full" style={{ background: dot }} />
        <span className="truncate">{message}</span>
      </div>
    </div>
  );
}

/** Shows a toast for `ms` and announces it. */
export function useBlockToast(ms = 2600) {
  const { announce, region } = useAnnouncer();
  const [toast, setToast] = useState<{ message: string; tone: "neutral" | "success" | "error" } | null>(null);
  const timer = useRef<number | undefined>(undefined);
  const show = useCallback(
    (message: string, tone: "neutral" | "success" | "error" = "neutral") => {
      window.clearTimeout(timer.current);
      setToast({ message, tone });
      announce(message);
      timer.current = window.setTimeout(() => setToast(null), ms);
    },
    [announce, ms],
  );
  useEffect(() => () => window.clearTimeout(timer.current), []);
  const node = (
    <>
      <BlockToast message={toast?.message ?? null} tone={toast?.tone} />
      {region}
    </>
  );
  return { show, node };
}

export interface BlockDialogProps {
  open: boolean;
  onClose: () => void;
  /** Visible title; also the dialog's accessible name. */
  title: string;
  description?: ReactNode;
  children?: ReactNode;
  /** Buttons for the footer, right-aligned. */
  footer?: ReactNode;
  tone?: "neutral" | "danger";
  className?: string;
}

/**
 * A modal dialog positioned inside the block (not portalled), so a forced theme reaches it. Traps focus, closes
 * on Escape or a scrim click, and returns focus to the trigger. Mark the block's own content `inert` while open.
 */
export function BlockDialog({ open, onClose, title, description, children, footer, tone = "neutral", className }: BlockDialogProps) {
  const panelRef = useRef<HTMLDivElement>(null);
  const reduce = useBlockReducedMotion();
  const titleId = useId();
  const descId = useId();
  useFocusTrap(panelRef, open, onClose);
  return (
    <div
      className={cn("absolute inset-0 z-50 grid place-items-center p-4", open ? "pointer-events-auto" : "pointer-events-none")}
      aria-hidden={!open}
    >
      <div
        className={cn(
          "absolute inset-0 bg-[var(--blk-scrim)] transition-opacity duration-200 ease-out",
          open ? "opacity-100" : "opacity-0",
        )}
        onClick={onClose}
      />
      <div
        ref={panelRef}
        role={tone === "danger" ? "alertdialog" : "dialog"}
        aria-modal="true"
        aria-labelledby={titleId}
        aria-describedby={description ? descId : undefined}
        tabIndex={-1}
        inert={!open}
        className={cn(
          "relative flex max-h-full w-full max-w-[440px] flex-col overflow-hidden rounded-[18px] border border-[color:var(--bjork-border)] bg-[var(--bjork-surface)] shadow-[var(--bjork-shadow-menu)] outline-none",
          reduce
            ? cn("transition-opacity duration-150", open ? "opacity-100" : "opacity-0")
            : cn(
                "transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]",
                open ? "scale-100 opacity-100" : "scale-[0.97] opacity-0",
              ),
          className,
        )}
      >
        <div className="overflow-y-auto px-5 pb-4 pt-5">
          <h2 id={titleId} className="text-[16px] font-semibold tracking-[-0.015em]">
            {title}
          </h2>
          {description && (
            <div id={descId} className="mt-1.5 text-[13px] leading-[1.55] text-[color:var(--bjork-text-muted)]">
              {description}
            </div>
          )}
          {children && <div className="mt-4">{children}</div>}
        </div>
        {footer && (
          <div className="flex flex-wrap items-center justify-end gap-2 border-t border-[color:var(--bjork-border)] bg-[var(--bjork-panel)] px-5 py-3">
            {footer}
          </div>
        )}
      </div>
    </div>
  );
}

export interface BlockSwitchProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
  checked: boolean;
  onCheckedChange: (checked: boolean) => void;
  /** Accessible name when there is no visible label wired with aria-labelledby. */
  label?: string;
}

/** A role="switch" toggle. Space and Enter flip it; it reads as on/off to assistive tech. */
export const BlockSwitch = forwardRef<HTMLButtonElement, BlockSwitchProps>(function BlockSwitch(
  { checked, onCheckedChange, label, className, disabled, ...props },
  ref,
) {
  return (
    <button
      ref={ref}
      type="button"
      role="switch"
      aria-checked={checked}
      aria-label={label}
      disabled={disabled}
      onClick={() => onCheckedChange(!checked)}
      className={cn(
        "relative inline-flex h-[22px] w-[38px] shrink-0 cursor-pointer items-center rounded-full border transition-[background-color,border-color] duration-200 ease-out disabled:cursor-not-allowed disabled:opacity-45 motion-reduce:transition-none",
        checked
          ? "border-transparent bg-[var(--blk-accent-fill)]"
          : "border-[color:var(--bjork-border-strong)] bg-[var(--bjork-field-inset)] shadow-[var(--bjork-shadow-inset)]",
        focusRing,
        className,
      )}
      {...props}
    >
      <span
        aria-hidden="true"
        className={cn(
          "absolute left-[2px] size-4 rounded-full bg-white shadow-[0_1px_2px_rgba(0,0,0,0.3)] transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
          checked ? "translate-x-4" : "translate-x-0",
        )}
      />
    </button>
  );
});

export function BlockSpinner({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" aria-hidden="true" className={cn("animate-spin motion-reduce:animate-[spin_1.6s_linear_infinite]", className)}>
      <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeOpacity="0.25" strokeWidth="1.8" />
      <path d="M14 8a6 6 0 0 0-6-6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
    </svg>
  );
}
```

### `components/bjork-ui/_core/tone.ts`

```ts
"use client";

import { useSyncExternalStore } from "react";
import { useTheme } from "next-themes";
import type { BjorkTone } from "./palette";

const subscribeNoop = () => () => {};
const getMounted = () => true;
const getServerMounted = () => false;

/**
 * Resolves the tone for a component. An explicit `tone` always wins.
 * Otherwise it follows next-themes' resolved theme, but only after mount,
 * so the server render and first client render agree on dark.
 */
export function useBjorkTone(tone?: BjorkTone): BjorkTone {
  const { resolvedTheme } = useTheme();
  const mounted = useSyncExternalStore(subscribeNoop, getMounted, getServerMounted);
  if (tone) return tone;
  if (!mounted) return "dark";
  return resolvedTheme === "light" ? "light" : "dark";
}
```

### `components/bjork-ui/_core/palette.ts`

```ts
export type BjorkTone = "dark" | "light";

// Canvas, WebGL and SVG attribute colours. DOM code uses the CSS tokens with these as fallbacks.
export const BJORK_PALETTE = {
  dark: {
    bg: "#050505", stage: "#111111", surface: "#121212", raised: "#161616", active: "#202020",
    border: "#232323", borderStrong: "#343434",
    text: "#ededed", textMedium: "rgba(237,237,237,0.72)", textMuted: "rgba(237,237,237,0.6)",
    textSoft: "rgba(237,237,237,0.5)", textFaint: "rgba(237,237,237,0.22)", hair: "rgba(237,237,237,0.12)",
    accent: "#ec5c13", accentInk: "#ec5c13", accentFill: "#b84a12", accentSoft: "rgba(236,92,19,0.12)", accentMuted: "rgba(236,92,19,0.24)",
    accentFg: "#fff2ea", success: "#4cc38a", warning: "#f2b544", error: "#ff5c4d",
  },
  light: {
    bg: "#f7f5ef", stage: "#f7f5ef", surface: "#fffcf6", raised: "#f5efe3", active: "#efe7d8",
    border: "#eee6db", borderStrong: "#e1d7c8",
    text: "#171717", textMedium: "rgba(23,23,23,0.78)", textMuted: "rgba(23,23,23,0.68)",
    textSoft: "rgba(23,23,23,0.62)", textFaint: "rgba(23,23,23,0.22)", hair: "rgba(23,23,23,0.10)",
    accent: "#ec7d43", accentInk: "#b4531f", accentFill: "#ec7d43", accentSoft: "rgba(236,125,67,0.10)", accentMuted: "rgba(236,125,67,0.20)",
    accentFg: "#3f2112", success: "#1f8a55", warning: "#a86b00", error: "#c8361f",
  },
} as const;
```

### `lib/utils.ts`

```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```
