{
  "name": "ChatLog",
  "type": "pattern",
  "description": "A scrolling conversation log: author-gutter messages with term highlighting, semantic event banners with an optional action, a custom escape hatch, and stick-to-bottom auto-scroll",
  "export": "@gtmi/strix-react/atoms/chat-log",
  "path": "@gtmi/strix-react/atoms/chat-log",
  "figma": {
    "file": "zT3vGm4QlFEI2Jp9nxyFKI",
    "fileName": "Demo components library",
    "nodes": [
      {
        "node": "22:5672",
        "name": ".system-messages",
        "section": "Atoms"
      }
    ]
  },
  "props": [
    {
      "name": "entries",
      "type": "({ id?: string; type: 'message'; author: string; text: string; side?: 'start' | 'end'; authorColor?: 'default' | 'accent' | 'success' | 'error' } | { id?: string; type: 'event'; variant: 'info' | 'success' | 'warning' | 'error' | 'response'; text: ReactNode; icon?: ReactNode; action?: { label: string; onClick: () => void; variant?: 'inline' | 'button'; icon?: ReactNode } } | { id?: string; type: 'custom'; render: ReactNode })[]",
      "required": true,
      "description": "The log, oldest first. Keyed by entry.id when present, index otherwise"
    },
    {
      "name": "highlights",
      "type": "string[]",
      "default": "[]",
      "description": "Terms emphasised wherever they appear as whole words in message text. Regex-escaped, case-insensitive, never applied to event banners"
    },
    {
      "name": "autoScroll",
      "type": "boolean",
      "default": "true",
      "description": "Follow the newest entry, but only when the reader is already within 32px of the bottom"
    },
    {
      "name": "scrollbar",
      "type": "pill | hidden | none",
      "default": "pill",
      "description": "Scrollbar appearance: pill (Figma's 8px gray-700 bar), or hidden/none to suppress scrollbar display."
    },
    {
      "name": "scrollContainerRef",
      "type": "RefObject<HTMLElement | null>",
      "description": "Optional ref to an external scroll container (e.g. ScrollArea viewport), if ChatLog is wrapped and should autoscroll that container."
    },
    {
      "name": "emptyState",
      "type": "ReactNode",
      "description": "Replaces the default EmptyState entirely when entries is empty"
    },
    {
      "name": "emptyTitle",
      "type": "string",
      "default": "'No messages yet'",
      "description": "Default empty-state headline"
    },
    {
      "name": "emptyBody",
      "type": "string",
      "default": "'Messages will appear here as the conversation goes on.'",
      "description": "Default empty-state body"
    },
    {
      "name": "variantLabels",
      "type": "Record<'info' | 'success' | 'warning' | 'error' | 'response', string>",
      "default": "{ info: 'Information:', success: 'Success:', warning: 'Warning:', error: 'Error:', response: 'Information:' }",
      "description": "Visually-hidden prefix naming an event's severity, so severity is never carried by colour alone"
    }
  ],
  "parts": [
    {
      "name": "chat-log",
      "description": "Root, the scroll container and the role=\"log\" live region, via data-slot"
    },
    {
      "name": "chat-log-entry",
      "description": "One entry. Carries data-type=message|event|custom, plus data-side on a message and data-variant on an event"
    },
    {
      "name": "chat-log-author",
      "description": "Message author name in the gutter"
    },
    {
      "name": "chat-log-text",
      "description": "Message body, into which highlights are injected"
    },
    {
      "name": "chat-log-highlight",
      "description": "One highlighted term"
    },
    {
      "name": "chat-log-event-icon",
      "description": "Event glyph, aria-hidden and decorative"
    },
    {
      "name": "chat-log-event-text",
      "description": "Event copy, prefixed by the visually-hidden variant label"
    },
    {
      "name": "chat-log-event-action",
      "description": "The event's boxed Button action, rendered only for action.variant 'button'. The default inline form is a button inside chat-log-event-text carrying the shared inline-link slot instead, which is what exempts it from the 44px floor under WCAG 2.5.5's Inline exception"
    },
    {
      "name": "chat-log-empty",
      "description": "Wrapper around the empty state, default or caller-supplied"
    }
  ],
  "ai_tags": [
    "chat",
    "log",
    "transcript",
    "conversation",
    "messages",
    "live-region",
    "auto-scroll",
    "highlight",
    "banner",
    "pattern"
  ],
  "since": "0.2.0",
  "source": "cr-web-demo packages/ui/src/atoms/transcript-section.tsx (entry type: packages/core/src/types/transcript.ts)",
  "tokens_used": [
    "--color-text-primary",
    "--color-text-accent-strong",
    "--color-text-success",
    "--color-text-error",
    "--color-surface-chat-banner",
    "--color-surface-chat-success",
    "--color-surface-warning",
    "--color-surface-chat-error",
    "--color-border-chat-info",
    "--color-border-chat-response",
    "--color-icon-chat-info",
    "--color-icon-chat-response",
    "--color-border-chat-success",
    "--color-border-warning",
    "--color-border-chat-error",
    "--spacing-gap-200",
    "--spacing-gap-400",
    "--spacing-padding-horizontal-med-sm",
    "--spacing-padding-vertical-sm",
    "--radius-xs",
    "--text-size-body-small",
    "--text-size-body-xsmall",
    "--color-text-on-chat-banner"
  ],
  "notes": "DOMAIN STRIPPED. The upstream union was welded to a voice-call product (`{type:'system', event:'call_start'|…}`, `{type:'tool', action:'requestContact'|…}`); it is generalised to three entry types with no product vocabulary — `message` (any `author` string), `event`, and `custom` for things outside the vocabulary. The `tool` entry's two hardcoded consent button-sets and its `responded` bookkeeping become one `action: { label, onClick }` and the caller's business. Every user-facing string is a prop with an English default; react-i18next is dropped.\n\nFIGMA'S BANNERS ARE LIGHT CHIPS ON A DARK PAGE, AND `error` IS THE EXCEPTION. Reading all six variants of `.system-messages` (22:5672) together: error = red-600 fill / red-850 rule / white text; informative = white / accesible-red-dark-theme / ink; response and profile = white / blue-600 / ink; action and call-ended = blue-100 / blue-400 / ink. This atom had generalised from `error` and painted a translucent dark tint under white ink for all four severities. Figma's own values measure better (ink on white 19.37:1, ink on blue-100 14.69:1) — the substitutions were the light-page argument rule 3 voided. `warning` alone keeps the inherited tint: Figma draws no warning member, so it is a rule-2 gap with nothing to revert to, and it is the sole entry in the contrast test's BY-NAME unmeasurable list.\n\nINK IS PER VARIANT, NEVER ON THE BASE LIST. `cva` CONCATENATES rather than merging, so a base ink plus a per-variant ink emits both classes and stylesheet order decides the winner — the trap SelectableTile's shared `selected` class fell into. A guard asserting exactly one ink per variant makes that impossible to reintroduce.\n\nTHE GLYPH INK IS FIGMA'S TOO, AND IT IS A SEPARATE CVA. Figma paints the mark separately from the copy: accesible-red-dark-theme on `informative`, blue-600 on `response`/`profile`, while the copy is ink on all four light banners. Both clear 1.4.11's 3:1 on white (#ef223a 4.23:1, #1953b9 7.05:1), so `--color-icon-chat-info` and `--color-icon-chat-response` are minted. They live in `chatLogEventIconStyles`, not in `chatLogEventStyles`, because a second ink there would defeat the one-ink-per-variant guard. Toast's per-variant glyphs are reused as defaults but NOT its icon inks — `--color-icon-warning` is 2.61:1 on the light page.\n\nTWO LOGGED CONFLICTS, per rule 1 and docs/FIGMA-EXCEPTIONS.md: red-850 on red-600 is 2.29:1 and blue-400 on blue-100 is 2.73:1, both under 1.4.11's 3:1. Each marks severity on a chip already 14-19:1 clear of the page rather than bounding a control, and the glyph plus the hidden variant name carry the meaning.\n\nERROR PERCEIVABILITY rests on a visually-hidden word from `variantLabels`, not on the glyph: `icon` is a caller override, so a consumer could replace it with something whose label says nothing about severity and silently make the error colour-only. The glyph stays `aria-hidden`.\n\nAUTO-SCROLL IS CONDITIONAL. Upstream did `el.scrollTop = el.scrollHeight` unconditionally on every entry, yanking the viewport away from anyone reading history. This tracks a stick-to-bottom flag from the container's own `onScroll` (within 32px of the bottom) and only follows when the reader was already there; it starts `true` so a pre-populated log opens at the newest entry. The follow is smooth normally and a plain `scrollTop` assignment under `prefers-reduced-motion: reduce` (WCAG 2.3.3) — the scroll is the only motion this component produces. `matchMedia` and `scrollTo` are feature-guarded because jsdom implements neither.\n\nTHE SCROLL ROOT IS A PLAIN `div`, AND THE BAR IS FIGMA'S. One element must be simultaneously the forwarded ref, the `role=\"log\"` live region, the `onScroll` source, and the thing whose scroll metrics are read and written, so a Root/Viewport split would leave the live region on a box that never moves. Strix ships no `ScrollArea` at all — phase 3b planned one, it never landed, and the docblock here argued against it as though it existed. The plain `div` used to cost the bar's appearance; it now wears `scrollbar-pill`, Figma's 8px `gray-700` thumb at `radius/50` (22:27958) — see docs/FIGMA-EXCEPTIONS.md. `scrollbar-hidden` is the opt-out and `cn()` resolves the pair.\n\n`ChatLogEvent` IS EXPORTED, because a consumer rebuilt it. They imported `chatLogEventStyles`/`chatLogTextStyles` and copied the internal markup to get a banner with their own action, and the copy lost the visually-hidden severity word, the per-variant glyph ink and the inline-vs-boxed action split — everything that makes the banner correct rather than merely similar. `variantLabel` is the one prop the standalone form adds, since there is no parent `variantLabels` map to read.\n\nLIVE REGION: `role=\"log\"` with `aria-live=\"polite\"` stated explicitly (survives a role override) and `aria-relevant=\"additions\"` rather than the role's default `additions text`, because a streamed reply would otherwise re-announce the same growing sentence every frame.\n\nHIGHLIGHTING is ported faithfully, including the capture-group split parity trick — `String.prototype.split` with a single capture group interleaves the matches at odd indices, which holds only because the lookarounds are non-capturing and there is exactly one group. Terms are regex-escaped and `(?<!\\w)…(?!\\w)` keeps `cat` out of `catalogue`. Message text only, never event copy.\n\nGEOMETRY: `border-l-4 rounded-xs px-padding-horizontal-med-sm py-padding-vertical-sm` — a 4px LEFT rule on all six variants and no box, which REST cannot report and Dev Mode confirmed. A previous pass changed it to a full 1px outline to \"match Figma\" and pinned the mistake with a `not.toContain('border-l-4')` assertion; the test now asserts the rule AND the absence of a box. The author gutter's two hardcoded widths become `min-w-14` so a longer name is not truncated.\n\nCONTRAST TESTS read every token out of the component's own cva output rather than naming it, so an assertion cannot outlive the component's use of the token. Both the `disabledOnly` and `unmeasurable` exclusion lists are asserted by name, not by count, so an exclusion cannot grow silently.\n\nTHE ACTION DEFAULTS TO FIGMA'S INLINE FORM. `.system-messages` gives exactly one banner an action and draws it as an underlined run of the banner's own sentence — \"Connection error. \" then \"Try again\" then the cycle glyph, all at the banner's 12/18 Semibold in the banner's own ink. The boxed `Button` this atom shipped is the upstream consent reply, which Figma never draws, so under rule 1 the default is the drawn form and the box is `action.variant: 'button'`. `text` widened from `string` to `ReactNode` for the same reason: one sentence with mixed emphasis is not a string. The hidden variant label is still prepended by the component, so severity does not depend on what a caller passes. The inline control carries `data-slot=\"inline-link\"`, the shared slot that holds WCAG 2.5.5's Inline exemption in `audit-touch.mjs` — a 44px floor would have to grow the 34px banner, and Figma's geometry wins. `action.icon` renders INSIDE the control rather than beside it: the copy is `flex-1`, so a sibling would be pushed to the far edge instead of packed against the label as Figma packs it, and one target is better than two adjacent ones.\n\nFOUR GLYPHS ARE FIGMA'S OWN VECTORS. `Etc./promo-abuse`, `Symbols/arrow--cycle`, `Users/user--warning` and `Users/user-profile` exist in no upstream icon source — Paste 13.1.0 was searched at its pinned ref and lucide has no equivalent shape — so they are converted from Dev Mode exports by `scripts/port-figma-icons.mjs`. This closes the two recorded `userGlyphs` gaps and corrects a wrong reading: the error mark had been called `Symbols/warning--alt` and mapped to `AlertTriangleIcon`, but it is a hairline triangle outline with a separate dot, not a filled-bang triangle."
}
