{
  "name": "ChannelMessage",
  "type": "pattern",
  "description": "One message in a channel conversation: a bubble whose fill, radius, padding and ink follow the channel and the author. `rcs` is a richer card with an image slot and action rows; `email` paints no bubble at all.",
  "export": "@gtmi/strix-react/molecules/channel-message",
  "path": "@gtmi/strix-react/molecules/channel-message",
  "props": [
    {
      "name": "children",
      "type": "ReactNode",
      "description": "The message body. 12/18 in every channel, which is what Figma's every text layer is."
    },
    {
      "name": "channel",
      "type": "messaging | rcs | whatsapp | web-chat | email",
      "default": "messaging",
      "description": "The carrying channel. `whatsapp` and `web-chat` render `messaging`'s bubble exactly and have no Figma member of their own; they exist so a composition names one channel for both this and PhoneFrame."
    },
    {
      "name": "author",
      "type": "business | user",
      "default": "business",
      "description": "Figma's `Type` axis. Drives the alignment as well as the fill, because the two fills are only 1.94:1 apart in dark and hue alone would fail WCAG 1.4.1."
    },
    {
      "name": "media",
      "type": "ReactNode",
      "description": "The RCS card's image, from Figma's `Card image` asset set. Rendered only when channel=\"rcs\"."
    },
    {
      "name": "actions",
      "type": "ReactNode[]",
      "description": "The RCS card's action rows, one node per row, separated by Figma's single hairline. Each node is the caller's own control. Rendered only when channel=\"rcs\"."
    },
    {
      "name": "timestamp",
      "type": "ReactNode",
      "description": "Optional timestamp to display under the message bubble."
    },
    {
      "name": "device",
      "type": "ReactNode",
      "description": "Optional device or channel identifier that sent the message (e.g. \"SMS\", \"Chat\", \"WhatsApp\")."
    },
    {
      "name": "meta",
      "type": "ReactNode",
      "description": "Custom meta node rendered under the message bubble. When provided, takes precedence over timestamp and device."
    },
    {
      "name": "className",
      "type": "string",
      "description": "Merged through cn() on the root, so a caller's utility wins."
    }
  ],
  "parts": [
    {
      "name": "channel-message",
      "description": "Root, via data-slot. Alignment only, no colour. Also carries data-channel and data-author."
    },
    {
      "name": "channel-message-container",
      "description": "The column wrapping the bubble and optional meta line, aligned with the author side."
    },
    {
      "name": "channel-message-bubble",
      "description": "The bubble: fill, radius, padding, gap"
    },
    {
      "name": "channel-message-meta",
      "description": "Optional meta line under the bubble (timestamp and/or device)."
    },
    {
      "name": "channel-message-text",
      "description": "The body, in every channel except rcs"
    },
    {
      "name": "channel-message-media",
      "description": "rcs only: the Card image slot"
    },
    {
      "name": "channel-message-body",
      "description": "rcs only: the 12/8-padded body frame"
    },
    {
      "name": "channel-message-actions",
      "description": "rcs only: the action row group"
    },
    {
      "name": "channel-message-action",
      "description": "rcs only: one 34px action row"
    }
  ],
  "ai_tags": [
    "channel-message",
    "message",
    "bubble",
    "chat",
    "sms",
    "rcs",
    "whatsapp",
    "email",
    "conversation",
    "pattern"
  ],
  "since": "0.1.2",
  "figma": {
    "file": "85sD2HI8wksP9Y7uDmJ9DK",
    "fileName": "Multi product demo",
    "node": "1027:11104",
    "componentSet": "Message",
    "variants": [
      "Type=Business, Channel=Messaging — 52px, paddingHorizontal 8, paddingVertical 8, gap 8, radius 10 with the bottom-right corner square, fill color/gray/gray-800, ink color/brand/white",
      "Type=User, Channel=Messaging — 52px, same geometry, fill color/gray/gray-50, ink color/brand/ink, bottom-left corner square",
      "Type=Business, Channel=Email — 180px, gap 16, fill none, ink color/brand/white",
      "Type=User, Channel=Email — 214px, gap 16, fill none, ink color/gray/gray-300",
      "Type=Business, Channel=RCS — 233px, radius 10, fill color/gray/gray-800, ink color/brand/white; composes Card image 77 + body 88 + two 34px link rows and one 0.5px Line 602 in color/gray/gray-700"
    ]
  },
  "tokens_used": [
    "--color-surface-field",
    "--color-surface-bubble-user",
    "--color-text-primary",
    "--color-text-secondary",
    "--color-text-on-bubble-user",
    "--color-text-tertiary",
    "--color-border-control",
    "--radius-large",
    "--spacing-gap-050",
    "--spacing-gap-200",
    "--spacing-gap-300",
    "--spacing-padding-horizontal-sm",
    "--spacing-padding-horizontal-med-sm",
    "--spacing-padding-vertical-sm",
    "--spacing-borders-thin",
    "--text-size-figma-12",
    "--leading-height-figma-12"
  ],
  "notes": "FIVE CHANNEL MEMBERS WHERE FIGMA VARIES ACROSS THREE, AND NOTHING IS MISSING. `Message` (1027:11104) is `Type=Business|User` x `Channel=Messaging|Email|RCS`, five members in all (there is no `Type=User, Channel=RCS`). `MessageChannel` adds `whatsapp` and `web-chat`, which reuse `messaging`'s bubble byte for byte and differ only in the chrome around the list — which is PhoneFrame's business, not this component's. The extra members exist so a composition names one channel once and passes it to both, rather than mapping WhatsApp to \"messaging\" at every call site. Stated here so the next reader does not go hunting for two Figma variants that were never drawn. `MessageChannel` and `MessageAuthor` are defined in this file and imported by PhoneFrame, ChannelAvatar and Track C's EmailThread. Note `MessageChannel` shadows the DOM's own `MessageChannel` interface inside any importing module; that is the plan's chosen name and a design system has no use for the postMessage type.\n\nGEOMETRY CHECKED AGAINST THE VARIANT HEIGHTS, NOT THE SET FRAME. Messaging: 8 + 36 (two 18px lines) + 8 = 52, exactly what Figma reports. RCS: 77 (Card image) + 88 (body) + 34 + 34 (link rows) = 233, exactly the variant height with nothing left over — so the layers are FLUSH and Figma's `gap: -189` on that variant is an absolute-position artefact, not a gap. That reading is the difference between a card that stacks correctly and one with 189px of negative overlap. `gap-0` and not `gap-none`: `--spacing-none` is a declared token but no compiled `.gap-none` rule exists in the built stylesheet, and a class that generates no CSS fails silently (ScenarioCard found the same). Email: gap 16, no padding, no radius, no fill — the 180/214px variant heights are content, not a box.\n\nDEV MODE SETTLES THE RADIUS, AND ONE CORNER IS SQUARE. `radius/large` (10) is bound on the RCS root alone, and this previously concluded that the messaging root's radius is therefore neither 0 nor 10 on the evidence, so `rounded-large` was carried across the whole family because \"a square-cornered bubble beside a 10px one would be inventing a second step\". It is not a second step. Every bubble is 10px on THREE corners and square on the FOURTH, and which one depends on the speaker: `rounded-br-none` for `business`, `rounded-bl-none` for `user`. That is the tail, the device every chat UI uses to point a bubble at its sender, and a blanket `rounded-large` removed it. The inference was correctly flagged as an inference; what it got wrong was reading absence-from-the-extraction as absence-from-the-design. REST reports a radius binding nested under `rectangleCornerRadii` and an unbound literal is simply missing (docs/AGENTS.md §8, points 4 and 7), which is why the payload looked silent — the generated code is explicit. RCS keeps all four corners round: it is a card, and the file has no user-authored RCS member for a tail to point from. Verified from decoded pixels: the user bubble's bottom-left corner and the business bubble's bottom-right are both page-coloured at the exact corner pixel while the other three are the fill.\n\nTHE FILLS ARE FIGMA'S NOW, AND RULE 3 IS WHAT CHANGED. This paragraph used to open \"EVERY FILL AND EVERY INK IS A SUBSTITUTION\". Figma paints the business bubble `color/gray/gray-800` #232b45 with white text (13.97:1) and the user bubble `color/gray/gray-50` #f3f4f7 with `color/brand/ink` #000d25 (17.61:1) — a dark chip beside a near-white one, which is the whole device for telling the two speakers apart. Both were refused because \"ported literally, #f3f4f7 is 1.10:1 against a white page and vanishes, and #232b45 needs a light ink that --color-text-primary does not supply in light mode\". Under rule 3 there is no white page, so neither refusal survives. The business substitute was --color-surface-neutral-semi-medium (#232e47 dark) — three bytes from Figma's own #232b45, which is what made it look harmless; gray-800 is already minted as --color-surface-field for the field atoms that share the literal. The user substitute was --color-surface-accent-extra-strong (#1152c2), a BLUE: not a lighter version of Figma's near-white chip but a different idea, and the plan's claim that \"#1866ee is EXACTLY color/blue/blue-500, and the outgoing bubble is the one place that value belongs\" was reasoning from a variable the atom declares and binds to no fill in any variant. A variable an atom declares but never binds is not evidence about where it belongs.\n\nTHE INK MOVED WITH THE FILL, WHICH IS THE HALF THAT IS EASY TO MISS. The user bubble's ink was --color-text-inverse, i.e. WHITE, chosen to sit on the blue. White on Figma's near-white chip is 1.10:1 — reverting only the fill would have left an invisible message, so --color-text-on-bubble-user is minted at `color/brand/ink` for 17.61:1. Same literal as --color-text-on-tooltip, the other near-white surface in the file; the value is not new, the pairing is. Email is unchanged: business ink --color-text-primary (17.85 on the page), user ink --color-text-tertiary (8.17) replacing `color/gray/gray-300` #9aa0b4, the same InfoRow substitution, still recorded. --color-surface-primary was measured and rejected as a fill: it is rgba(255, 255, 255, 0.08) in dark, and a translucent fill's composite depends on its backdrop, so it cannot carry a fixed ink. A test asserts every chosen fill is an opaque hex, and the test that used to assert `not.toContain('#f3f4f7')` — guarding the substitution as though the Figma value were the regression — is now inverted to pin both hexes, because a NAME is not enough when --color-surface-accent-extra-strong reads like a plausible user bubble and resolves to a blue.\n\nWCAG 1.4.1: THE 1.94:1 WORRY WAS A PROPERTY OF THE SUBSTITUTES, NOT OF FIGMA. This used to record the two messaging fills as 1.94:1 apart in dark and conclude that hue alone cannot distinguish the speakers, which is why `author` drives the alignment as well as the fill. Figma's own fills are 12.70:1 apart — a dark chip beside a near-white one is exactly the colour distinction the substitutes had flattened. The alignment still ships, and so does the tail, because Figma draws both; they are simply no longer carrying 1.4.1 on their own. The characterisation test now asserts 12.70 rather than \"less than 3\", so a re-substitution that flattens the pair trips it.\n\nNO BUBBLE BORDER, ON PURPOSE. On the page Figma draws, the business fill measures 1.39:1 against it and the user fill 17.61:1, so the business edge is a weak one. WCAG 1.4.11 asks 3:1 of the boundary of a CONTROL or of a graphical object conveying information; a message bubble is neither, and its edge carries nothing the text and the side do not. ToolCall made the same call about its pill with the same numbers. The RCS card's internal hairline is different and IS held to 3:1, because it is the only thing separating two adjacent controls.\n\nTHE RCS HAIRLINE: 0.5PX AND #38425e ARE BOTH DECLINED. Figma's `Line 602` is `color/gray/gray-700` #38425e at borderWidth 0.5. Width snaps to 1px (`border-t`, --spacing-borders-thin): there is no 0.5px token and a sub-pixel rule renders inconsistently across device pixel ratios anyway. The colour is not ported — --color-border-neutral-medium, the nearest token to #38425e, is 1.42:1 light / 2.83:1 dark against this card's own fill and would be a rule visible in one theme only. --color-border-control is 4.38:1 / 4.20:1 against the card. Figma has exactly one Line 602, between the two link rows (the four layer heights already sum to 233 with nothing left for a rule above the first row), so it is drawn on every action except the first, and a test asserts that.\n\nRCS IS AUTHOR-INVARIANT. Figma has no `Type=User, Channel=RCS` member, so the card keeps the business fill and ink whichever author is passed; only the alignment follows the author. Silence is not disagreement (docs/AGENTS.md §8), so a user-authored RCS card is not invented, merely not repainted.\n\nACTIONS ARE NODES, NOT LABELS. Figma's action rows contain a layer called `Link`, and a link in that position needs an href, a handler or a form — none of which a presentational bubble can invent without guessing wrong. `actions: ReactNode[]` positions the caller's own controls and owns only the row frame and the hairline. `media` is a slot for the same reason ScenarioCard's is: `Card image` (1536:22843) is an 18-variant asset set keyed by subject, an asset library rather than something to build, and its 77px height belongs to the asset. Both are rendered ONLY on channel=\"rcs\", because Figma draws them nowhere else and rendering them elsewhere would invent a variant; a test asserts they are dropped on `messaging`.\n\nTYPOGRAPHY. Every text layer in the atom is 12/18 at `font/weight/Book`, mapped by variable NAME to 400. Figma reports 325, which is Whitney SSm's embedded value and provenance only — `font-weight: 325` is not Book to a browser. The pair is --text-size-figma-12 / --leading-height-figma-12 and not the `body-xs` role, whose leading is 16px: 16 + 8 + 8 is 32 where Figma's messaging variant is 52, and only 18px leading produces that. Badge's `small` size and ToolCall's pill choose the same pair for the same reason. Written out as literal utilities in this component's own cva rather than reached through <Typography variant>, because the ink varies per combination and Typography would add a second box to say one thing.\n\nCONTRAST DERIVATION: PER COMBINATION AND PER ROLE, EACH ROLE FROM ITS OWN STYLE FUNCTION. Seven style functions; the ten (channel, author) combinations are enumerated from the two exported const arrays, and for each one the fill comes from channelMessageBubbleStyles and the ink from channelMessageTextStyles, separately, so the ink is measured against the fill it is ACTUALLY seen on and email's ink falls back to the page because email has no fill. No list is ever collected and split by token-name prefix — that split is what made InfoSection's first draft vacuous, since a token matching neither --color-text* nor --color-icon* fell out of both groups unmeasured and it passed with the ink mutated to text-highlight at 1.84:1. Count guards on every derived set (one ink per combination; one fill per bubble and zero for email; zero colour of any kind on the root; zero fill on the RCS body and action frames), because an unmappable utility drops out silently and every pairing would then pass by measuring one colour fewer.\n\nMUTATION-TESTED SIX WAYS, EVERY ONE QUOTING A REAL RATIO OR A REAL UNMEASURABLE. (1) business messaging ink -> text-highlight: \"messaging/business: --color-highlight on --color-surface-neutral-semi-medium (light): #3acefa on #f1f5ff = 1.69:1, wanted 4.5:1\". (2) user messaging ink -> text-text-tertiary: \"#677389 on #1866ee = 1.05:1, wanted 4.5:1\" and \"#a5b0c4 on #1152c2 = 3.19:1, wanted 4.5:1\". (3) email user ink -> text-highlight: \"#3acefa on #ffffff = 1.84:1, wanted 4.5:1\". (4) RCS card fill -> bg-surface-primary: three tests fail, five unmeasurables, every one reporting \"--color-surface-primary=rgba(255, 255, 255, 0.08)\" in dark, including the divider check. (5) divider -> border-border-neutral-medium: \"#c5d0e3 on #f1f5ff = 1.42:1, wanted 3:1\" and \"#677389 on #232e47 = 2.83:1, wanted 3:1\". (6) user bubble fill set equal to the business fill: the separation test trips. RE-MUTATED AFTER THE REVERT, three ways: user fill back to bg-surface-accent-extra-strong fails 3 tests (the hex pin, the separation figure, and the 4.5:1 pairing); dropping both `rounded-*-none` classes fails the tail test; user ink back to text-text-inverse fails 2 (the ink hex pin and #ffffff on #f3f4f7 = 1.10:1, wanted 4.5:1).\n\nNO TRANSITION AND NO SCROLL. This component animates nothing, so there is no motion-reduce: utility to add — the plan's reduced-motion rule has no surface here. It is also a single message and never a list, so it does not scroll and does not auto-scroll: stick-to-bottom is ChatLog's, already solved there, and a message list built out of these should compose that rather than reimplement it.\n\nEVERY CLASS IS A LITERAL, INCLUDING THE SIX COMPOUND VARIANTS THAT DUPLICATE EACH OTHER. Tailwind extracts class names by scanning source text, so a fill assembled from the two variant values would generate no CSS and the bubble would render transparent while the rendered className looked correct. The duplication is the point."
}
