# Message (Lumen Halo): prompt.md (v1.0.0)

- id: `lumen-message` · version 1.0.0 · component · free
- category: AI interfaces
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add https://beautiful-ui.dev/r/lumen-message.json`
- npm dependencies: none
- registry dependencies: utils, @beautiful-ui/lumen-avatar, @beautiful-ui/lumen-button, @beautiful-ui/lumen-clipboard-button, https://beautiful-ui.dev/r/lumen-foundation.json
- docs: https://beautiful-ui.dev/components/lumen-message
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

One message in a chat: the assistant writes on the page beside its mark, the person's words sit in a quiet bubble, system notes are centred. Name and time, copy and retry, a failed send that says so, a streaming caret, and a content slot that styles your rendered markdown.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: none beyond React.
- Files: `components/ui/lumen/controls/message.tsx`; shared code: `lib/beautiful-ui/core/chat.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `lumen-avatar`, `lumen-button`, `lumen-clipboard-button`, `lumen-foundation`.
- Builds: one set of files for both, but its dependencies come in Base UI and Radix builds. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `Message`, `MessageAvatar`, `MessageBody`, `MessageHeader`, `MessageTime`, `MessageContent`, `MessageActions`, `MessageAction`, `MessageCopy`, `MessageRetry`, and every exported type.
- CSS: the install merges this item's rules (the registry `css` field) into your global stylesheet, in `@layer components`, and adds the lumen foundation (tokens, keyframes, motion levels) once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Tokens: retheme with the `--lumen-*` custom properties (`--lumen-accent`, `--lumen-accent-foreground`, `--lumen-accent-text`, `--lumen-bad`, `--lumen-bad-text`, `--lumen-chart-accent`, `--lumen-chart-accent-foreground`, `--lumen-chart-accent-text`, `--lumen-focus`, `--lumen-good`, `--lumen-good-text`, `--lumen-hairline`, `--lumen-ink`, `--lumen-muted-ink`, `--lumen-series-1`, `--lumen-series-2`, `--lumen-series-3`, `--lumen-series-4`, `--lumen-series-5`, `--lumen-series-6`, `--lumen-warn`, `--lumen-warn-text`); this item's CSS also reads `--lumen-font-mono`, `--lumen-font-sans`, `--lumen-radius-k`. Never add Tailwind colour classes inside the component.

```tsx
import { /* … */ } from "@/components/ui/lumen/controls/message";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `Message` | — | The message: who it's from and its status. |
| `MessageAvatar` | — | The author's mark or photo. |
| `MessageBody` | — | The column beside the avatar. |
| `MessageHeader` | — | Name and time. |
| `MessageTime` | — | A time element with the full date as its title. |
| `MessageContent` | — | The words, or your rendered markdown. |
| `MessageActions` | — | The row under the text; Not sent and Retry when sending failed. |
| `MessageAction` | — | An icon action of your own. |
| `MessageCopy` | — | Copies the message. |
| `MessageRetry` | — | Asks for the reply again. |

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads.

## Match the original
- Read `components/ui/lumen/controls/message.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- chat message, AI reply, assistant message, user message bubble, message actions, copy and retry, Lumen
- Each message of an AI chat or a support thread
- A single reply shown outside a thread (an answer card)

### Not when
- A whole thread with scrolling and day lines: use Conversation, which renders Message for you
- Comments on a document: use Activity Timeline

## Mistakes
- Don't pass model output as HTML: render markdown to elements (react-markdown) and put them in MessageContent
- Give the copy button the plain text, not the rendered markup

## Usage

```tsx
"use client";

import {
  Message,
  MessageActions,
  MessageAvatar,
  MessageBody,
  MessageContent,
  MessageCopy,
  MessageHeader,
  MessageRetry,
} from "@/components/ui/lumen/controls/message";

