{
  "name": "ChannelAvatar",
  "type": "pattern",
  "description": "The sender identity above a channel conversation: a rounded logo tile, a verified badge and a company-name pill. Two layouts — a column for RCS & SMS, a row for WhatsApp.",
  "export": "@gtmi/strix-react/atoms/channel-avatar",
  "path": "@gtmi/strix-react/atoms/channel-avatar",
  "props": [
    {
      "name": "name",
      "type": "ReactNode",
      "description": "The company name, in the pill. 12/18 Semibold."
    },
    {
      "name": "channel",
      "type": "messaging | rcs | whatsapp | web-chat",
      "default": "messaging",
      "description": "`messaging`, `rcs` and `web-chat` all take Figma's single `RCS & SMS` treatment; `whatsapp` is its own row layout. `email` is deliberately not a member — Figma's Channel=Email variant is a subject line, not an avatar."
    },
    {
      "name": "logo",
      "type": "ReactNode",
      "description": "The company's logo, 16px. A slot, not a component: Figma's is the Twilio product mark and a design system cannot supply anyone else's. Pass it aria-hidden — the name beside it is the accessible text."
    },
    {
      "name": "verified",
      "type": "boolean",
      "default": "true",
      "description": "Whether to draw the verified badge. Figma has no unverified member; the prop exists because an unverified sender is real."
    },
    {
      "name": "badge",
      "type": "ReactNode",
      "default": "<ShieldCheckIcon />",
      "description": "The badge glyph, nearest to Figma's `verified_user`."
    },
    {
      "name": "verifiedLabel",
      "type": "string",
      "default": "Verified business",
      "description": "The badge's text alternative. Visually hidden and always rendered alongside the glyph, because a decorative check conveys \"verified\" to sighted users only."
    },
    {
      "name": "className",
      "type": "string",
      "description": "Merged through cn() on the root, so a caller's utility wins."
    }
  ],
  "parts": [
    {
      "name": "channel-avatar",
      "description": "Root, via data-slot. Layout only, no colour. Also carries data-channel."
    },
    {
      "name": "channel-avatar-tile",
      "description": "The rounded logo tile: 32px stacked / 24px WhatsApp."
    },
    {
      "name": "channel-avatar-glyph",
      "description": "The 16px logo slot inside the tile."
    },
    {
      "name": "channel-avatar-badge",
      "description": "The 12px verified glyph. aria-hidden, paired with a visually hidden label."
    },
    {
      "name": "channel-avatar-pill",
      "description": "The 18px name pill."
    },
    {
      "name": "channel-avatar-name",
      "description": "The 12/18 Semibold company name."
    }
  ],
  "ai_tags": [
    "channel-avatar",
    "avatar",
    "sender",
    "identity",
    "verified",
    "badge",
    "company",
    "rcs",
    "sms",
    "whatsapp",
    "pattern"
  ],
  "since": "0.1.2",
  "figma": {
    "file": "85sD2HI8wksP9Y7uDmJ9DK",
    "fileName": "Multi product demo",
    "node": "1033:14525",
    "componentSet": "Avatar",
    "variants": [
      "Channel=RCS & SMS — 64px root, gap 4, no paddingVertical; Icon 32 radius 50 fill color/gray/gray-850; Icon / Product Logos/twilio_autopilot 16, path-fill color/brand/accesible-red-dark-theme; Icon / verified_user 11 (unbound literals #4287f5 on #d9d9d9 with a #ffffff ring at borderWidth 0.49); Company 18 radius 50 paddingHorizontal 8 gap 4 fill color/brand/ink; Company / Owl support 12/18 fontWeight 375 ink color/brand/white",
      "Channel=WhatsApp — 40px root, gap 8, paddingVertical 8; Icon 24 radius 50; logo 16; Company 18 as above; Verified Icon 12, Vector fill color/blue/blue-700",
      "Channel=Email — 40px root; NO Icon, NO Company, NO verified badge. Only a `gradient` frame (32, gap 4) and `gradient / Subject line` 14/20 ink color/brand/white. NOT AN AVATAR — declined, see notes."
    ]
  },
  "tokens_used": [
    "--color-surface-identity-chip",
    "--color-surface-avatar-pill",
    "--color-text-inverse",
    "--color-icon-primary",
    "--color-icon-accent",
    "--radius-2x-large",
    "--spacing-gap-100",
    "--spacing-gap-200",
    "--spacing-padding-vertical-sm",
    "--spacing-padding-horizontal-sm",
    "--text-size-figma-12",
    "--leading-height-figma-12"
  ],
  "notes": "ONE FIGMA VARIANT COVERING TWO CHANNELS, AGAINST A FIVE-MEMBER TYPE, AND THE MISMATCH RUNS BOTH WAYS. `Avatar` is keyed Channel=RCS & SMS | WhatsApp | Email — three members against MessageChannel's five. (1) `RCS & SMS` is ONE variant covering TWO channels; in Strix's vocabulary that is `rcs` plus `messaging`, `messaging` being the member ChannelMessage already uses for SMS. So one treatment maps to two members and NO VARIANT IS MISSING — stated here because the next reader will otherwise hunt for a separate SMS avatar. (2) `web-chat` has no Figma member at all and takes the same treatment, because it is the same list chrome; that is a fourth member on a three-member set and is recorded as an extension rather than as evidence. (3) `email` is EXCLUDED, and the prop type is `Exclude<MessageChannel, 'email'>` rather than a hand-written list, so a channel added to MessageChannel becomes a type error here instead of a silently unhandled variant. A test asserts the member set equals MessageChannel minus email, and a second asserts the three RCS & SMS channels emit byte-identical class strings — if they differed, one of them would be an invention.\n\nWHY EMAIL IS DECLINED, WITH THE PAYLOAD AS EVIDENCE. `Channel=Email`'s only layers are a `gradient` frame (32px, gap 4) and a 14/20 `Subject line`. It has NO Icon, NO Company pill and NO verified badge — none of the three things this component composes. It is an email header, not a sender identity, and Figma's `Email` (1152:33529) is a 1332x896 thread over ten Device x #-of-emails variants with reply nesting, which is EmailThread's component (Track C). Building a subject line here to satisfy an enum would put another track's component in this directory, and is the same mistake as forcing one component to serve two treatments — how the --color-*-button-secondary-* roles ended up producing a 1.1:1 invisible fill.\n\nBASE UI'S `avatar` WAS CHECKED AND DECLINED, BY RENDERING IT RATHER THAN BY READING ABOUT IT. @base-ui/react/avatar exists and is unused by Strix, so it was probed — the otp-field lesson, where sound reasoning reached a wrong conclusion because the primitive was never rendered. Four things were only visible from the DOM: Avatar.Root emits a bare <span> with NO ROLE (`<span aria-label=\"Owl Support\"><span>OS</span></span>`); Avatar.Fallback emits another plain <span> and renders IMMEDIATELY with no delay; Avatar.Image with a valid `src` rendered NOTHING AT ALL in jsdom, because it is gated on the image's load event which jsdom never fires — so any test of it would silently assert the fallback path only; and an aria-label on an unroled <span> names nothing, so the primitive supplies the WRONG accessibility rather than none. Its entire value is remote-image load state plus an initials fallback, and this atom has neither: the tile holds a local brand glyph and there is no image URL or initials anywhere in the payload. Unlike otp-field — which solved real problems (roving tabindex, paste, autofill) that hand-rolling got wrong — there is no behaviour here to make headless. It remains the right primitive the day a photo avatar with a fallback is wanted, and that is recorded rather than left to be rediscovered.\n\nTWO LAYOUTS, DECIDED BY THE REPORTED HEIGHTS AND NOT BY THE LAYER LIST. RCS & SMS is a COLUMN: 32 (Icon) + 4 (gap) + 18 (Company) = 54. WhatsApp is a ROW: paddingVertical 8 + 24 (Icon) + 8 = 40, exactly its reported height — which is what rules out reading it as a column too, since 24 + 8 + 18 is 50. Their badges also sit in different places, and both placements are kept because where the badge sits IS the visible difference between the two identity blocks: Figma nests the RCS badge inside Icon (`Icon / verified_user`, 11px) and has WhatsApp's as a top-level row sibling (`Verified Icon`, 12px).\n\nTHE 64PX FRAME HAS 10PX THIS PORT CANNOT ACCOUNT FOR, AND IT IS NOT INVENTED. The RCS column's layers sum to 54 inside a 64px root. No layer in the payload explains the remainder; the root declares paddingVertical on the WhatsApp variant ONLY. 10px has no spacing token, no-arbitrary-tailwind rightly rejects py-[5px], and snapping to 8 or 12 would make the column 62 or 66 — neither of which is 64 either. So no padding is added and the discrepancy is recorded as unexplained rather than papered over with a number that looks derived and is not.\n\nTHE BADGE SNAPS TO 12PX IN BOTH TREATMENTS. Figma draws 11 on RCS & SMS and 12 on WhatsApp. 11 has no spacing token and its nearest neighbour is the 12 the other variant already uses, so snapping UP costs one pixel and keeps ONE badge size rather than inventing a second step in the scale. A test asserts both channels emit size-3.\n\nTYPOGRAPHY: 375 IS MAPPED BY NAME, NOT BY NUMBER. Figma reports fontWeight 375 on `Company / Owl support`, and `font/weight/Semibold` is the only weight variable this atom declares — so the mapping is Semibold -> 600. 375 is Whitney SSm's embedded value and provenance only; `font-weight: 375` is not Semibold to a browser. The pair is --text-size-figma-12 / --leading-height-figma-12 rather than the body-xs role, whose leading is 16px where the Company layer reports 18 — and 18 is the whole height of that layer, which is why the pill carries no vertical padding: padding there would make it 26 and break the column's 54px sum. A test asserts the pill emits no py-* utility at all.\n\nTHREE COLOUR SUBSTITUTIONS, EACH WITH ITS OWN REASON, ALL MEASURED IN BOTH THEMES. (1) THE TILE: Figma paints Icon `color/gray/gray-850` #191f36, a near-black chip drawn for a #000d25 page, which in light mode is a dark hole. --color-surface-neutral-semi-medium (opaque #f1f5ff / #232e47) is the same fill ChannelMessage uses for its business bubble, so the identity block and the messages below it agree. (2) THE PRODUCT GLYPH: Figma fills it `color/brand/accesible-red-dark-theme` #ef223a, and that is refused on MEANING rather than on contrast — it is Twilio's own logo colour, and hardcoding a red fill would repaint every other company's logo a caller passes in. `logo` is a slot for exactly the reason ChannelMessage's `media` is, and the wrapper's --color-icon-primary is a default a caller's own fill overrides: 17.75:1 light / 13.52:1 dark against the tile. (3) THE VERIFIED BADGE: Figma's WhatsApp check is `color/blue/blue-700` #0e3e92, which measures 1.80:1 against Strix's dark --color-surface-base #0f172a; its RCS badge is unbound literals (#4287f5 on a #d9d9d9 bounding box with a #ffffff ring at borderWidth 0.49), and an unbound literal is a new palette entry rather than a re-point (docs/AGENTS.md §8, point 7). --color-icon-accent is #1866ee in light — EXACTLY `color/blue/blue-500`, the blue the file itself declares — and measures 4.61:1 / 7.40:1 on the tile and 5.03:1 / 9.78:1 on the page. The company pill keeps Figma's own pairing, which happens to survive: `color/brand/ink` #000d25 is exactly light's --color-surface-inverse, and --color-text-inverse on it is 19.37:1 / 13.52:1.\n\nTHRESHOLDS: 4.5 FOR THE NAME, 3 FOR THE GLYPH AND THE BADGE. The name is 12px small text, so WCAG 1.4.3's 4.5:1. The logo and the badge are graphical objects that convey information, so WCAG 1.4.11's 3:1. The badge is measured against BOTH the tile and the page, because it overlaps the tile's edge in the RCS treatment and sits wholly on the backdrop in the WhatsApp one — measuring only one of the two would leave the other unchecked.\n\nNO BORDER ON THE TILE OR THE PILL. Figma draws none, and silence is not disagreement. The tile measures 1.09:1 against a white page and the pill 1.32:1 against the dark one, so both are weak edges — but 1.4.11 asks 3:1 of the boundary of a CONTROL or of a graphical object conveying information, and neither is either: the glyph inside the tile clears 3:1 on its own and the pill's meaning is its text. Same call ChannelMessage made about its bubble, with the same numbers.\n\nTHE BADGE HAS A REAL TEXT ALTERNATIVE. The glyph is aria-hidden and paired with an always-rendered sr-only string, default \"Verified business\" and overridable, because a decorative check conveys \"verified\" to sighted users only. An unlabelled <svg> with no text alternative was one of the eight pattern ports' inherited bugs (docs/AGENTS.md §7) and is not repeated. Setting verified={false} removes the glyph AND the string, so the two can never disagree, and a test asserts both halves in both directions.\n\nCONTRAST DERIVATION: PER ROLE, EACH ROLE FROM ITS OWN SEPARATE STYLE FUNCTION. Six style functions — root, tile, glyph, badge, pill, name — and five derived sets, never one list 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 passed with the ink mutated to text-highlight at 1.84:1. Each ink is measured against THE FILL IT IS ACTUALLY SEEN ON: the name against the pill, the glyph against the tile, the badge against the tile and the page. Count guards on every set (exactly one fill per tile, one ink per glyph, one ink per badge per channel, one pill fill, one name ink, and zero colour of any kind on the root), because an unmappable utility drops out silently and every pairing would then pass by measuring one colour fewer — a mutation that simply DELETED the badge's ink was run and the guard caught it: \"expected [] to have a length of 1 but got +0\". The pairing assertions pass the joined failure list as the assertion message so a failure quotes the real ratios instead of `expected [ Array(1) ] to deeply equal []`.\n\nMUTATION-TESTED FIVE WAYS — AND THE FIRST ATTEMPT WAS ITSELF WRONG, WHICH IS WORTH RECORDING. Mutating the name's ink to `text-highlight` PASSED, and that is not a vacuous test: --color-highlight #3acefa is 1.84:1 on a WHITE page but roughly 10:1 on this pill's #000d25 ink fill, so on this surface it is a legitimately passing colour. `text-highlight` is the house mutation because every earlier case measured against a light page; used here it would have \"confirmed\" nothing. The surface decides the mutation, exactly as docs/AGENTS.md §8's \"name the surface, always\" says. The five that do bite: (1) name ink -> text-text-primary: \"--color-text-primary on --color-surface-inverse (light): #000d25 on #000d25 = 1.00:1, wanted 4.5:1\". (2) badge ink -> text-highlight: \"--color-highlight on --color-surface-neutral-semi-medium (light): #3acefa on #f1f5ff = 1.69:1, wanted 3:1\". (3) glyph ink -> text-border-neutral-soft: \"#e5eeff on #f1f5ff = 1.07:1, wanted 3:1\". (4) tile fill -> bg-surface-primary: three tests fail with unmeasurables reporting \"--color-surface-primary=rgba(255, 255, 255, 0.08)\" in dark, including the opaque-hex guard — a translucent fill's composite depends on its backdrop, so it cannot carry a fixed ink across the themes. (5) badge ink deleted: the count guard trips.\n\nNO MOTION. Nothing here transitions or animates, so there is no motion-reduce:transition-none to add — and a test asserts that over every style function, because the omission is only defensible while it stays true.\n\nEVERY CLASS IS A LITERAL, INCLUDING THE THREE IDENTICAL RCS & SMS VARIANT LISTS. Tailwind extracts class names by scanning source text, so a size or a fill assembled from a variant value would generate no CSS while the rendered className looked correct — the exact failure the aria-disabled: helper produced in Button. The duplication is the point.\n\nTWO SUBSTITUTIONS REVERTED, AND ONE OF THEM NO STATED-VALUE CHECK COULD HAVE FOUND. (1) THE TILE. Figma's `color/gray/gray-850` #191f36 was refused as \"a near-black chip drawn for a #000d25 page, which in light mode is a dark hole\", substituted with --color-surface-neutral-semi-medium because it matched ChannelMessage's business bubble. Rule 3 removed the light mode, and ChannelMessage moved to --color-surface-field when its bubbles were reverted, so the agreement being preserved had lapsed. 1.19:1 against the page is not a conflict: a tile is not a control boundary and the glyph on it is 16.27:1. (2) THE COMPANY PILL, which the binding believed was already Figma's: it recorded that the pill \"keeps Figma's own pairing, which happens to survive: color/brand/ink #000d25 is exactly light's --color-surface-inverse\". True of the LIGHT value — dark.css re-points that role to --palette-secondary-800 #232e47, and dark is the only theme, so what rendered was never Figma's ink. Same error Phone-container's border made: reading a role out of the wrong layer and calling a substitution a revert. A RATIO CHECK COULD NOT HAVE CAUGHT IT — the substitute measures 13.52:1 and passes everything. What caught it was decoding four channel-screen renders whose every stated colour had already been reverted and finding #232e47 still present. Figma's ink is 19.37:1.\n\nBOTH ARE NEW ROLES RATHER THAN RE-POINTS OF ROLES CARRYING THE SAME LITERAL. --color-surface-field-disabled already holds gray-850 and --color-surface-base already holds brand/ink; a disabled field is not an identity tile, and the page is not a pill drawn ON the page. One role per treatment is what keeps either free to move — and in the pill's case, pointing at the page role would make it invisible the day the page colour moves."
}
