{
  "name": "PhoneFrame",
  "type": "pattern",
  "description": "The text-channel device shell: a padded, optionally rounded, optionally bounded frame with header, content and footer slots. Figma's single `Style` axis is split into an independent channel skin and a conversation lifecycle state.",
  "export": "PhoneFrame",
  "path": "@gtmi/strix-react/organisms/phone-frame",
  "props": [
    {
      "name": "children",
      "type": "ReactNode",
      "description": "The messages, in the 24px-gapped content area. Compose ChatLog here if the list needs to scroll."
    },
    {
      "name": "channel",
      "type": "messaging | rcs | whatsapp | web-chat",
      "default": "messaging",
      "description": "The channel skin. `whatsapp` suppresses the frame's boundary because WhatsApp draws its own chrome; the other three are identical. `email` is deliberately excluded — Figma's Email is 1332px with reply threading and belongs to EmailThread."
    },
    {
      "name": "state",
      "type": "active | loading | ended",
      "default": "active",
      "description": "Where the conversation is in its life. `loading` draws no boundary. Separate from `channel` so that an ended WhatsApp conversation is representable, which Figma's single Style enum makes impossible."
    },
    {
      "name": "device",
      "type": "desktop | mobile",
      "default": "desktop",
      "description": "Figma's Device axis. `mobile` drops the 20px radius and halves the vertical padding to 16px."
    },
    {
      "name": "header",
      "type": "ReactNode",
      "description": "The identity block above the messages, i.e. ChannelAvatar. Omitted renders no frame, so it consumes no root gap."
    },
    {
      "name": "headerPlacement",
      "type": "static | overlay",
      "default": "static",
      "description": "How the header is positioned: static sits above content in normal flex flow; overlay pins the header over the top of the frame so scrolling messages slide smoothly underneath the avatar."
    },
    {
      "name": "footer",
      "type": "ReactNode",
      "description": "The composer below the messages, i.e. ChatInput. Omitted renders no frame."
    },
    {
      "name": "className",
      "type": "string",
      "description": "Merged through cn() on the root, so a caller's utility wins."
    }
  ],
  "parts": [
    {
      "name": "phone-frame",
      "description": "Root, via data-slot. Fill, radius, padding, gap and the boundary. Also carries data-channel, data-state and data-device."
    },
    {
      "name": "phone-frame-header",
      "description": "The identity slot. Rendered only when `header` is given."
    },
    {
      "name": "phone-frame-content",
      "description": "The 24px-gapped message area."
    },
    {
      "name": "phone-frame-footer",
      "description": "The composer slot. Rendered only when `footer` is given."
    }
  ],
  "ai_tags": [
    "phone-frame",
    "phone",
    "device",
    "shell",
    "channel",
    "sms",
    "rcs",
    "whatsapp",
    "web-chat",
    "conversation",
    "pattern"
  ],
  "since": "0.1.2",
  "figma": {
    "file": "zT3vGm4QlFEI2Jp9nxyFKI",
    "fileName": "Demo components library",
    "node": "22:7686",
    "componentSet": "Phone-container",
    "variants": [
      "Style=Default, Device=Default — 276x536, radius/extra-large on all four corners, paddingHorizontal 32, paddingVertical 32, gap 32, fill color/brand/ink, border color/blue/blue-700 1px all four sides, items-end",
      "Style=Default, Device=Mobile — 276x360, radius/extra-large on the TOP TWO corners only, paddingVertical 16, fill is a GRADIENT (180deg, color/brand/ink at 50% to rgba(0,13,37,0) at 100%) and NOT none, border color/blue/blue-700 1px on LEFT, RIGHT and TOP only",
      "Style=WhatsApp, Device=Default — hidden in the file; Dev Mode returns no code",
      "Style=WhatsApp, Device=Mobile — hidden in the file; Dev Mode returns no code",
      "Style=Loading, Device=Default — 276x536, radius 20, fill color/brand/ink, NO border, inset shadow 0px 4px 40px rgba(239,34,58,0.5), Content layer at opacity 20%",
      "Style=Loading, Device=Mobile — as Loading/Default at 360px with mobile padding",
      "Style=End, Device=Default — 276x536, radius 20, fill color/brand/ink, border color/gray/gray-700 1px, items-CENTER rather than items-end",
      "Style=End, Device=Mobile — as End/Default at 360px with mobile padding"
    ]
  },
  "tokens_used": [
    "--color-surface-base",
    "--color-border-phone-active",
    "--color-border-phone-ended",
    "--shadow-phone-loading",
    "--gradients-phone-mobile",
    "--radius-extra-large",
    "--spacing-gap-400",
    "--spacing-gap-500",
    "--spacing-padding-horizontal-xl",
    "--spacing-padding-vertical-xl",
    "--spacing-padding-vertical-md"
  ],
  "notes": "FIGMA'S `Style` AXIS IS TWO PROPS, AND THIS IS THE ONE REAL API DECISION HERE. `Style=Default|WhatsApp|Loading|End` mixes two different kinds of thing: `WhatsApp` is a CHANNEL SKIN while `Loading` and `End` are LIFECYCLE STATES. As one enum, `whatsapp` and `ended` are mutually exclusive and a WhatsApp conversation can never end — an API that looks fine until the first WhatsApp demo reaches its last message, at which point the only escapes are a fifth member (`WhatsAppEnd`, then `WhatsAppLoading`, and the enum is a cross product written by hand) or a caller reaching past the prop with className. Split into `channel` (typed as `Exclude<MessageChannel, 'email'>`, so it pairs with ChannelMessage and a channel added there is a type error here) and `state` ('active' | 'loading' | 'ended'), the four Figma members become a 4x3 grid governed by two rules read straight off the payload's borderWidth map — a boundary is drawn on Default and End and on neither WhatsApp nor Loading — which is to say: the WhatsApp skin suppresses the frame's chrome, and `loading` has not drawn one yet. WhatsApp wins where the two meet. That last rule is an INFERENCE FROM A SINGLE DATA POINT (Figma has exactly one borderless channel) and is recorded as one rather than presented as measured; what it is not is invention, because no third treatment is made up for a cell Figma left empty and every cell resolves to one of the two the file does draw. Tests assert each rule independently and assert the count of boundary tokens for all 24 combinations.\n\nBOTH BOUNDARY TINTS SHIP NOW, AS LOGGED CONFLICTS, AND THE REFUSAL CONTAINED THE EXACT WRONG NUMBER CLAUDE.md WARNS ABOUT. `Style=Default` draws `color/blue/blue-700` #0e3e92 and `Style=End` draws `color/gray/gray-700` #38425e. This section used to read: \"against Strix's dark --color-surface-base #0f172a those are 1.80:1 and 1.79:1 ... once the page stops being Figma's #000d25 (against which they are 9.89:1 and 9.96:1)\". Two errors. (1) The page never stopped being #000d25 — Finding 0 re-pointed --color-surface-base to --palette-brand-ink, which IS #000d25, so the measurement was against a page colour this system had already replaced. (2) The 9.89:1 for #0e3e92 is the figure CLAUDE.md names as the audit error that \"would have justified the wrong call\": it is 1.96:1. Recomputed on the page that actually renders: blue-700 1.96:1, gray-700 1.95:1. Both still fail 1.4.11's 3:1, which under rule 1 makes them conflicts to RECORD, not values to substitute. --color-border-phone-active and --color-border-phone-ended are minted, and a test pins both the hex and the ratio — a threshold assertion would not notice a hue change, and a ratio alone would not notice which tint produced it.\n\nTWO BOUNDARY TOKENS, ONE PER LIFECYCLE STATE. This read \"one boundary token for both, not two\", on the reasoning that Figma distinguishes Default from End by border hue alone and \"a hue-only difference between live and ended would fail WCAG 1.4.1 anyway\". 1.4.1 forbids colour as the ONLY means of conveying information; `data-state` is on the root and the frame's content — an end-of-conversation CTA, a loading banner — carries the distinction visually, so the clause is already satisfied and the hue is Figma's choice to make. Collapsing them made two states render identically that the file draws differently, and the test that enforced it asserted `active` equals `ended`. It now asserts they differ.\n\nTHE TWO MOBILE VARIANTS ARE A GRADIENT, NOT `fill: none`, AND THIS IS THE CLEAREST THING REST COULD NOT SEE. This section read: Figma paints the root color/brand/ink in six of eight variants and none in the other two, \"#000d25 is precisely the dark file's own page colour, so the two authorings render identically wherever the frame sits on the page and the difference is not a design statement\" — so --color-surface-base was painted on all eight. Dev Mode: `bg-gradient-to-b from-1/2 from-[#000d25] to-[rgba(0,13,37,0)]`. Ink held to the midpoint, then falling to transparent. That is how a phone running off the bottom of the screen is drawn, and the entire treatment was lost to a reading of \"none\". --gradients-phone-mobile carries it, and it is consumed as `bg-[image:var(--gradients-phone-mobile)]` — the form Typography already uses for its eight gradients, because a CSS gradient is a background IMAGE and Tailwind has no `bg-image-*` namespace. `bg-gradients-phone-mobile` reads better and emits NOTHING; verified against the compiled stylesheet, which is the only place that failure shows. The desktop fill stays --color-surface-base, which is Figma's literal exactly. --color-surface-primary was measured and REMAINS rejected for a reason rule 3 does not touch: rgba(255, 255, 255, 0.08) in dark, and a translucent fill's composite depends on its backdrop, so it cannot carry a fixed boundary colour.\n\nTHREE MORE THINGS THE BINDING MISSED, AND ONE OF THEM ONLY DECODED PIXELS CAUGHT. (1) `Loading` draws an INSET glow, `0px 4px 40px rgba(239,34,58,0.5)`, and takes its Content layer to 20% OPACITY. The binding had loading differing from active in nothing but the absence of a border, so the state rendered as a plain frame; --shadow-phone-loading is minted, and the tint is accesible-red-dark-theme at 50%, a FOURTH distinct red alpha after the three 20% ones. The 20% opacity is itself a logged rule-1 conflict — it multiplies every child's contrast by roughly a fifth, so white copy at 19.37:1 lands near 1.6:1 — shipped as drawn because the state is transient, with `aria-busy` added as the non-visual signal (rule 4, no tie to break). (2) Mobile rounds the TOP TWO corners at radius/extra-large; the binding read it as binding no radius and made it fully square, a different shape. (3) The mobile border is THREE-SIDED — `border-l border-r border-t`, no bottom edge — which no class-name assertion here caught. `border` on all four sides drew a rule across the bottom fade, and only decoded pixels showed it: 472 border pixels on the frame's lowest row where the correct answer is 4 (the two side borders). Stroke sidedness is on docs/AGENTS.md §8's list of what REST cannot report, and it was invisible to this suite too. (4) `End` re-aligns its content, items-end to items-center.\n\n\n\nNO WIDTH IS SET, AND NO DESKTOP HEIGHT; MOBILE'S 360PX IS PINNED. Figma's 276x536 desktop is the composed screen, and 536 − 32 − 32 = 472 is exactly the Content layer's reported height: the desktop frame's size IS its padding plus its content, so pinning it adds nothing. Mobile is different and now ports its stated height as h-90 — 360 − 16 − 16 = 328, the Content layer's reported MOBILE height, so the pin agrees with the content box rather than fighting it. MEASURED IN A BROWSER: frame 360, content 327, because the 1px top border is inside the border box; Figma strokes without affecting layout, so that 1px is a consequence of porting the border and not a porting error. WHERE SHORT CONTENT SITS IS A GAP. Figma's Content layer is 328 tall, i.e. exactly the free space, so the file never shows leftover vertical room and says nothing about distributing it; pinned, a frame holding less than 328px of content leaves the remainder at the BOTTOM, and no main-axis distribution utility is added because choosing one would invent a rule Figma does not state (rule 2). This reverses an earlier decision that read the mobile cell the same way as desktop; the 1.4.10 cost of a height that cannot grow with enlarged text is recorded in docs/FIGMA-EXCEPTIONS.md rather than softened to a minimum-height, which would be the quiet substitution rule 1 forbids. NO WIDTH IS EVER SET, on either device: a fixed width cannot reflow, and audit:keyboard checks reflow at 320 CSS pixels — the fourth harness mistake in docs/AGENTS.md is auditing a story whose decorator has a fixed width, and a component with a fixed width makes that unavoidable. Story decorators use max-w-69 (276px) rather than w-69 for the same reason. Neither 276 nor 536 has a spacing token; 360 needs none, because Tailwind's 0.25rem step resolves h-90 to exactly 360 and a minted --height-* token would be WORSE — cn() registers no such namespace, so a caller's own h-* would stop winning. A test enumerates all 24 combinations and asserts the sizing set is exactly ['h-90'] on mobile and empty on desktop; it tokenises the class string rather than regexing it, because `min-w-0` contains `w-0` at a word boundary and the first version of that assertion failed against a correct component.\n\nTHE BOUNDARY WIDTH IS TAILWIND'S 1PX DEFAULT, NOT A TOKEN REFERENCE. Figma's borderWidth is 1, and the bare `border` utility is 1px, so no --spacing-borders-* token is named — it is listed nowhere in tokens_used on purpose, because claiming a token the source does not reference would put a false entry into required-tokens.txt. --spacing-borders-thin is numerically the same 1px if a future change wants it explicit.\n\nGEOMETRY, READ FROM THE VARIANTS AND NOT FROM THE SET FRAME. paddingHorizontal 32 (spacing/medium) on every variant -> px-padding-horizontal-xl. paddingVertical 32 desktop -> py-padding-vertical-xl, 16 mobile (spacing/small) -> py-padding-vertical-md, and a test asserts exactly ONE py-* utility per device because two in one class string would leave the winner to Tailwind's source order rather than to the variant. Root gap 32 -> gap-gap-500, between the header, content and footer slots. Content gap 24 (spacing/medium-small) -> gap-gap-400. radius 20 -> --radius-extra-large -> rounded-extra-large, bound on the four Device=Default variants ONLY, so mobile is square and a test asserts the absence as well as the presence.\n\nNO MOTION, ASSERTED RATHER THAN CLAIMED. The frame declares no transition and no animation, so there is no motion-reduce:transition-none to add — and a test asserts that over every combination plus the three sub-frames, because the omission is only defensible while it stays true. It also does not scroll and does not auto-scroll: stick-to-bottom is ChatLog's and is already solved there, so a message list inside this frame composes ChatLog rather than reimplementing it, and there is no rAF loop to ignore prefers-reduced-motion.\n\nTHE FOUR PER-CHANNEL FRAMES ARE STORIES, NOT EXPORTS. `RCS` (1082:24681), `SMS` (1147:19899), `WhatsApp` (1151:29862) and `Web Chat` (1152:33530) are all `Property 1=Desktop|Mobile` at 252–255 x 887–945 and each composes the same three things — Avatar, Message, Input bar — inside this frame. They are compositions, and they ship as the four Composition* stories so audit:a11y and audit:keyboard cover them without four more exports existing. `Channel selection` (836:7185), `Error` (1079:21046), `Loading text` (834:7413) and `Mic access` (1079:20810) are 212x472 phone-area states whose body Track E's E2 resolved to ChatLog's event entry, so they are content passed to this frame rather than variants of it.\n\nNO LANDMARKS. `phone-frame-header` and `phone-frame-footer` are plain divs, not <header>/<footer>: a landmark for a device mock-up would put a spurious banner and contentinfo in the page's landmark list, and the real page around it already has both. The root takes no role either — it is chrome, and every string inside it belongs to a child. A test asserts the frame emits no ink of its own for the same reason, since an ink here could only shadow a child's and would be measured against the wrong backdrop if it did.\n\nCONTRAST DERIVATION: PER COMBINATION, PER ROLE, EACH ROLE FROM ITS OWN STYLE FUNCTION. Four style functions. The 24 (channel, state, device) combinations are enumerated from the three exported const arrays; for each one the fill and the boundary are derived SEPARATELY from phoneFrameStyles via colorTokensIn's `surfaces` and `borders` buckets, so the boundary is measured against the fill it is actually seen on rather than against the page. No list is 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 passed with the ink mutated to text-highlight at 1.84:1. Count guards on every derived set (exactly one fill per combination; exactly one boundary for the combinations Figma draws one for and zero for the rest; zero inks anywhere; zero colour of any kind on the header, content and footer frames), because an unmappable utility drops out silently and every pairing would then pass by measuring one colour fewer. The pairing assertions pass the joined failure list as the assertion message, so a failure quotes the real ratios instead of `expected [ …(12) ] to deeply equal []`.\n\nRE-MUTATED AFTER THE REVERT, SIX WAYS, AND TWO OF THE ORIGINAL SIX QUOTED A LIGHT THEME THAT NO LONGER EXISTS. (1) ended hue set to the active token: 2 failures, including \"expected [ '--color-border-phone-active' ] not to deeply equal [ '--color-border-phone-active' ]\". (2) glow dropped: the loading test fails on `shadow-phone-loading`. (3) content dim dropped: same test fails on `opacity-20`. (4) mobile given the full four-corner radius: 2 failures. (5) mobile fill set back to `bg-surface-base`: the per-device fill test fails, reporting a mappable surface where a gradient belongs. (6) mobile border set back to the four-sided `border` shorthand: the sidedness test fails — the one defect this suite could not see before, since it was found in decoded pixels rather than in a class list.\n\nVERIFIED FROM DECODED PIXELS. Active and mobile draw #0e3e92, ended draws #38425e, loading draws NO border (0 blue pixels) and shows the glow decaying from rgb(65,18,41) at the top edge to the page colour by y=140, which is `inset 0 4px 40px` behaving as specified. Mobile's lowest border row carries 4 pixels rather than 472, i.e. the two side borders and no bottom edge.\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 boundary assembled from the two variant values would generate no CSS at all and the frame would render edgeless while the rendered className looked correct — the exact failure the aria-disabled: helper produced in Button. The duplication is the point."
}