export function Reply({ text, sentAt, onRegenerate }: { text: string; sentAt: Date; onRegenerate: () => void }) {
  return (
    <div className="flex flex-col gap-5">
      <Message from="user">
        <MessageBody>
          <MessageContent>Summarise the incident report in three bullets.</MessageContent>
        </MessageBody>
      </Message>
      <Message from="assistant">
        <MessageAvatar name="Assistant" />
        <MessageBody>
          <MessageHeader name="Assistant" time={sentAt} />
          <MessageContent>{text}</MessageContent>
          <MessageActions>
            <MessageCopy value={text} />
            <MessageRetry onClick={onRegenerate} />
          </MessageActions>
        </MessageBody>
      </Message>
    </div>
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `from` | `"user" \| "assistant" \| "system"` | `"assistant"` | Who wrote it. The person's words sit in a bubble on the far side; system notes are centred. |
| `status` | `"sent" \| "sending" \| "error" \| "streaming"` | `"sent"` | sending dims the text and says Sending…; error shows Not sent with Retry (MessageActions onRetry); streaming adds a caret (three cells while empty) and aria-busy. |
| `grouped` | `boolean` | `false` | A follow-up from the same author: tighter, and its bubble corner meets the one above. |
| `actions` | `"hover" \| "always"` | `"hover"` | When the actions row shows. Hover also means focus, and touch screens always show it. |
| `labels` | `Partial<MessageLabels>` |  | copy, copied, retry, sending, failed: for other languages. |
| `MessageAvatar name / src` | `string` |  | Initials from the name, the photo when it loads. Or pass your own children. |
| `MessageHeader name / time / locale / timeZone` | `ReactNode / Date \| number \| string` |  | The name and the time of day (the full date in its title). Fix timeZone so server and browser agree. |
| `MessageContent` | `ReactNode` |  | Text keeps its line breaks; rendered markdown (p, lists, code, pre, links, quotes, tables) is styled for chat. Never set raw HTML here. |
| `MessageActions onRetry` | `() => void` |  | On a failed message: the Retry beside Not sent. |
| `MessageCopy value / MessageRetry onClick / MessageAction label icon` | `…` |  | Copy (Clipboard Button), ask again, or your own icon action (the label is its name and title). |

Full docs: https://beautiful-ui.dev/components/lumen-message

## Customising
- Colours: set the `--lumen-*` tokens on `:root`, or on a container with the `lumen-scope` class to retheme one area. Add the `lumen-inherit` class to follow your shadcn palette instead (`--chart-N`, `--destructive`).
- Dark mode follows the `.dark` class on an ancestor (the shadcn and next-themes convention).
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| Tab | Reach the actions (they show on focus as well as hover) |
| Enter or Space | Copy or retry |

## Motion inventory

| Interaction | What moves |
|---|---|
| Arrive | A message rises 4px into place; reduced motion places it |
| Streaming | A slow caret breathes at the end; before the first words, three cells take turns. Reduced motion holds both still |

## Accessibility contract (preserve when editing)
- Each message is an article; its header gives the name and a machine-readable time
- A streaming message is aria-busy, so a surrounding log announces it once it settles, not token by token
- A failed send is an alert with a named Retry; icon actions carry their label as the accessible name

## Install

Base UI project (a base-* style in components.json):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/lumen-message.json
```

Radix project (a radix-*, new-york or default style):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/radix-nova/lumen-message.json
```

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/lumen-message`: the CLI picks the build from your style.

## Credits
- Built on shadcn/ui (https://ui.shadcn.com)

## Source (Base UI build)

### components/ui/lumen/controls/message.tsx

```tsx
"use client";
// Generated from src/registry/core/controls/message.tsx by scripts/gen-systems.ts. Edit the core file, not this one.

import * as React from "react";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/lumen/avatar";
import { Button } from "@/components/ui/lumen/button";
import { ClipboardButton } from "@/components/ui/lumen/controls/clipboard-button";
import { initials, timeLabel, type ChatRole, type ChatStatus } from "@/lib/beautiful-ui/core/chat";

/*
 * Message: one message in a chat. The assistant writes on the page with its mark beside it; the
 * person's own words sit in a quiet bubble on the far side; system notes are centred and small. A
 * header carries the name and time, actions (copy, retry, your own) follow the text, and a message
 * that failed to send says so with Retry. The content is a slot: pass text, or your rendered
 * markdown, and Lumen styles its paragraphs, lists, code and links. Nothing is set as raw HTML.
 *
 *   <Message from="assistant">
 *     <MessageAvatar name="Assistant" />
 *     <MessageBody>
 *       <MessageHeader name="Assistant" time={sentAt} />
 *       <MessageContent>{text}</MessageContent>
 *       <MessageActions><MessageCopy value={text} /><MessageRetry onClick={regenerate} /></MessageActions>
 *     </MessageBody>
 *   </Message>
 */

export interface MessageLabels {
  copy: string;
  copied: string;
  retry: string;
  sending: string;
  failed: string;
}

export const defaultMessageLabels: MessageLabels = { copy: "Copy", copied: "Copied", retry: "Retry", sending: "Sending…", failed: "Not sent" };

type MessageContextValue = { from: ChatRole; status: ChatStatus; labels: MessageLabels };
const MessageContext = React.createContext<MessageContextValue>({ from: "assistant", status: "sent", labels: defaultMessageLabels });
const useMessage = () => React.useContext(MessageContext);

function Message({
  from = "assistant",
  status = "sent",
  grouped = false,
  actions = "hover",
  labels,
  className,
  children,
  ...props
}: React.ComponentProps<"article"> & {
  /** Who wrote it: the person (user), the model (assistant) or the app (system). */
  from?: ChatRole | undefined;
  /** sending, error (offers Retry) or streaming (still arriving: aria-busy until it settles). */
  status?: ChatStatus | undefined;
  /** A follow-up in a run from the same author: no avatar or header, tighter spacing. */
  grouped?: boolean | undefined;
  /** When the actions show: on hover and focus (always on touch screens), or always. */
  actions?: "hover" | "always" | undefined;
  labels?: Partial<MessageLabels> | undefined;
}) {
  const ctx = React.useMemo(() => ({ from, status, labels: { ...defaultMessageLabels, ...labels } }), [from, status, labels]);
  return (
    <MessageContext value={ctx}>
      <article
        data-slot="message"
        data-from={from}
        data-status={status}
        data-grouped={grouped || undefined}
        data-actions={actions}
        aria-busy={status === "streaming" || undefined}
        className={cn(`lumen-message`, className)}
        {...props}
      >
        {children}
      </article>
    </MessageContext>
  );
}

function MessageAvatar({ name, src, className, children, ...props }: React.ComponentProps<"div"> & { name?: string | undefined; src?: string | undefined }) {
  return (
    <div data-slot="message-avatar" aria-hidden className={cn(`lumen-message-avatar`, className)} {...props}>
      {children ?? (
        <Avatar size="sm">
          {src && <AvatarImage src={src} alt="" />}
          <AvatarFallback>{name ? initials(name) : null}</AvatarFallback>
        </Avatar>
      )}
    </div>
  );
}

function MessageBody({ className, ...props }: React.ComponentProps<"div">) {
  return <div data-slot="message-body" className={cn(`lumen-message-body`, className)} {...props} />;
}

/** The time a message was sent, as the locale writes it, with the full date and time in its title. */
function MessageTime({ at, locale, timeZone, className, ...props }: Omit<React.ComponentProps<"time">, "dateTime"> & { at: Date | number | string; locale?: string | undefined; timeZone?: string | undefined }) {
  const date = at instanceof Date ? at : new Date(at);
  const full = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone }).format(date);
  return (
    // The server and the browser can sit in different zones: pass timeZone to make them agree.
    <time data-slot="message-time" dateTime={date.toISOString()} title={full} suppressHydrationWarning className={cn(`lumen-message-time`, className)} {...props}>
      {timeLabel(date, { locale, timeZone })}
    </time>
  );
}

