{
  "name": "ScenarioCard",
  "type": "pattern",
  "description": "A setup-page navigation card: an image slot, a title, a description and an arrow affordance. `detail` shows the title always; `reveal` is image-led and expands the title on hover or focus.",
  "export": "@gtmi/strix-react/molecules/scenario-card",
  "path": "@gtmi/strix-react/molecules/scenario-card",
  "props": [
    {
      "name": "title",
      "type": "ReactNode",
      "required": true,
      "description": "The scenario's name. Always rendered, in both variants, so it is always part of the control's accessible name."
    },
    {
      "name": "titleAs",
      "type": "h2 | h3 | h4 | h5 | h6",
      "description": "Render the title as a real heading. Omitted by default: guessing a level produces a wrong document outline that no test in the consumer's app will catch. Same reasoning as InfoSection and CollapsiblePanel."
    },
    {
      "name": "description",
      "type": "ReactNode",
      "description": "The supporting line. Omit it and no element is rendered, rather than an empty 18px line."
    },
    {
      "name": "image",
      "type": "ReactNode",
      "description": "The card image. A slot, because Figma's `Card image` is an 18-variant asset set rather than a component; its intrinsic height is the asset's."
    },
    {
      "name": "variant",
      "type": "detail | reveal",
      "default": "detail",
      "description": "Which Figma card, named for behaviour rather than for the node it came from. Decision 2 of the 0.1.2 plan."
    },
    {
      "name": "orientation",
      "type": "vertical | horizontal | responsive",
      "default": "vertical",
      "description": "Figma's `Device` axis on `Scenario card`: vertical stacks image above content, horizontal places image beside content (row content with side-by-side arrow), and responsive switches from horizontal below md to vertical from md up."
    },
    {
      "name": "revealed",
      "type": "boolean",
      "default": "false",
      "description": "Pin variant=reveal open. The escape hatch for a touch device, which has no hover at all. Ignored by detail."
    },
    {
      "name": "render",
      "type": "React.ReactElement",
      "description": "Base UI's render seam and the house convention for element swapping — never asChild or as. Pass an element, not a component: render={<Link href=\"/scenarios/hotel\" />}."
    },
    {
      "name": "className",
      "type": "string",
      "description": "Merged through cn(), so a caller's utility wins."
    }
  ],
  "parts": [
    {
      "name": "scenario-card",
      "description": "Root, via data-slot. Also carries data-variant and data-orientation."
    },
    {
      "name": "scenario-card-image",
      "description": "The image slot's frame"
    },
    {
      "name": "scenario-card-content",
      "description": "The content column"
    },
    {
      "name": "scenario-card-container",
      "description": "Title over description"
    },
    {
      "name": "scenario-card-reveal",
      "description": "Present only in variant=reveal: the collapsible wrapper around the title. Carries data-revealed."
    },
    {
      "name": "scenario-card-title",
      "description": "The title"
    },
    {
      "name": "scenario-card-description",
      "description": "The description"
    },
    {
      "name": "scenario-card-arrow",
      "description": "The arrow affordance, aria-hidden"
    }
  ],
  "ai_tags": ["scenario-card", "card", "navigation", "link", "setup", "hover", "reveal", "arrow", "tile", "pattern"],
  "since": "0.1.2",
  "figma": {
    "file": "85sD2HI8wksP9Y7uDmJ9DK",
    "fileName": "Multi product demo",
    "components": {
      "detail": "816:14901 — `Scenario card`, State=Default|hover x Device=Desktop|Mobile",
      "reveal": "672:8354 — `Scenario card opt2`, Property 1=Default|hover"
    },
    "composes": {
      "Card image": "{CREDIT_DEBIT_CARD_NUMBER} — 18 variants over Image=Hotel|Order|Car|Insurance|Customer|Loan x State=Default|RCS|Mobile. An ASSET SET, not a component: taken as a ReactNode slot and not built."
    }
  },
  "tokens_used": [
    "--color-surface-card-inverted",
    "--color-border-focus",
    "--color-text-on-card-inverted",
    "--color-text-on-card-inverted-muted",
    "--color-icon-on-card-inverted",
    "--color-icon-inverse",
    "--color-surface-card-arrow-active",
    "--radius-large",
    "--spacing-gap-100",
    "--spacing-gap-200",
    "--spacing-gap-250",
    "--spacing-padding-horizontal-med-sm",
    "--spacing-padding-horizontal-md",
    "--spacing-padding-vertical-med-sm",
    "--spacing-padding-vertical-sm",
    "--text-size-figma-12",
    "--text-size-figma-14",
    "--text-size-figma-16",
    "--leading-height-figma-12",
    "--leading-height-figma-14",
    "--leading-height-figma-16"
  ],
  "notes": "ONE COMPONENT, TWO VARIANTS, NAMED FOR BEHAVIOUR — Decision 2 of docs/superpowers/plans/2026-07-31-0.1.2-gtm-990-plan.md. `Scenario card` 816:14901 and `Scenario card opt2` 672:8354 share radius 10, the same 12px root gap, the same 8px content gap, the same 4px title/description gap and the same arrow affordance (radius 10, paddingHorizontal 16, paddingVertical 8, gap 8, a 24px right-arrow icon). They differ in exactly one thing: whether the title is always shown. So `variant` is that, and nothing else.\n\nA NAVIGATION CARD, SO IT IS A CONTROL. It renders as a real <button type=\"button\"> by default and swaps element through the house `render` seam, which is Base UI's convention and never asChild or as. Neither existing component fits: `Card` has no image, no hover and no interaction, and `SelectableTile` is a toggle reporting aria-pressed, which would announce a selection state that never changes. `jsx-a11y/anchor-has-content` fires on the childless <a /> element literal the seam requires — documented in docs/CONSUMING.md, and disabled inline in the story and the test with a comment saying why.\n\nTHE HOVER-ONLY REVEAL IS THE ONE REAL ACCESSIBILITY PROBLEM IN THIS PORT, and four things together fix it. Figma hides `reveal`'s title in `Property 1=Default` and shows it in `Property 1=hover`, so ported literally the card's only specific label would be unreachable from a keyboard, unreliable on touch, and — if implemented with `hidden` or a conditional render — absent from the accessibility tree entirely. (1) The title is ALWAYS rendered; the collapse is `max-h-0` plus `opacity-0`, which keep the node in the DOM and exposed, so it is always part of the control's accessible name. Never hidden, never display:none, never conditional. (2) Focus reveals it as well as hover: `group-focus-visible:` for a keyboard and `group-focus-within:` for anything that moves focus into the card, including a tap. (3) The description and the arrow stay visible at rest, exactly as Figma's Default draws them, so the collapsed card already says what it is for — the image is never the only thing carrying meaning. (4) `revealed` pins it open, for a device with no hover at all. A test asserts the accessible name contains the title with the card at rest, which is the assertion that fails if any of this regresses. Every trigger is a literal class: a name assembled at runtime generates no CSS, so a helper here would leave the block collapsed for everyone while looking correct in the rendered className.\n\nREDUCED MOTION: the reveal animates a height and the arrow animates a fill, so both carry motion-reduce:transition-none, written as they were written rather than retrofitted. WCAG 2.3.3.\n\nGEOMETRY, AND THE TWO PLACES IT COULD NOT BE COPIED. `reveal` is 295px tall in Figma with its image shrinking 247px -> 139px on hover, which keeps the card's own height fixed so neighbouring cards never shift. That behaviour is preserved and the numbers are not: 247, 139 and 295 have no spacing token, no-arbitrary-tailwind correctly rejects h-[247px], and a hard-coded pair would break the moment a title wraps to a third line. The height is stated once as `h-74` (296px, the nearest 4px step — the 1px divergence from 295 is knowing) and the 108px shrink falls out of a flex relationship: the image is flex-1 with min-h-0, so it gives up exactly the space the revealed title takes. `detail` sets no image height at all, because 140px belongs to the `Card image` asset (140 Default / 144 Mobile / 77 RCS) rather than to this component.\n\nTHE 12PX HORIZONTAL PADDING IS STATED ONCE. Figma puts paddingHorizontal 12 on both `Content / Container` and `Content / Arrow container`, which are siblings; the same value on the shared parent is the same rendering with one declaration.\n\nTYPOGRAPHY. Title 16/24 at font/weight/Semibold -> 600, description 12/18 at font/weight/Book -> 400, both mapped by variable NAME. Figma reports 375 and 325, which are Whitney SSm's embedded values and provenance only: font-weight: 375 is not Semibold to a browser. The description uses the --text-size-figma-12 / --leading-height-figma-12 pair rather than the `body-xs` role, because that role's leading is 16px and Figma's description layer is exactly 18px tall — one line at 18. Badge's `small` size and ToolCall's pill use the same pair for the same reason. The title uses the figma-16 pair, which is 16/24 exactly. Both roles are written out as literal utilities in the component's own cva rather than reached through <Typography variant>, because the title's element is chosen by titleAs — sometimes a heading, sometimes a span — and Typography would add a second box to say one thing. ToolCall's <code> is the same exception for the same reason.\n\nEVERY COLOUR IS FIGMA'S NOW, AND RULE 3 IS WHAT CHANGED. Figma paints the card `color/brand/white` with a `color/brand/ink` title, a `gray-700` description and a `color/brand/ink` resting arrow glyph — a light card on a dark page, the one component in the file that inverts. All four were refused because \"the file is authored dark-only, so none of that is verified in light, and ported literally the title is 1.00:1 the moment the page is light\". There is no light page. Measured on Figma's own card: title 19.37:1, description 9.96:1, resting glyph 19.37:1, and the fill itself 19.37:1 against the page. Every one is BETTER than the substitute it replaces (13.52:1 / 8.70:1 / 13.52:1), which is the tell that the substitution was paying for a theme rather than fixing a defect. Note the description's decline measured against the WRONG SURFACE: \"1.79:1 against Strix's dark surface\" is what gray-700 would be on the substituted dark fill, and the card is white. Name the surface, always — a token has no contrast, a token on a named surface does. Four roles are minted (--color-surface-card-inverted, --color-text-on-card-inverted, --color-text-on-card-inverted-muted, --color-icon-on-card-inverted) rather than reusing --color-surface-tooltip / --color-text-on-tooltip / --color-text-on-tooltip-muted, which carry three of these literals exactly: same values, different treatment, and one role per treatment is what lets either move without moving the other. --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 ink at all.\n\nTHE BOUNDARY IS REMOVED, BECAUSE THE FILL IS THE EDGE. This read: \"Figma draws no border: a white card on a dark page is its own edge. --color-surface-secondary measures 1.09:1 light / 1.32:1 dark against --color-surface-base, so the fill-as-edge does not survive the port ... the remedy is the one docs/AGENTS.md prescribes: --color-border-control.\" The first sentence is Figma and the rest is about the SUBSTITUTE. `color/brand/white` against `color/brand/ink` is 19.37:1, the highest-contrast boundary in the file, so 1.4.11's 3:1 is satisfied by the fill and the border had nothing to do. Recorded because this shape recurred across the whole port: a substituted value fails a check, an ADDITION is made to rescue it, and the addition then reads as a design decision. Two tests went vacuous with it — they measured `card.borders` against the page and against the fill, and an empty derived set yields no pairings and therefore no failures, so they stopped asking rather than failing. Replaced by one that measures the FILL as its own edge and pins it to #ffffff, plus the count guard asserting zero borders.\n\nTHE HOVERED ARROW IS FIGMA'S RED, AND THE RATIO IN THE DECLINE WAS WRONG. Figma fills it `color/brand/accesible-red-dark-theme` #ef223a under a `color/brand/white` glyph. This was declined as \"3.30:1, which passes as a glyph and would fail as text, and a dark-theme-tuned red with no light counterpart to invert to\", and substituted with --color-surface-accent-extra-strong — a BLUE, not a safer shade of the same hue. Recomputed: white on #ef223a is 4.23:1. So the number that justified the substitution was not the number, and 1.4.3's 4.5:1 is not the applicable floor for a glyph anyway. This is the failure mode CLAUDE.md names — an invented measurement becoming cited evidence — and the fix is the one it prescribes: recompute before citing. The glyph needs no new role, because --color-icon-inverse is already #ffffff, Figma's value exactly. The pair is pinned as two hexes rather than as a threshold, since the substitute also cleared 3:1 and a threshold check would not have noticed the hue change. The hovered pair lives in its OWN style function (scenarioCardArrowActiveStyles) so the glyph is measured against the fill it is actually seen on rather than against the card's, and the focus ring lives in a third (scenarioCardFocusStyles) because colorTokensIn buckets outline-* with border-*.\n\nCONTRAST DERIVATION: EVERY ROLE FROM ITS OWN STYLE FUNCTION. Six functions, six derivations, no list ever collected and split by token-name prefix. That prefix split is what made InfoSection's first draft vacuous — 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 all seven derived sets, because an unmappable utility drops out of its bucket silently and every pairing would then pass by measuring one colour fewer.\n\nMUTATION-TESTED, EIGHT WAYS, ALL QUOTING REAL RATIOS OR REAL UNMEASURABLES. (1) Title ink -> text-highlight: \"--color-highlight on --color-surface-secondary (light): #3acefa on #f1f5ff = 1.69:1, wanted 4.5:1\". (2) Description ink -> text-highlight: same 1.69:1 against 4.5. (3) Card boundary -> border-border-neutral-extra-soft: four failures, \"#f1f5ff on #ffffff = 1.09:1, wanted 3:1\", \"#232e47 on #0f172a = 1.32:1\", and 1.00:1 on both sides against the card's own fill. (4) Focus ring -> outline-highlight: \"#3acefa on #ffffff = 1.84:1, wanted 3:1\" plus 1.69:1 against the card. (5) Resting arrow glyph -> text-highlight: 1.69:1 against 3. (6) Hovered arrow glyph -> group-hover:text-highlight: count guard trips AND \"#3acefa on #1866ee = 2.73:1, wanted 3:1\". (7) Card fill -> bg-surface-primary: six assertions fail, every one reporting it unmeasurable in dark — \"--color-surface-primary=rgba(255, 255, 255, 0.08)\". (8) Hovered arrow fill -> group-hover:bg-surface-error: count guard trips, \"#ffffff on #fbe9e8 = 1.17:1, wanted 3:1\" in light and unmeasurable rgba(177, 15, 35, 0.2) in dark.\n\ngap-0 AND NOT gap-none on the horizontal root. --spacing-none exists as a token, but no compiled .gap-none rule appears in the built stylesheet, and a class that generates no CSS fails silently.\n\nKEYBOARD: Enter and Space activation and Tab reachability are all driven with user-event rather than element.click(), which bypasses key handling entirely and would pass against a component that is not operable."
}