function MessageHeader({ name, time, locale, timeZone, className, children, ...props }: React.ComponentProps<"header"> & { name?: React.ReactNode; time?: Date | number | string | undefined; locale?: string | undefined; timeZone?: string | undefined }) {
  const { status, labels } = useMessage();
  return (
    <header data-slot="message-header" className={cn(`lumen-message-header`, className)} {...props}>
      {name != null && <span className={`lumen-message-name`}>{name}</span>}
      {status === "sending" ? <span className={`lumen-message-time`}>{labels.sending}</span> : time != null ? <MessageTime at={time} locale={locale} timeZone={timeZone} /> : null}
      {children}
    </header>
  );
}

/**
 * The words. Text keeps its line breaks; rendered markdown (p, ul, ol, pre, code, a, blockquote,
 * h1–h4, table) is styled to sit quietly in a chat. A streaming message ends in a caret.
 */
function MessageContent({ className, children, ...props }: React.ComponentProps<"div">) {
  const { status } = useMessage();
  const empty = children == null || children === "" || children === false;
  return (
    <div data-slot="message-content" data-plain={typeof children === "string" || undefined} className={cn(`lumen-message-content`, className)} {...props}>
      {children}
      {status === "streaming" &&
        (empty ? (
          // Nothing yet: three cells take turns while the reply starts.
          <span aria-hidden className={`lumen-message-typing`}>
            <i />
            <i />
            <i />
          </span>
        ) : (
          <span aria-hidden className={`lumen-message-caret`} />
        ))}
    </div>
  );
}

/** The row under the text. A failed message shows why and a Retry here, before your actions. */
function MessageActions({ onRetry, className, children, ...props }: React.ComponentProps<"div"> & { onRetry?: (() => void) | undefined }) {
  const { status, labels } = useMessage();
  return (
    <div data-slot="message-actions" className={cn(`lumen-message-actions`, className)} {...props}>
      {status === "error" && (
        <span className={`lumen-message-failed`} role="alert">
          <svg aria-hidden viewBox="0 0 16 16">
            <circle cx="8" cy="8" r="6" />
            <path d="M8 5v3.5M8 10.8v.2" />
          </svg>
          {labels.failed}
          {onRetry && (
            <Button type="button" size="xs" variant="ghost" onClick={onRetry}>
              {labels.retry}
            </Button>
          )}
        </span>
      )}
      {children}
    </div>
  );
}

/** An icon button in the actions row. Its label is the accessible name and the hover title. */
function MessageAction({ label, icon, className, ...props }: Omit<React.ComponentProps<typeof Button>, "children" | "aria-label"> & { label: string; icon: React.ComponentProps<typeof Button>["icon"] }) {
  return <Button type="button" data-slot="message-action" variant="ghost" size="icon-xs" icon={icon} aria-label={label} title={label} className={cn(`lumen-message-action`, className)} {...props} />;
}

/** Copies the message (Clipboard Button: a check draws in and "Copied" is announced). */
function MessageCopy({ value, onCopy, className }: { value: string | (() => string | Promise<string>); onCopy?: ((text: string) => void) | undefined; className?: string | undefined }) {
  const { labels } = useMessage();
  return <ClipboardButton data-slot="message-copy" value={value} onCopy={onCopy} variant="ghost" size="xs" aria-label={labels.copy} title={labels.copy} copiedLabel={labels.copied} motion="subtle" className={cn(`lumen-message-action`, className)} />;
}

function RetryGlyph() {
  return (
    <svg aria-hidden viewBox="0 0 16 16" className={`lumen-message-glyph`}>
      <path d="M12.6 6.2A5 5 0 1 0 13 9.5" />
      <path d="M13 3v3.4H9.6" />
    </svg>
  );
}

/** Ask for this reply again. */
function MessageRetry({ label, ...props }: Omit<React.ComponentProps<typeof MessageAction>, "label" | "icon"> & { label?: string | undefined }) {
  const { labels } = useMessage();
  return <MessageAction data-slot="message-retry" label={label ?? labels.retry} icon={<RetryGlyph />} {...props} />;
}

export { Message, MessageAvatar, MessageBody, MessageHeader, MessageTime, MessageContent, MessageActions, MessageAction, MessageCopy, MessageRetry };
```

### lib/beautiful-ui/core/chat.ts

```tsx
/*
 * The chat set's model (Message, Conversation, the AI Chat block): plain data, no React. A message
 * says who it is from and when; a conversation is grouped into days, and each day into runs of
 * consecutive messages from the same author, so a run shows its avatar and name once. Days and times
 * are read in a time zone you can fix (timeZone), so the server and the browser agree.
 */

export type ChatRole = "user" | "assistant" | "system";

/** Sending: not yet confirmed. Error: failed to send (offer Retry). Streaming: a reply still arriving. */
export type ChatStatus = "sent" | "sending" | "error" | "streaming";

export interface ChatAuthor {
  /** Distinguishes authors with the same role (two people in a shared thread). Defaults to the role. */
  id?: string | undefined;
  name: string;
  /** A photo URL; initials are shown without it (or while it loads). */
  avatar?: string | undefined;
}

export interface ChatMessage {
  id: string;
  from: ChatRole;
  /** The text. Render rich content (markdown, citations) with the Conversation's renderContent. */
  text: string;
  /** When it was sent: a Date, epoch milliseconds or an ISO string. */
  at: Date | number | string;
  author?: ChatAuthor | undefined;
  status?: ChatStatus | undefined;
}

export interface ChatRun {
  key: string;
  from: ChatRole;
  author?: ChatAuthor | undefined;
  messages: ChatMessage[];
}

export interface ChatDay {
  /** YYYY-MM-DD in the conversation's time zone. */
  key: string;
  /** The first message's time, for labelling the day. */
  date: Date;
  runs: ChatRun[];
}

export const toDate = (at: Date | number | string): Date => (at instanceof Date ? at : new Date(at));

/** YYYY-MM-DD for a moment in a time zone (the runtime's own when omitted). */
export function dayKey(at: Date | number | string, timeZone?: string): string {
  const parts = new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone }).formatToParts(toDate(at));
  const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "00";
  return `${get("year")}-${get("month")}-${get("day")}`;
}

const authorKey = (m: ChatMessage) => `${m.from}:${m.author?.id ?? m.author?.name ?? ""}`;

/**
 * Days, then runs: a message joins the run before it when it's from the same author, on the same day,
 * within gapMs of the previous one (default 5 minutes). System messages always stand alone.
 */
export function groupConversation(messages: readonly ChatMessage[], { gapMs = 5 * 60_000, timeZone }: { gapMs?: number; timeZone?: string | undefined } = {}): ChatDay[] {
  const days: ChatDay[] = [];
  let prev: ChatMessage | undefined;
  for (const m of messages) {
    const key = dayKey(m.at, timeZone);
    let day = days[days.length - 1];
    if (!day || day.key !== key) {
      day = { key, date: toDate(m.at), runs: [] };
      days.push(day);
      prev = undefined;
    }
    const run = day.runs[day.runs.length - 1];
    const joins = run && prev && m.from !== "system" && prev.from !== "system" && authorKey(prev) === authorKey(m) && toDate(m.at).getTime() - toDate(prev.at).getTime() <= gapMs;
    if (joins) run.messages.push(m);
    else day.runs.push({ key: m.id, from: m.from, author: m.author, messages: [m] });
    prev = m;
  }
  return days;
}

export interface ChatDayLabels {
  today: string;
  yesterday: string;
}

/** "Today", "Yesterday", a weekday within the week, then a date (with the year when it isn't this one). */
export function dayLabel(date: Date, { now = new Date(), locale, timeZone, labels = { today: "Today", yesterday: "Yesterday" } }: { now?: Date; locale?: string | undefined; timeZone?: string | undefined; labels?: ChatDayLabels } = {}): string {
  const key = dayKey(date, timeZone);
  const today = dayKey(now, timeZone);
  if (key === today) return labels.today;
  // Calendar days apart, read from the keys (so a time zone's offset can't shift the count).
  const days = Math.round((Date.parse(today) - Date.parse(key)) / 86_400_000);
  if (days === 1) return labels.yesterday;
  if (days > 1 && days < 7) return new Intl.DateTimeFormat(locale, { weekday: "long", timeZone }).format(date);
  const sameYear = key.slice(0, 4) === today.slice(0, 4);
  return new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "short", year: sameYear ? undefined : "numeric", timeZone }).format(date);
}

/** The time of day, as the locale writes it ("14:05", "2:05 PM"). */
export function timeLabel(at: Date | number | string, { locale, timeZone }: { locale?: string | undefined; timeZone?: string | undefined } = {}): string {
  return new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone }).format(toDate(at));
}

/** Initials for an avatar: the first letters of the first two words ("Ada Lovelace" → "AL"). */
export function initials(name: string): string {
  const words = name.trim().split(/\s+/).filter(Boolean);
  return words
    .slice(0, 2)
    .map((w) => Array.from(w)[0]?.toUpperCase() ?? "")
    .join("");
}
```

### CSS (the registry `css` / `cssVars`, merged into the global stylesheet by the shadcn CLI)

```css
@layer components {
  @keyframes lumen-message-in {
    from {
      opacity: 0;
      transform: translateY(4px);
    }
  }
  @keyframes lumen-message-caret {
    50% {
      opacity: .18;
    }
  }
  @keyframes lumen-message-typing {
    0%,100% {
      background: var(--gc-cellOff);
    }
    40% {
      background: var(--gc-ink);
    }
  }
  .lumen-message {
    display: grid;
    grid-template-columns: minmax(0,1fr);
    column-gap: 12px;
    min-width: 0;
    color: var(--gc-ink);
    font: 400 14px/1.6 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    animation: lumen-message-in calc(.28s * var(--gcp-k,1)) cubic-bezier(.2,.8,.2,1) both;
  }
  .lumen-message:has(>.lumen-message-avatar) {
    grid-template-columns: 24px minmax(0,1fr);
  }
  .lumen-message[data-from="user"]:has(>.lumen-message-avatar) {
    grid-template-columns: minmax(0,1fr) 24px;
  }
  .lumen-message[data-from="user"]>.lumen-message-avatar {
    order: 2;
  }
  .lumen-message-avatar {
    display: flex;
    align-items: flex-start;
    padding-top: 1px;
  }
  .lumen-message-body {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;
    min-width: 0;
  }
  .lumen-message-body>.lumen-message-content {
    align-self: stretch;
  }
  .lumen-message[data-from="user"] .lumen-message-body {
    align-items: flex-end;
    justify-self: end;
    max-width: min(82%,560px);
  }
  .lumen-message[data-from="user"] .lumen-message-body>.lumen-message-content {
    align-self: auto;
  }
  .lumen-message-header {
    display: flex;
    align-items: baseline;
    gap: 8px;
    min-height: 18px;
    font: 500 12.5px/18px var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    color: var(--gc-ink);
  }
  .lumen-message[data-from="user"] .lumen-message-header {
    flex-direction: row-reverse;
  }
  .lumen-message-name {
    font-weight: 600;
    letter-spacing: -.005em;
  }
  .lumen-message-time {
    font: 500 11px/18px var(--lumen-font-mono, var(--font-mono, var(--font-geist-mono, 'Geist Mono'))), ui-monospace, monospace;
    color: var(--gc-sec);
    font-variant-numeric: tabular-nums;
  }
  .lumen-message-content {
    min-width: 0;
    overflow-wrap: anywhere;
  }
  .lumen-message-content[data-plain] {
    white-space: pre-wrap;
  }
  .lumen-message[data-from="user"] .lumen-message-content {
    padding: 8px 14px;
    border-radius: calc(18px * var(--lumen-radius-k, 1));
    border-end-end-radius: calc(6px * var(--lumen-radius-k, 1));
    background: color-mix(in srgb,var(--gc-ink) 6%,transparent);
    box-shadow: inset 0 0 0 1px var(--gc-hair);
  }
  .lumen-message[data-from="user"][data-grouped] .lumen-message-content {
    border-start-end-radius: calc(6px * var(--lumen-radius-k, 1));
  }
  .lumen-message[data-from="system"] {
    justify-items: center;
    text-align: center;
  }
  .lumen-message[data-from="system"] .lumen-message-body {
    align-items: center;
  }
  .lumen-message[data-from="system"] .lumen-message-content {
    padding: 3px 10px;
    border-radius: calc(999px * var(--lumen-radius-k, 1));
    background: color-mix(in srgb,var(--gc-ink) 4%,transparent);
    color: var(--gc-sec);
    font-size: 12.5px;
    line-height: 1.5;
  }
  .lumen-message[data-status="sending"] .lumen-message-content {
    opacity: .62;
  }
  .lumen-message[data-status="error"][data-from="user"] .lumen-message-content {
    box-shadow: inset 0 0 0 1px color-mix(in srgb,var(--gc-bad) 45%,var(--gc-hair));
  }
  .lumen-message-content>:first-child {
    margin-top: 0;
  }
  .lumen-message-content>:last-child {
    margin-bottom: 0;
  }
  .lumen-message-content :is(p,ul,ol,blockquote,pre,table,figure):not(.lumen-message-content [data-slot] *):not([data-slot]) {
    margin: 0 0 .7em;
  }
  .lumen-message-content :is(ul,ol):not(.lumen-message-content [data-slot] *):not([data-slot]) {
    padding-inline-start: 1.3em;
  }
  .lumen-message-content ul:not(.lumen-message-content [data-slot] *):not([data-slot]) {
    list-style: disc;
  }
  .lumen-message-content ol:not(.lumen-message-content [data-slot] *):not([data-slot]) {
    list-style: decimal;
  }
  .lumen-message-content li+li:not(.lumen-message-content [data-slot] *):not([data-slot]) {
    margin-top: .2em;
  }
  .lumen-message-content li:not(.lumen-message-content [data-slot] *):not([data-slot])::marker {
    color: var(--gc-sec);
  }
  .lumen-message-content :is(h1,h2,h3,h4):not(.lumen-message-content [data-slot] *):not([data-slot]) {
    margin: 1.1em 0 .35em;
    font: 600 14.5px/1.4 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    letter-spacing: -.01em;
  }
  .lumen-message-content h1:not(.lumen-message-content [data-slot] *):not([data-slot]) {
    font-size: 17px;
  }
  .lumen-message-content h2:not(.lumen-message-content [data-slot] *):not([data-slot]) {
    font-size: 15.5px;
  }
  .lumen-message-content strong:not(.lumen-message-content [data-slot] *):not([data-slot]) {
    font-weight: 600;
  }
  .lumen-message-content a:not(.lumen-message-content [data-slot] *):not([data-slot]) {
    color: inherit;
    text-decoration: underline;
    text-decoration-color: color-mix(in srgb,var(--gc-ink) 35%,transparent);
    text-underline-offset: 3px;
    transition: text-decoration-color min(0.2s, var(--gcp-cm)) ease;
  }
  .lumen-message-content a:not(.lumen-message-content [data-slot] *):not([data-slot]):focus-visible {
    outline: none;
    border-radius: calc(3px * var(--lumen-radius-k, 1));
    box-shadow: 0 0 0 2px var(--gc-gap), 0 0 0 4px var(--gc-p-focus), 0 0 #0000;
  }
  .lumen-message-content :not(pre)>code:not(.lumen-message-content [data-slot] *):not([data-slot]) {
    padding: 1px 5px;
    border-radius: calc(5px * var(--lumen-radius-k, 1));
    background: color-mix(in srgb,var(--gc-ink) 7%,transparent);
    font: 500 .86em/1 var(--lumen-font-mono, var(--font-mono, var(--font-geist-mono, 'Geist Mono'))), ui-monospace, monospace;
  }
  .lumen-message-content pre:not(.lumen-message-content [data-slot] *):not([data-slot]) {
    padding: 12px 14px;
    overflow-x: auto;
    border-radius: calc(12px * var(--lumen-radius-k, 1));
    background: color-mix(in srgb,var(--gc-ink) 4%,transparent);
    box-shadow: inset 0 0 0 1px var(--gc-hair);
    font: 400 12.5px/1.6 var(--lumen-font-mono, var(--font-mono, var(--font-geist-mono, 'Geist Mono'))), ui-monospace, monospace;
    white-space: pre;
  }
  .lumen-message-content blockquote:not(.lumen-message-content [data-slot] *):not([data-slot]) {
    margin-inline: 0;
    padding-inline-start: 12px;
    border-inline-start: 2px solid var(--gc-hair);
    color: var(--gc-sec);
  }
  .lumen-message-content table:not(.lumen-message-content [data-slot] *):not([data-slot]) {
    border-collapse: collapse;
    font-size: 13px;
  }
  .lumen-message-content :is(th,td):not(.lumen-message-content [data-slot] *):not([data-slot]) {
    padding: 6px 10px;
    border-bottom: 1px solid var(--gc-hair);
    text-align: start;
  }
  .lumen-message-content th:not(.lumen-message-content [data-slot] *):not([data-slot]) {
    font-weight: 500;
    color: var(--gc-sec);
  }
  .lumen-message-content hr:not(.lumen-message-content [data-slot] *):not([data-slot]) {
    margin: 1em 0;
    border: 0;
    border-top: 1px solid var(--gc-hair);
  }
  .lumen-message-caret {
    display: inline-block;
    width: 2px;
    height: 1.05em;
    margin-inline-start: 2px;
    vertical-align: -.17em;
    border-radius: calc(1px * var(--lumen-radius-k, 1));
    background: currentColor;
    opacity: .75;
    animation: lumen-message-caret 1.1s ease-in-out infinite;
  }
  .lumen-message-content:has([data-slot="streaming-text"])>.lumen-message-caret {
    display: none;
  }
  .lumen-message-typing {
    display: inline-flex;
    gap: 3px;
    padding: 8px 0;
  }
  .lumen-message-typing>i {
    width: 5px;
    height: 5px;
    border-radius: calc(1.5px * var(--lumen-radius-k, 1));
    background: var(--gc-cellOff);
    animation: lumen-message-typing 1.2s ease-in-out infinite;
  }
  .lumen-message-typing>i:nth-child(2) {
    animation-delay: .15s;
  }
  .lumen-message-typing>i:nth-child(3) {
    animation-delay: .3s;
  }
  .lumen-message-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 2px;
    min-height: 26px;
    margin-inline-start: -6px;
    transition: opacity min(0.18s, var(--gcp-cm)) ease;
  }
  .lumen-message[data-from="user"] .lumen-message-actions {
    margin-inline: 0 -6px;
  }
  .lumen-message-actions:empty {
    display: none;
  }
  .lumen-message .lumen-message-action {
    color: var(--gc-sec);
  }
  @media (hover:hover) {
    .lumen-message-content a:not(.lumen-message-content [data-slot] *):not([data-slot]):hover {
      text-decoration-color: currentColor;
    }
    .lumen-message[data-actions="hover"]:not([data-status="error"]) .lumen-message-actions {
      opacity: 0;
    }
    .lumen-message[data-actions="hover"]:is(:hover,:focus-within) .lumen-message-actions {
      opacity: 1;
    }
    .lumen-message .lumen-message-action:hover {
      color: var(--gc-ink);
    }
  }
  .lumen-message-glyph {
    width: 14px;
    height: 14px;
    fill: none;
    stroke: currentColor;
    stroke-width: 1.5;
    stroke-linecap: round;
    stroke-linejoin: round;
  }
  .lumen-message-failed {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding-inline-start: 6px;
    color: var(--gc-bad-ui);
    font: 500 12px/1 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
  }
  .lumen-message-failed>svg {
    width: 14px;
    height: 14px;
    fill: none;
    stroke: currentColor;
    stroke-width: 1.5;
    stroke-linecap: round;
  }
  @media (prefers-reduced-motion:reduce) {
    .lumen-message {
      animation: none;
    }
    .lumen-message-caret {
      animation: none;
    }
    .lumen-message-typing>i {
      animation: none;
      background: var(--gc-sec);
    }
  }
}
```

The lumen foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://beautiful-ui.dev/r/lumen-foundation.json.
