/* ==========================================================================
   swap.css — product-surface primitives for the LI.FI swap widget
   --------------------------------------------------------------------------
   All primitives here use the `.ui-*` (tier 3) prefix per the three-tier
   class-naming convention in design.md §13. They live on the public
   playground.html surface (and any future product page that hosts the
   widget). Do not load this file on marketing pages — the .ui-* primitives
   are not designed for those contexts.

   Phase 1 scope (Apr 2026):
     .ui-card             widget shell + state-machine root
     .screen-header    [back] [centered title] [trailing slot] header
     .ui-token-select     From/To picker trigger (avatar + chain sublabel)
     .ui-amount-field     big numeric + fiat preview + small left avatar
     .ui-swap-arrow       circular swap-direction button between selectors

   Theme bridge: every primitive consumes existing brand tokens
   (--lifi-sapphire / --lifi-ink / --accent-* / --ink-*, etc.) so the Palette
   Composer drives the widget canvas with zero adapter code.
   ========================================================================== */


/* ── .ui-card — swap widget shell ─────────────────────────────────────────
   Composes the universal `.panel` primitive (styles.css → Panel) for
   surface chrome — background, shadow, backdrop, border, radius, and
   padding all flow from the panel recipe so the widget reads at the
   same visual tier as the playground rail and any other panel surface.

   `.ui-card` adds only what's swap-widget-specific:
     • Width — pinned to var(--w-7) (416 px) so the widget renders at
       the canonical product width on every page that hosts it.
     • Content gap — bumped from the panel's default 12 to 16 so the
       Send/Receive sub-card pair gets more breathing room than a
       dense settings list inside the rail.
     • State-machine root — data-state attribute toggles inner content
       (form / picker / review / tx-progress).

   Markup: <article class="panel ui-card" data-state="form">…</article>
   ----------------------------------------------------------------------- */
.ui-card {
  width: 100%;
  max-width: var(--w-7);
  /* Roomier than the .panel default (--panel-gap = --space-12) — tuned via the
     token, not a bare `gap` override, so .panel's `gap: var(--panel-gap)` reads
     it on this same element. Same computed value (16) as the prior literal. */
  --panel-gap: var(--space-16);
  /* May 2026 — viewport-cap. `--widget-max-h` is set on body.playground-body
     by the rail's Height drill (Auto = unset → no cap = pure-grow; presets
     and Custom write a px value here). When set, `.ui-card` becomes a
     fixed-height flex column and its state-content children scroll inside.
     The picker state's `.ui-token-picker` is already authored for this
     (min-height:0; height:100%; head pinned, body owns scroll) — it just
     needed a parent envelope. Marketing / production pages without the
     body var fall through to no cap (pure-grow), backwards-compatible. */
  max-height: var(--widget-max-h, none);
  overflow: hidden;
}


/* ── Picker panel side-padding compensation ──────────────────────────────
   Token picker rows paint their hover / active / selected state on a
   ::before pseudo that extends --list-hover-bleed past each row edge
   (see the .ui-token-list .list-item--interactive::before block below).
   For that bleed to render visibly, the panel must have room for it on
   the L/R — so the picker panels reduce their inline padding by exactly
   --list-hover-bleed. The token picker's body (and head, for search-bar
   alignment) carries a compensating inline padding of --list-hover-bleed,
   so the avatar and trail stay at the canonical panel content edge
   (panel-outer + --panel-padding) and the bleed extends back into what
   was the panel's side padding.

   Four sites consume --list-hover-bleed in lockstep:
     • the pseudo's inset
     • the body's L/R padding (compensation)
     • the head's L/R padding (search-bar alignment with rows)
     • this rule's panel-padding subtraction
   Bumping the token in styles.css :root reaches all four at once.

   The chain picker matches the inline-padding reduction so the two
   pickers (token + chain) read as one visual rhythm side-by-side. Chain
   rows don't carry the bleed pseudo (their .list-item--interactive
   default L/R padding of --space-16 compensates positionally instead),
   so the reduction is purely about column-alignment with the token
   picker on shared screens.

   Why not :has()? The .ui-card keeps all state-content divs in the DOM
   (display:none on inactive ones), so :has(.ui-token-list) would match
   even in form state — over-applying the reduction. The state attribute
   is the only correct signal for the .ui-card; the standalone chain
   filter aside gets its own explicit selector. */
.ui-card:is([data-state="picker"], [data-state="chain-picker"]),
.split-pane--chain-filter {
  padding-inline: calc(var(--panel-padding) - var(--list-hover-bleed));
}

/* Back-button alignment with the content edge. The panel-padding
   suppression above pulls the panel's content edge --list-hover-bleed
   outward to host the row hover pseudo's bleed. The picker's __head
   compensates by re-inseting the search bar by --list-hover-bleed so
   it lands at the canonical panel content edge. The .screen-header
   sitting above the head doesn't carry that compensation by default —
   its back button ends up --list-hover-bleed left of the search bar,
   visibly out of alignment with the content below.

   Scoping to :has(.screen-header__back) is deliberate: without a back
   button the title is centered and the trailing slot is empty in these
   contexts today, so no compensation is needed. Broaden the selector
   if a trailing slot ever lands here. */
.ui-card:is([data-state="picker"], [data-state="chain-picker"]) .screen-header:has(.screen-header__back) {
  padding-inline: var(--list-hover-bleed);
}


/* ── .screen-header — promoted to styles.css (May 2026) ────────────────
   Cross-widget primitive now. Lives in styles.css alongside the other
   universal Component primitives — search "Product-surface (.ui-*)
   primitives" or ".screen-header" in styles.css for the canonical
   block. Live spec at design-system/index.html#screen-header.

   Authoring on this widget remains unchanged; the markup contract
   (`<header class="screen-header"> + __back / __title / __trailing
   slots`) is identical. Two new capabilities:
     · markup-driven slot presence via :has() — author omits the slot
       (no more `display: none` on hidden back buttons)
     · __subtitle nested slot under the title
     · __back--close modifier for the modal-wrapper case. */


/* ── .ui-token-select — From/To picker trigger ────────────────────────────
   A mini-card that displays the chosen token (avatar + symbol + chain
   sublabel) or an empty "Select…" state. Tap opens the token picker.
   In the wide/split subvariant two of these sit side-by-side with a
   .ui-swap-arrow between them.
   ----------------------------------------------------------------------- */
.ui-token-select {
  display: flex;
  flex-direction: column;
  gap: var(--space-8, 8px);
  padding: var(--space-12, 12px) var(--space-16, 16px);
  background: oklch(0% 0 0 / 0.025);
  border: 1px solid oklch(0% 0 0 / 0.06);
  border-radius: var(--card-radius);
  cursor: pointer;
  transition: border-color .12s ease, background-color .12s ease;
  text-align: left;
  flex: 1;                     /* equal-width when in a flex row */
  min-width: 0;
}
.ui-token-select:hover {
  border-color: oklch(0% 0 0 / 0.12);
  background: oklch(0% 0 0 / 0.04);
}
.ui-token-select__label {
  font-size: var(--text-caption, 12px);
  /* Pair size + line-height as a token-defined unit so the label reads
     at a consistent 16 px line-box across every consumer. */
  line-height: var(--lh-caption);
  font-weight: var(--fw-medium);
  color: var(--text-secondary, #666);
  letter-spacing: var(--tracking-normal);
}
.ui-token-select__body {
  display: flex;
  align-items: center;
  gap: var(--space-8, 8px);
  min-width: 0;
}
.ui-token-select__avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: oklch(0% 0 0 / 0.06);
  flex: 0 0 auto;
  position: relative;
}
.ui-token-select__avatar img {
  width: 100%;
  height: 100%;
  border-radius: inherit;
  display: block;
}
.ui-token-select__avatar-chain {
  /* small chain dot at bottom-right of the avatar — appears when a
     specific chain is associated with the selected token */
  position: absolute;
  right: -2px;
  bottom: -2px;
  width: var(--space-12);
  height: var(--space-12);
  border-radius: 50%;
  border: 2px solid var(--surface-page, #fff);
  background: var(--ink-200, #ccc);
}
.ui-token-select__text {
  display: flex;
  flex-direction: column;
  min-width: 0;
}
.ui-token-select__symbol {
  font-size: var(--text-body, 16px);
  font-weight: var(--fw-bold);
  color: var(--text-primary, #111);
  line-height: 1.2;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.ui-token-select__sublabel {
  font-size: var(--text-caption, 12px);
  font-weight: var(--fw-regular);
  color: var(--text-secondary, #666);
  line-height: 1.3;
}
/* Empty state — "Select…" placeholder */
.ui-token-select--empty .ui-token-select__symbol {
  color: var(--text-tertiary, #999);
  font-weight: var(--fw-medium);
}


/* ── .ui-swap-arrow — circular direction toggle FAB ──────────────────────
   Circular icon button that sits between two paired elements (Send / Receive
   sub-cards, or the legacy From / To selectors in the wide/split layout).
   Click rotates the icon 180° to indicate direction swap.

   ── Tokenized primitive (May 2026 v2) ─────────────────────────────────────
   Every visible value reads from a `--swap-arrow-*` token published on the
   primitive root. Override per-instance via inline style or via a parent
   selector — the .ui-amount-pair opts into the cutout + hover-scale via
   `.ui-amount-pair > .ui-swap-arrow { ... }`.

   Tokens:
     --swap-arrow-size           button diameter (rest)
     --swap-arrow-icon-size      SVG width/height — independent of button size,
                                 so the icon stays put when the button scales
     --swap-arrow-hover-size     button diameter on hover (defaults to
                                 --swap-arrow-size = no scale-on-hover)
     --swap-arrow-extra-shadow   composable shadow slot the --glass modifier
                                 fills with the Panel Surfaces shadow recipe

   The CUTOUT (FAB visually punching through neighbouring cards) is a
   CONSUMER concern, not a FAB primitive concern. The pair / container
   coordinates it by MASKING its sibling cards — see .ui-amount-pair
   below for the canonical example. The mask is a true cutout (cards
   become transparent in the FAB's footprint, revealing whatever surface
   sits behind), same technique as .avatar-tandem badge cutouts.
   ----------------------------------------------------------------------- */
.ui-swap-arrow {
  --swap-arrow-size:         var(--space-28, 28px);
  --swap-arrow-icon-size:    var(--space-12, 12px);
  --swap-arrow-hover-size:   var(--swap-arrow-size);
  --swap-arrow-extra-shadow: 0 0 0 transparent;
  /* Icon rotation slot — composes with the --vertical modifier (which
     overrides both slots to add 90°). On hover the icon rotates to the
     hover slot, previewing the swap-direction reversal that's the FAB's
     semantic action. */
  --swap-arrow-icon-rotate:       0deg;
  --swap-arrow-icon-rotate-hover: 180deg;

  flex: 0 0 auto;
  width: var(--swap-arrow-size);
  height: var(--swap-arrow-size);
  border-radius: 50%;
  background: var(--surface-page, #fff);
  border: 1px solid oklch(0% 0 0 / 0.08);
  /* Composable shadow slot — --glass fills it with the Panel Surfaces
     shadow recipe. The cutout (FAB punching through neighbours) is the
     consumer's job — they mask their sibling cards; see .ui-amount-pair. */
  box-shadow: var(--swap-arrow-extra-shadow);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  color: var(--text-secondary, #666);
  font-family: var(--font-sans);                      /* defensive — <button> UA default is Arial; render brand Figtree if ever given a text label / ::after glyph */
  align-self: center;
  /* Width / height transition powers the scale-on-hover; the icon does NOT
     consume any transform so its geometry stays put when the button grows. */
  transition: width .18s cubic-bezier(0.2, 0.8, 0.2, 1),
              height .18s cubic-bezier(0.2, 0.8, 0.2, 1),
              border-color .12s ease,
              box-shadow .18s ease,
              transform .2s ease;
}
.ui-swap-arrow:hover {
  width: var(--swap-arrow-hover-size);
  height: var(--swap-arrow-hover-size);
  border-color: oklch(0% 0 0 / 0.16);
  color: var(--text-primary, #111);
  /* transform: scale() (composited — no layout shift) lifts the FAB on
     hover. Geometry: at --lg the rest FAB is 32 px, scale(1.06) renders
     ~34 px, the sibling cutout is 40 px → ~3 px clearance preserved. */
  transform: scale(1.06);
}
.ui-swap-arrow svg {
  width: var(--swap-arrow-icon-size);
  height: var(--swap-arrow-icon-size);
  transform: rotate(var(--swap-arrow-icon-rotate));
  transition: transform .25s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ui-swap-arrow:hover svg {
  transform: rotate(var(--swap-arrow-icon-rotate-hover));
}


/* ── .ui-amount-field — large numeric input with fiat + balance ──────────
   Anchors the bottom of the form. Big amount input on the right (or
   centered), small token avatar on the left, fiat preview below. In the
   v2 wide/split layout the From/To selectors handle token picking, so
   this field doesn't carry a token chip — just the amount.
   ----------------------------------------------------------------------- */
.ui-amount-field {
  display: flex;
  flex-direction: column;
  gap: var(--space-12, 12px);
  padding: var(--space-16, 16px);
  background: oklch(0% 0 0 / 0.025);
  border: 1px solid oklch(0% 0 0 / 0.06);
  border-radius: var(--card-radius);
}
.ui-amount-field__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-8, 8px);
}
.ui-amount-field__label {
  font-size: var(--text-caption, 12px);
  font-weight: var(--fw-medium);
  color: var(--text-secondary, #666);
}
.ui-amount-field__body {
  display: flex;
  align-items: center;
  gap: var(--space-12, 12px);
}
.ui-amount-field__avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: oklch(0% 0 0 / 0.06);
  flex: 0 0 auto;
  position: relative;
}
.ui-amount-field__avatar-chain {
  position: absolute;
  right: -2px;
  bottom: -2px;
  width: var(--space-12);
  height: var(--space-12);
  border-radius: 50%;
  border: 2px solid var(--surface-page, #fff);
  background: var(--ink-200, #ccc);
}
.ui-amount-field__input {
  flex: 1;
  min-width: 0;
  border: 0;
  background: transparent;
  font-size: var(--text-h2);
  font-weight: var(--fw-semibold);
  font-variant-numeric: tabular-nums;
  letter-spacing: var(--tracking-firm);
  color: var(--text-primary, #111);
  outline: none;
  padding: 0;
  font-family: inherit;
  width: 100%;
}
.ui-amount-field__input::placeholder { color: var(--text-tertiary, #999); }
.ui-amount-field__footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-8, 8px);
  font-size: var(--text-caption, 12px);
  color: var(--text-secondary, #666);
}
.ui-amount-field__fiat { font-variant-numeric: tabular-nums; }


/* ── .ui-amount-card — Send / Receive sub-card inside the widget shell ──
   Composable two-line card that lives stacked inside .ui-card. Slots:
     __header   →  label (left) + trailing slot (right) for chip rows,
                   route badges, gas / time meta, etc.
     __body     →  big amount on the left, .ui-token-pill on the right
     __footer   →  fiat value (left) + supplementary meta (right)

   Supersedes the single-cell .ui-amount-field for the v2 widget layout.
   The Send / Receive distinction is pure composition — no `--send` /
   `--receive` modifier classes; pass `<input>` for editable amounts,
   `<span>` for static. The trailing __header slot accepts any chip /
   chip row; the __footer accepts a fiat element + optional right-cell
   meta (gas chip, time chip, expand chevron, balance, etc.).

   ── Configurable via CSS custom properties ──────────────────────────
   Every visible value reads from a `--amount-card-*` token published
   on the primitive root. Override per-instance via inline style or via
   a context selector. Example — review-screen amount card with no
   shadow and a larger amount:
     <div class="ui-amount-card" style="
       --amount-card-shadow: none;
       --amount-card-amount-size: var(--text-display);
     ">…</div>

   ── Sizing ladder (per CLAUDE.md → Component Sizing Ladders) ────────
   --sm        compact embedded contexts (drawer, history list)
   default     form-state widget (Send / Receive, 416 px shell)
   --lg        review / hero screens (single amount in focus)

   ── Shape modifier ──────────────────────────────────────────────────
   --ghost     transparent bg + no shadow + no padding — for cases
               where the card lives flush inside an existing panel
   ----------------------------------------------------------------------- */

/* ══════════════════════════════════════════════════════════════════════
   WIDGET CARD-TITLE LABEL — one canonical style, single source.
   --------------------------------------------------------------------------
   The small label at the top of a widget card naming what it is — "Send",
   "Receive", "Receiving wallet", "When ETH is worth" / "Limit price", and the
   review-summary flow label. Every one reads at ONE treatment:
     14 px (--text-sm) · 600 (--fw-semibold) · primary ink · normal tracking
     · 20 px line-box (calc(20/14)).
   These tokens ARE that single source. Before this, each card redeclared the
   treatment and the limit card's comment literally said "keep in lockstep by
   hand" — which drifted (the destination card shipped at --text-secondary, a
   lighter grey). Now every card-title label consumes --ui-card-label-*, so the
   style can't drift card-to-card; change it here once and all cards follow.

   NEW cards: add `.ui-card-label` to the label span (zero-CSS default), OR
   reference these tokens in the card's `__label` rule (what the amount / limit
   / review cards do via their own slots). Per-instance override: set a
   --ui-card-label-* on the card (or inline style).

   NOT this style — the smaller FIELD-label role (.ui-amount-field__label /
   .ui-token-select__label: 12 px / medium / secondary) is intentionally
   different; don't fold those in.
   ══════════════════════════════════════════════════════════════════════ */
:root {
  --ui-card-label-size:     var(--text-sm);
  --ui-card-label-weight:   var(--fw-semibold);
  --ui-card-label-color:    var(--text-primary);
  --ui-card-label-tracking: var(--tracking-normal);
  --ui-card-label-lh:       calc(20 / 14);   /* 20 px line-box at the 14 px size */
}
.ui-card-label {
  font-size: var(--ui-card-label-size);
  font-weight: var(--ui-card-label-weight);
  color: var(--ui-card-label-color);
  letter-spacing: var(--ui-card-label-tracking);
  line-height: var(--ui-card-label-lh);
  margin: 0;
}

.ui-amount-card {
  /* Configurable surface — every value here is overridable per-instance.
     Default surface: --surface-card + --elev-1. This is the canonical
     "sub-card inside a panel" recipe — the .panel shell sits at
     --surface-raised + --elev-3 (the floating tier), and every card
     living inside it lifts one elevation tier above the panel via
     --surface-card (theme-aware: 15% dark, pivots to --surface-page
     in light so cards lift via shadow alone) + --elev-1 (resting-card
     shadow). Two-tier elevation reads the same in both themes.
     Override per-instance via inline style. */
  --amount-card-bg:             var(--surface-card);
  /* Default reads from the system-wide --card-radius (16 px by default;
     overridden via the playground rail's "Card radius" control). Per-
     instance consumers override the slot via inline style. */
  --amount-card-radius:         var(--card-radius);
  --amount-card-padding-y:      var(--space-16, 16px);
  /* May 2026 — padding-x dropped 20 → 16 (one tier on the ladder). */
  --amount-card-padding-x:      var(--space-16, 16px);
  --amount-card-gap:            var(--space-12, 12px);
  --amount-card-shadow:         var(--elev-1);

  /* Label slots default to the shared --ui-card-label-* single source (see
     WIDGET CARD-TITLE LABEL above) so this card can't drift from its siblings.
     Per-instance override still works: set --amount-card-label-* inline. */
  --amount-card-label-size:     var(--ui-card-label-size);
  --amount-card-label-weight:   var(--ui-card-label-weight);
  --amount-card-label-color:    var(--ui-card-label-color);

  --amount-card-amount-size:    var(--text-h2-lg);       /* 36px — value-display tier; shared with .ui-limit-price-card + .ui-token-info__price */
  --amount-card-amount-weight:  var(--fw-bold);          /* 700 — value-display tier reads as authoritative; semibold was evaluated May 2026 and reverted. Per-instance override via inline style="--amount-card-amount-weight: var(--fw-semibold)". */
  --amount-card-amount-tracking: var(--tracking-firm);   /* -0.02em — value-display tier, paired with --text-h2-lg */
  --amount-card-amount-lh:      1.1;                     /* unitless ratio — display-tier exception (no token for this pair) */
  --amount-card-amount-color:   var(--text-primary, #111);

  --amount-card-footer-size:    var(--text-caption, 12px);
  --amount-card-footer-color:   var(--text-secondary, #666);

  background: var(--amount-card-bg);
  border-radius: var(--amount-card-radius);
  padding: var(--amount-card-padding-y) var(--amount-card-padding-x);
  display: flex;
  flex-direction: column;
  gap: var(--amount-card-gap);
  box-shadow: var(--amount-card-shadow);
}

.ui-amount-card__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-8, 8px);
  min-height: var(--space-24, 24px);
}

.ui-amount-card__label {
  /* Typography matched to .seg-item's default tier — same size, weight,
     tracking, and line-box. Anywhere the amount-card label sits next to
     a seg-item (e.g., a screen-header scopebar above an amount card)
     the two labels read at the same visual weight. All values are
     tokens; the size + weight ride the configurable slots above so
     consumers can per-instance override via inline style if needed. */
  font-size: var(--amount-card-label-size);
  font-weight: var(--amount-card-label-weight);
  color: var(--amount-card-label-color);
  letter-spacing: var(--ui-card-label-tracking);
  line-height: var(--ui-card-label-lh);  /* 20px line-box at 14px — shared --ui-card-label-* */
  margin: 0;
}

.ui-amount-card__trailing {
  display: inline-flex;
  align-items: center;
  gap: var(--space-4, 4px);
  flex-wrap: nowrap;
}

.ui-amount-card__body {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-12, 12px);
  min-height: var(--space-40, 40px);
}

.ui-amount-card__amount {
  flex: 1;
  min-width: 0;
  border: 0;
  background: transparent;
  font-family: inherit;
  font-size: var(--amount-card-amount-size);
  font-weight: var(--amount-card-amount-weight);
  font-variant-numeric: tabular-nums;
  letter-spacing: var(--amount-card-amount-tracking);
  line-height: var(--amount-card-amount-lh);
  color: var(--amount-card-amount-color);
  outline: none;
  padding: 0;
  width: 100%;
}
.ui-amount-card__amount::placeholder { color: var(--field-placeholder); }
/* Read-only static amount (Receive card) — same scale as the input. */
span.ui-amount-card__amount,
div.ui-amount-card__amount {
  display: inline-block;
  white-space: nowrap;
}
/* Ellipsis backstop — a derived amount whose adaptive-precision string is
   still wider than the slot truncates instead of bleeding into the token
   chip. Works whether or not the tooltip wrap exists (the slot keeps
   flex: 1 + min-width: 0 in both cases). The rounded-away digits stay
   recoverable on hover/focus via .ui-amount-card__amount-tip below +
   setRecvAmount() in ui-amount-card.js. */
.ui-amount-card__amount {
  overflow: hidden;
  text-overflow: ellipsis;
}
/* When the amount is wrapped for a full-precision tooltip, the .tooltip-wrap
   takes over the flex sizing so the amount can shrink + clip inside it. */
.ui-amount-card__amount-tip {
  flex: 1;
  min-width: 0;
}
.ui-amount-card__amount-tip > .ui-amount-card__amount {
  width: 100%;
}

.ui-amount-card__footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-8, 8px);
  font-size: var(--amount-card-footer-size);
  line-height: var(--lh-caption, 1.4);
  color: var(--amount-card-footer-color);
  min-height: var(--space-16, 16px);
}

.ui-amount-card__fiat {
  display: inline-flex;
  align-items: center;
  gap: var(--space-4, 4px);
  font-variant-numeric: tabular-nums;
}

.ui-amount-card__fiat-icon {
  width: var(--space-12, 12px);
  height: var(--space-12, 12px);
  color: var(--text-tertiary, #999);
  cursor: pointer;
}

.ui-amount-card__footer-meta {
  display: inline-flex;
  align-items: center;
  gap: var(--space-8, 8px);
}

/* Balance / price-impact slot — used in either card's footer right slot
   to render a meta value ("/ 100.50", "Price impact: 0.10%"). Inherits
   --amount-card-footer-color from .ui-amount-card__footer so it matches
   the fiat slot on the left — both sides of the footer read as one muted
   row. May 2026: was --text-primary + medium weight per an earlier
   Figma; unified with the fiat slot at the user's request so the footer
   reads as a single calm tier of metadata. Weight stays medium so labels
   still feel like meta-data rather than primary copy.

   Inline-flex hosts both the slash-prefixed amount run AND the info
   trigger that sits next to it (.tooltip-wrap + .btn-icon). The
   trigger surfaces a tooltip with full-precision balance, USD value,
   and decimal places — the longform context the compact slash-amount
   omits. Slash-amount stays the dominant read; the info icon is the
   quiet affordance to drill in. */
.ui-amount-card__balance {
  display: inline-flex;
  align-items: center;
  gap: 0;
  font-variant-numeric: tabular-nums;
  font-weight: var(--fw-medium);
}
/* State modifiers on .ui-amount-card__balance — drive the ink colour
   for the slot's value run. Weight / size / tracking / line-height stay
   constant per the "state changes shift ink, not weight" rule in
   CLAUDE.md (the slot's typography IS its identity; colour is the
   state signal).

   Consumers today:
     data-state="error" — Send card's balance slot when the typed amount
                          exceeds the wallet balance (set by
                          ui-amount-card.js → renderPair). Also mirrored
                          as aria-invalid="true" on the sibling input for
                          screen readers.

   Dormant — reserved for a future opt-in feature:
     data-state="warn"  — was previously set on the Receive card's
                          price-impact slot when |impact| > 1% and ≤ 3%.
                          Removed May 2026 — price impact now renders at
                          neutral ink across every surface (see
                          design/components/swap.md → "Severity coloring
                          — dormant, future opt-in"). The CSS rule below
                          is intentionally kept so the data-state hook is
                          ready to re-engage when the opt-in lands.
     data-state="error" — was also set on the Receive card's price-impact
                          slot when |impact| > 3%. Same retirement as
                          warn; the rule covers the dormant case via the
                          shared selector. */
.ui-amount-card__balance[data-state="warn"] {
  color: var(--warn);
}
.ui-amount-card__balance[data-state="error"] {
  color: var(--danger);
}

/* ── Insufficient-balance state — slide twins on the Send balance slot ─
   The balance slot keeps a stable height (and a stable width — see the
   grid sizing below) across the default ↔ error transition. Two text
   twins live in the DOM at all times: the default slash-amount
   (.balance-amount) and the error message (.balance-error). Two SVGs
   live inside the trigger button: the info glyph and the X-in-circle
   error glyph. data-state="error" on the outer slot:
     - slides the amount out to the left (translateX(-30%) + opacity 0)
     - slides the error twin in from the right (translateX(60% → 0)
       + opacity 0 → 1)
     - cross-fades the info svg → the error svg

   Width stability: the inner stack is a 1×1 grid sized to max-content,
   so the cell is always as wide as the wider twin (= the error
   message). Both twins right-align inside the cell via justify-items:
   end so default-state "/ 1,000" sits flush against the info icon
   exactly as it did before. The slot's outer width is constant — no
   horizontal layout shift when the state flips.

   The default .balance-amount span gets an explicit --text-secondary
   color so the existing [data-state="error"] color: var(--danger) rule
   above (still load-bearing for the price-impact slot's whole-text
   ink-flip) doesn't bleed into the amount twin during the cross-fade.
   The error twin is self-painting in --danger.

   Reduced motion: opacity-only fades, no translate. */
.ui-amount-card__balance-stack {
  display: grid;
  grid-template-columns: max-content;
  align-items: center;
  justify-items: end;
  overflow: hidden;
}
/* All inner-stack rules are scoped via `.balance-stack > …` because the
   price-impact slot reuses the bare `.balance-amount` class WITHOUT a
   stack wrapper. An unscoped `.balance-amount` rule paired with the
   shared `[data-state="error"]` selector would hide the price-impact
   label whenever |impact| > 3 % — exact bug we tripped on first pass
   in May 2026. Keep the slide / transition / color rules behind the
   stack-child selector so price-impact stays untouched. */
.ui-amount-card__balance-stack > .ui-amount-card__balance-amount,
.ui-amount-card__balance-stack > .ui-amount-card__balance-error {
  grid-area: 1 / 1;
  white-space: nowrap;
  transition: transform 280ms ease, opacity 220ms ease;
}
.ui-amount-card__balance-stack > .ui-amount-card__balance-amount {
  /* Track the footer-color slot rather than binding to --text-secondary
     directly — so when .ui-amount-card--zero overrides
     --amount-card-footer-color to --text-faint, the balance amount
     dims with the rest of the footer row. The parent's
     [data-state="warn|error"] rules set `color` directly (for SVG
     stroke via currentColor on the info icon); the amount-span opts
     out of those by reading the variable, not `inherit`. */
  color: var(--amount-card-footer-color);
}
.ui-amount-card__balance-stack > .ui-amount-card__balance-error {
  color: var(--danger);
  font-weight: var(--fw-medium);
  transform: translateX(60%);
  opacity: 0;
  pointer-events: none;
}
.ui-amount-card__balance[data-state="error"] .ui-amount-card__balance-stack > .ui-amount-card__balance-amount {
  transform: translateX(-30%);
  opacity: 0;
  pointer-events: none;
}
.ui-amount-card__balance[data-state="error"] .ui-amount-card__balance-stack > .ui-amount-card__balance-error {
  transform: translateX(0);
  opacity: 1;
  pointer-events: auto;
}

/* Trigger button — two SVGs stacked, one per state, cross-faded by
   data-state on the outer slot. position: relative on the button so the
   error svg can absolute-position over the info svg. The info svg keeps
   its normal flow so the button's intrinsic box-size is unchanged from
   the single-svg version (no layout shift). The error svg paints in
   --danger directly so it reads as red even before the slot's data-state
   color cascades in. */
.ui-amount-card__balance-trigger {
  position: relative;
}
.ui-amount-card__balance-icon {
  transition: opacity 200ms ease;
}
.ui-amount-card__balance-icon--error {
  position: absolute;
  inset: 0;
  margin: auto;
  width: 1em;
  height: 1em;
  opacity: 0;
  color: var(--danger);
}
.ui-amount-card__balance[data-state="error"] .ui-amount-card__balance-icon--wallet {
  opacity: 0;
}
.ui-amount-card__balance[data-state="error"] .ui-amount-card__balance-icon--error {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .ui-amount-card__balance-stack > .ui-amount-card__balance-amount,
  .ui-amount-card__balance-stack > .ui-amount-card__balance-error,
  .ui-amount-card__balance-icon {
    transition: opacity 120ms ease;
    transform: none;
  }
}

/* ── Size ladder — retired June 2026 (.ui-amount-card--sm / --lg). Only the
   default tier was ever consumed; per-instance sizing now goes through the
   --amount-card-* var surface (inline style). Atomic retirement, no aliases —
   see CLAUDE.md → Primitive retirement is atomic. ─────────────────────── */

/* ── Shape modifier — .ui-amount-card--ghost retired June 2026. Never
   consumed on any surface; per-instance flush composition goes through the
   --amount-card-* var surface (inline style) instead. Atomic retirement,
   no aliases — see CLAUDE.md → Primitive retirement is atomic. ────────── */

/* ── State modifier — zero state ──────────────────────────────────────
   Applied to a card whose value slot is rendering "0" / "$0" / empty
   placeholder — either the wallet is disconnected (legacy trigger) or
   the user simply hasn't typed an amount yet. The widget shows the
   minimum information: the token chip is still pickable, every value
   that renders does so in muted ink, and per-card meta slots that
   have no honest value at amount=0 (e.g. price impact on Receive)
   are emptied via JS so their wrapper collapses to zero size.

   ── Visual recipe ──────────────────────────────────────────────────
   - Amount + fiat + footer-meta ink → var(--text-faint) (≈ 40% alpha;
     same ink as the .muted primitive). Weight, size, tracking,
     line-height stay constant per the "state changes shift ink, not
     weight" rule in CLAUDE.md — value-slot identity IS the weight,
     color is the state signal.
   - Footer-meta visibility is CONTENT-DRIVEN, not CSS-driven. The JS
     empties the slot's children (textContent='') for meta values that
     are dishonest at amount=0 (Receive price impact; Send balance on
     full disconnect). Visible meta values (Send balance "/ X" when
     wallet is connected) cascade the same --text-faint ink via
     --amount-card-footer-color, so the whole "footer row" reads as
     one calm muted tier.
   - Placeholder color in the Send input also flips to --text-faint
     so an empty input renders the same "0" as the static Receive span.

   ── Where this is set ──────────────────────────────────────────────
   ui-amount-card.js → renderEmptyState() adds the class on receive
   (always) + send (when input is empty) during disconnect; renderPair()
   toggles it on each side based on userAmount<=0 in the connected
   branch. The modifier never paints alone — it's always coordinated
   with content reset ("$0" / "0" / empty input). */
.ui-amount-card--zero {
  --amount-card-amount-color: var(--text-faint);
  --amount-card-footer-color: var(--text-faint);
}
.ui-amount-card--zero .ui-amount-card__amount::placeholder {
  color: var(--text-faint);
}

/* ── Reveal-on-hover trigger broadening — whole card, not just header ──
   The universal .chip-shortcuts--reveal-on-hover primitive fires on
   hover of the cluster itself OR a DIRECT ancestor (via `:has(>)` —
   see styles.css → .chip-shortcuts). In the swap widget the cluster
   sits inside .ui-amount-card__header, so by default only the header
   strip triggers the reveal — a thin band right under the card's top
   padding. That hover target is too small to feel intentional; the
   chips look skittish.

   For .ui-amount-card we broaden the trigger to the WHOLE card so any
   pointer movement over the card surface reveals the chips. :focus-
   within is included so keyboard tab-in still works (the cluster's
   own focus-within fires too, but the card-level rule is the one that
   carries the input → chip reveal handoff).

   This rule deliberately uses a descendant combinator (.ui-amount-card
   :hover .chip-shortcuts--reveal-on-hover) so the cluster's depth
   inside the card doesn't matter — header today, anywhere inside the
   card tomorrow if the markup evolves. The universal primitive's
   trigger rules in styles.css still fire (they're more specific in
   some cases via the `:has(>)` ancestor), and the cascade naturally
   layers them — both reveal the chips, only the WHOSE-hover changes. */
.ui-amount-card:hover .chip-shortcuts--reveal-on-hover > .chip,
.ui-amount-card:focus-within .chip-shortcuts--reveal-on-hover > .chip {
  opacity: 1;
  transform: translateX(0);
  pointer-events: auto;
}


/* ── .ui-form-stack — card-stack wrapper inside the swap form ──────────────
   Hosts the entire scrollable body of the form state: the stacked cards
   (limit-price card, amount-pair, inline quote preview, limit-expiry,
   limit mini-list), the conditional alerts (non-EVM warning, off-market
   warning), the action footer (.action-bar — Connect / Review),
   and the Dual variant's .ui-quick-settings. Everything lives here
   so the panel has ONE scroll context — the action CTA scrolls with the
   cards instead of pinning over them. The only intrinsic sibling above
   the stack is the screen-header (which pins to the panel top). See the
   .ui-card[data-state="form"] .ui-form-stack block below for the scroll
   architecture and the action-bar footer's 16-px rhythm bump.

   Cards consume --card-gap (defaults to --gap-card-sm = 8 px). Per the
   Card-gap-ladder rule: publish --card-gap, consume gap: var(--card-gap).
   Per-instance widening via inline `style="--card-gap: ..."`. */
.ui-form-stack {
  --card-gap: var(--gap-card-sm);
  display: flex;
  flex-direction: column;
  gap: var(--card-gap);
}


/* ── .ui-amount-pair — composition of two .ui-amount-card + a swap-FAB ──────
   Wraps the Send + Receive sub-cards and the .ui-swap-arrow FAB so the FAB
   always sits on the SEAM between the cards, regardless of either card's
   height. The FAB lives IN flex flow with negative margin-block; the cards
   meet at the joint and the FAB visually overlaps both equally.

   The visible inter-card gap equals --card-gap (defaults to --gap-card-sm
   = 8 px). Override --card-gap on the pair to widen. To use a different
   FAB size, override --swap-arrow-size on the pair (cascades into the FAB
   inside, which consumes it for its own width/height).

   Math: visible inter-card = 2 × card-gap + (arrow-size + 2 × margin)
         → margin = -(card-gap + arrow-size) / 2   (locks visible = card-gap)

   The seam math reads --swap-arrow-size (the FAB's REST size, not its hover
   size), so when the FAB scales on hover the cards stay put — the FAB just
   overhangs the seam by a few pixels.

   DO NOT switch back to position: absolute + top: 50%. That centers the FAB
   on the midpoint of the pair's TOTAL height, which is only the seam when
   both cards happen to be the same height. The moment a card grows or
   shrinks (zero-balance state, error state, expanded quote details) the
   FAB drifts off the seam. See design-system/index.html#ui-amount-pair.

   May 2026: --pair-gap was renamed to --card-gap (system-wide card-gap
   convention; see styles.css :root → "--gap-card" family).
   ----------------------------------------------------------------------- */
.ui-amount-pair {
  --card-gap: var(--gap-card-sm);
  /* Cascade --swap-arrow-size into the FAB inside so the seam math, the
     FAB's actual width/height, and the card-mask geometry all read from
     ONE source of truth. Must match the FAB's --lg modifier (32 px). */
  --swap-arrow-size: var(--space-32, 32px);
  /* Clearance between the FAB edge and the card's mask cutout edge. The
     mask radius = FAB radius + this value (16 + 4 = 20 px). */
  --swap-arrow-cutout: var(--space-4, 4px);
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--card-gap);
  /* Elevation is hosted on the PAIR, not on the individual cards — the
     cards carry mask-image cutouts (see rules below), and per the CSS
     spec masks are applied AFTER filters, so a per-card drop-shadow gets
     clipped by the cutout chord at the seam edge (leaving a 47-px gap in
     the shadow strip right below the FAB). Painting the filter on the
     unmasked parent renders the shadow around the COMBINED silhouette
     of both masked cards — the cutout reads as a true hole with a
     shadow rim, and the seam-facing edges keep their full shadow. */
  filter: var(--elev-1-filter);
}

/* True cutout — mask each card so the FAB punches a real circular hole
   through it, same technique as .avatar-tandem badge cutouts. The cutout
   region of the card becomes truly transparent, so whatever surface sits
   behind the pair shows through (panel, page, gradient — anything).

   Shadow note: per the CSS spec, masks are applied AFTER filters — so a
   per-card drop-shadow gets clipped by the cutout chord at the seam edge
   (leaving a ~47-px gap in the shadow right below the FAB). The fix is
   to host the elevation on the unmasked .ui-amount-pair parent (see the
   `filter` declaration in the rule above) — the parent's drop-shadow
   paints around the combined silhouette of both masked cards, so the
   cutout reads as a true hole with a shadow rim AND the cards keep
   their full perimeter shadow. The per-card rule below just suppresses
   the cards' built-in box-shadow (which would also be clipped by the
   cutout).

   Geometry:
     The FAB is centered on the seam between the two cards. With --card-gap
     between them and the FAB's negative margin pulling it into the flow,
     the FAB's vertical center lands at:
       top card's bottom edge + card-gap/2
     i.e., right in the middle of the visible inter-card gap.

     Cutout radius = FAB radius + clearance:
       --swap-arrow-size / 2 + --swap-arrow-cutout
       (40/2 + 4 = 24 px with defaults)

     Top card    — mask center at (50%, 100% + card-gap/2)
                   = directly below the card's bottom edge, on the seam
     Bottom card — mask center at (50%, -card-gap/2)
                   = directly above the card's top edge, on the seam

   Crisp edges: `transparent 99.5%, black 100%` — same pattern as
   .avatar-tandem so the cutout edge doesn't antialias into a soft halo. */
.ui-amount-pair > .ui-amount-card:first-of-type,
.ui-amount-pair > .ui-amount-card:last-of-type {
  /* Suppress the cards' own box-shadow — the mask would clip it at the
     seam-facing edge. Elevation is hosted on the .ui-amount-pair parent
     above so the cutout doesn't interrupt the shadow strip. */
  --amount-card-shadow: none;

  /* mask-clip: no-clip lets descendants paint past the card's border-box.
     The default `border-box` clips ALL the masked element's children to
     the card edge — same family of bug as the elevation-vs-mask issue,
     but applied to children instead of box-shadow. Surfaced by the
     `.tooltip` on the balance __info trigger: at its `max-width` the
     tooltip extends past the card's right edge and was being clipped.
     The FAB cutout still works — radial-gradient's "outer black" region
     extends to infinity, so removing the box clip only widens the area
     where descendants are visible, never the area where the card paints. */
  -webkit-mask-clip: no-clip;
          mask-clip: no-clip;
}
.ui-amount-pair > .ui-amount-card:first-of-type {
  -webkit-mask-image: radial-gradient(
    circle calc(var(--swap-arrow-size) / 2 + var(--swap-arrow-cutout))
    at 50% calc(100% + var(--card-gap) / 2),
    transparent 99.5%, black 100%
  );
          mask-image: radial-gradient(
    circle calc(var(--swap-arrow-size) / 2 + var(--swap-arrow-cutout))
    at 50% calc(100% + var(--card-gap) / 2),
    transparent 99.5%, black 100%
  );
}
.ui-amount-pair > .ui-amount-card:last-of-type {
  -webkit-mask-image: radial-gradient(
    circle calc(var(--swap-arrow-size) / 2 + var(--swap-arrow-cutout))
    at 50% calc(var(--card-gap) / -2),
    transparent 99.5%, black 100%
  );
          mask-image: radial-gradient(
    circle calc(var(--swap-arrow-size) / 2 + var(--swap-arrow-cutout))
    at 50% calc(var(--card-gap) / -2),
    transparent 99.5%, black 100%
  );
}

.ui-amount-pair > .ui-swap-arrow {
  /* Surface override — match the .ui-amount-card style so the FAB reads
     as part of the card family rather than a floating glass element.
     Same recipe as the canonical UI card: --surface-card fill, 1 px
     transparent border, --elev-1 shadow. The --glass modifier on the
     markup is no longer needed (this rule overrides its bg / shadow,
     and the markup drops the class).
     (Hover state: the primitive carries `transform: scale(1.06)` + icon
     rotate(180°) — both composited, no layout shift, no seam artifacts.
     An earlier scale-on-hover via width/height animation was retired
     because it DID shift layout; the transform version is safe. The
     cutout is a TRUE mask on the sibling cards above — see the two
     .ui-amount-card mask rules above. The FAB itself carries no cutout
     chrome anymore.) */
  background: var(--surface-card);
  border: 1px solid transparent;
  --swap-arrow-extra-shadow: var(--elev-1);
  align-self: center;
  margin-block: calc((var(--card-gap) + var(--swap-arrow-size)) / -2);
  z-index: 1;
}

/* Tooltip stacking — lift + un-mask the card whose tooltip is active.
   Two problems compose to hide the tooltip behind the FAB:

   1. STACKING. `.ui-amount-pair` carries `filter: var(--elev-1-filter)`
      (the Pattern A shadow-on-unmasked-parent recipe) which establishes
      a stacking context. Inside it:
        .ui-amount-card        → z-index: auto (effective 0)
        .ui-swap-arrow (FAB)   → z-index: 1
        .tooltip               → z-index: 1000, but it competes INSIDE
                                 the card's own context — the card's
                                 effective 0 caps the tooltip below the
                                 FAB's 1.
      Fix: bump the card to z-index: 2 (with position: relative so the
      z-index takes effect). Use :has() so only the active card lifts —
      the OTHER card stays at auto and the FAB still layers above its
      seam cutout.

   2. MASKING. The card carries a `mask-image: radial-gradient(...)` to
      punch a hole through which the FAB peeks. mask-image clips
      EVERYTHING the card paints, including absolutely-positioned
      descendants — so the portion of the tooltip overlapping the cutout
      zone gets erased, and the FAB (under the card) shows through the
      hole instead. Fix: drop the mask while the tooltip is active. The
      card paints solidly for the duration of the hover — and since the
      card is now at z-index: 2, it covers the FAB cleanly. The
      cutout-illusion is briefly suspended; that's the trade for an
      uncliipped tooltip.

   Together the rules make the active card a normal-painting opaque
   surface above the FAB, with the tooltip free to extend anywhere in
   the card's bounding box. */
.ui-amount-pair > .ui-amount-card:has(.tooltip-wrap:hover),
.ui-amount-pair > .ui-amount-card:has(.tooltip-wrap:focus-within) {
  position: relative;
  z-index: 2;
  -webkit-mask-image: none;
          mask-image: none;
}


/* ── Token-picker trigger — composed from .chip + .chip-avatar ───────────
   The swap widget's From / To token chooser is now built from the
   universal chip family (May 2026): `.chip.chip-neutral.chip-avatar.chip--lg`
   on a <button> host. The legacy `.ui-token-pill` primitive was retired
   to keep the visual recipe universal — see styles.css → `.chip-avatar`
   and the catalog entry at design-system/index.html#chip-avatar.

   Usage on the swap widget:
     <button class="chip chip-neutral chip-avatar chip--lg" data-role="from">
       <span class="avatar-tandem">
         <img class="avatar avatar--circle" src="…" alt="">
         <img class="avatar-badge avatar-badge--br" src="…" alt="">
       </span>
       USDC
     </button>

   swap.js queries `.widget-stage [data-role="from"], .widget-stage [data-role="to"]`
   to open the picker. The class composition stays universal; only the
   data-role attribute identifies the trigger's side.
   ----------------------------------------------------------------------- */


/* ── Chain-first wizard RETIRED (May 2026) ──────────────────────────────
   The chain-first wizard was retired. The FROM side opens directly to the
   token-picker (with the chain-filter sidebar alongside) in every mode.
   The TO side opens directly to the chain-picker AND ends there ONLY in
   Gas mode — Gas destinations are chain-only because the destination token
   snaps to the chain's native gas (see renderReceiveChip / snapToToNativeGas
   in swap.js). Every other mode (Bridge / Swap / Limit) opens the full
   token-picker on the TO side — Bridge is unconstrained (any token, any
   chain) after the May 2026 mode rename. The default
   `.widget-stage:has(.ui-card[data-state="picker"]) .split-pane--chain-filter`
   rule below applies wherever the token picker is open. */


/* ── .ui-swap-arrow size + chrome modifiers ──────────────────────────────
   Modifiers re-pin the local tokens via CSS-var override. The base rule
   above does the actual rendering — modifiers just shift the inputs.
     --lg       FAB tier (40px button, 16px icon) — the size when the
                button sits at the joint between two stacked sub-cards.
                Pairs with .ui-amount-pair.
     --glass    Translucent surface — Panel Surfaces three-token recipe
                + backdrop-filter. The shadow goes into the composable
                --swap-arrow-extra-shadow slot so it stacks cleanly with
                the cutout ring.
     --vertical Rotates the icon 90° for stacked layouts.
   ----------------------------------------------------------------------- */
.ui-swap-arrow--lg {
  --swap-arrow-size:      var(--space-32, 32px);
  --swap-arrow-icon-size: var(--space-16, 16px);  /* 50 % of the 32 px button — confident proportion */
}
.ui-swap-arrow--glass {
  --swap-arrow-extra-shadow: var(--shadow-panel-glass);
  background: var(--surface-panel-glass);
  backdrop-filter: var(--backdrop-panel-glass);
  -webkit-backdrop-filter: var(--backdrop-panel-glass);
  border: 0;
}
.ui-swap-arrow--vertical {
  /* Override the rotation slots so the base SVG rule consumes the right
     angles — 90° at rest, 270° on hover (90° base + 180° preview-reverse). */
  --swap-arrow-icon-rotate:       90deg;
  --swap-arrow-icon-rotate-hover: 270deg;
}


/* ── .ui-card state machine (data-state) ──────────────────────────────────
   The shell hosts multiple inner content blocks; only the one whose
   data-state-content matches the card's data-state is visible. Default
   state is "form". Phase 2 introduces "picker" (token list, chain list).
   Future phases add "review", "tx-progress".

   Usage:
     <article class="ui-card" data-state="form">
       <div data-state-content="form">…form contents…</div>
       <div data-state-content="picker">…picker contents…</div>
     </article>

   Toggle data-state via swap.js (or any consumer) — pure CSS visibility,
   no animations to keep phase 2 markup-only.
*/
.ui-card > [data-state-content] { display: none; }
.ui-card[data-state="form"]     > [data-state-content="form"],
.ui-card[data-state="picker"]   > [data-state-content="picker"],
.ui-card[data-state="chain-picker"] > [data-state-content="chain-picker"],
.ui-card[data-state="quotes"]   > [data-state-content="quotes"],
.ui-card[data-state="modes"]    > [data-state-content="modes"],
.ui-card[data-state="settings"] > [data-state-content="settings"],
.ui-card[data-state="send-wallet"] > [data-state-content="send-wallet"],
.ui-card[data-state="review"]   > [data-state-content="review"],
.ui-card[data-state="tx-progress"] > [data-state-content="tx-progress"],
.ui-card[data-state="activity"] > [data-state-content="activity"],
.ui-card[data-state="tx-details"] > [data-state-content="tx-details"] {
  display: flex;
  flex-direction: column;
  gap: var(--space-16, 16px);
  animation: ui-card-fade-in .18s ease-out;
}
/* Form + picker + chain-picker states — participate in the card's flex
   envelope so the inner scroll surface can scroll when the card hits its
   --widget-max-h cap. `flex: 1 1 auto` + `min-height: 0` lets state-content
   fill available card space when the cap is on (picker scrolls, form's
   inner .ui-form-stack scrolls) and grow to content when the cap is off
   (Auto mode — pure-grow, page scrolls). Form was previously intrinsic-
   sized; Limit mode (May 2026 v2) added enough cards to the form stack
   (limit price card + amount pair + expires-in + quotes + alerts) that it
   regularly exceeds the cap, so form now joins the picker pattern. Review
   joins too — with both disclosures (provider step + fee breakdown) expanded
   the summary + fee cards exceed the cap, so .ui-review__body owns the scroll
   while the header + action footer pin. tx-progress joins too — its status
   hero fills the middle (centered) so the header pins above and the Done CTA
   pins below. */
.ui-card[data-state="form"] > [data-state-content="form"],
.ui-card[data-state="picker"] > [data-state-content="picker"],
.ui-card[data-state="chain-picker"] > [data-state-content="chain-picker"],
.ui-card[data-state="quotes"] > [data-state-content="quotes"],
.ui-card[data-state="modes"] > [data-state-content="modes"],
.ui-card[data-state="settings"] > [data-state-content="settings"],
.ui-card[data-state="send-wallet"] > [data-state-content="send-wallet"],
.ui-card[data-state="review"] > [data-state-content="review"],
.ui-card[data-state="tx-progress"] > [data-state-content="tx-progress"],
.ui-card[data-state="activity"] > [data-state-content="activity"],
.ui-card[data-state="tx-details"] > [data-state-content="tx-details"] {
  flex: 1 1 auto;
  min-height: 0;
}

/* Form state — interior scroll architecture. The .ui-form-stack owns the
   scroll; the only intrinsic sibling is the screen-header above, which
   pins to the top of the panel. EVERY other piece of form content — the
   stacked cards, the conditional alerts (non-EVM warning, off-market
   warning), the action footer (Connect / Review), and the Dual
   variant's quick-settings rail — lives INSIDE .ui-form-stack and
   scrolls together as one unified body when the panel hits its
   --widget-max-h cap. Same clip-and-host pattern as the FAB sub-panel —
   see CLAUDE.md → "Sub-panel scroll architecture".

   This is a deliberate move (May 2026): the prior architecture pinned
   the action footer + quick-settings below the form-stack's overflow
   region, which caused the CTA to visually cover the bottom of the
   scrolling cards inside it. Folding action + quick into the scroll
   container — one view, one scrollbar, no cover-up — is the new
   default across every layout variant.

   The horizontal bleed (margin-inline calc(-24) + matching padding-inline)
   pushes the scrollbar into the panel's 24 px gutter so it doesn't crop
   the cards' right edges. Cards keep their full content width; the
   scrollbar sits where the panel's padding would otherwise live. Bottom
   padding inside the bleed adds a soft fade space so the last child
   (action button, or quick-settings in Dual) doesn't read as cropped
   against the panel edge. */
.ui-card[data-state="form"] .ui-form-stack {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  margin-inline: calc(-1 * var(--space-24));
  padding-inline: var(--space-24);
  padding-bottom: var(--space-8);
  /* Anchor scroll-into-view a little below the screen-header in case
     focus jumps to a card. */
  scroll-padding-top: var(--space-8);
}

/* Action footer — sits inside .ui-form-stack as the last intrinsic
   child (in Dual it follows the quick-settings rail — the buttons stay
   the final decision point below the settings summary; everywhere else
   it's the last visible child). The form-stack's --card-gap (8 px) is
   appropriate between adjacent CARDS but too tight as the rhythm above a
   primary CTA. Add 8 px margin-top so the visual gap above the action
   lifts to 16 px — matching the panel's outer breathing rhythm. */
.ui-card[data-state="form"] .ui-form-stack > .action-bar {
  margin-top: var(--space-8);
}
@keyframes ui-card-fade-in {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  .ui-card[data-state] > [data-state-content] { animation: none; }
}

/* Modes screen — show the trailing-slot check svg ONLY on the row whose
   data-mode matches the active mode. applyMode() toggles aria-checked
   on every .setting-row[data-mode]; the column itself stays reserved so
   row widths remain uniform across selected / non-selected. */
[data-state-content="modes"] .setting-row[data-mode] .setting-row__value svg {
  visibility: hidden;
}
[data-state-content="modes"] .setting-row[data-mode][aria-checked="true"] .setting-row__value svg {
  visibility: visible;
}


/* ── Review state (.ui-review*) — pre-execution confirmation screen ─────────
   Lives in the form .ui-card as data-state="review" (state-machine wiring at
   the top of this file; the receive panel collapses via the :has() rule near
   line ~3450). The summary card IS a .ui-tx-summary composition (June 2026
   convergence — the review card, the tx-progress pair/Received cards, the
   Activity list, and Transaction details all share ONE from/↓/to slot
   vocabulary; the review-only .ui-review-row / .ui-review-summary__* /
   .ui-review-detail* glue was retired into the .ui-tx-summary__* slots,
   atomic, no aliases). Only the screen scroll body + the metric-pill svg
   sizing live here. Populated by swap.js → initReview(). */
/* Scroll body — header pins above, action footer pins below, this scrolls
   when the panel hits its --widget-max-h cap. Same clip-and-host pattern as
   .ui-form-stack: the horizontal bleed pushes the scrollbar into the panel's
   24 px gutter so card right-edges aren't cropped. */
.ui-review__body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--space-16);
  margin-inline: calc(-1 * var(--space-24));
  padding-inline: var(--space-24);
  padding-bottom: var(--space-8);
}
/* Est-time + gas metric icons — size the bare svg to the caption tier so the
   pill reads at the same scale as the quote-card footer metrics. The
   __foot selector covers the slot wherever it composes (Review, the
   Transaction-details card, catalog previews). */
.ui-review .ui-quote-metric > svg,
.ui-tx .ui-quote-metric > svg,
.ui-tx-summary__foot .ui-quote-metric > svg {
  width: var(--space-16);
  height: var(--space-16);
  color: var(--text-muted);
  flex-shrink: 0;
}

/* ── tx-progress (.ui-tx*) — execution status screen ───────────────────────
   data-tx-outcome on the pane (running | success | partial | failed) is the
   single visual-state switch: it picks the hero mark (spinner ring vs tone
   disc), the lower card (from/↓/to pair vs Received), and the footer pair
   (See details + Done vs Remove + Retry). All composition — .card, .spinner,
   .spot-icon-4xl --round + tone, .status-row, .ui-card-label, .action-bar;
   this block is layout glue + the outcome gating. Wired in swap.js →
   runTxProgress(). */
.ui-tx__body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--space-16);
  margin-inline: calc(-1 * var(--space-24));
  padding-inline: var(--space-24);
  padding-bottom: var(--space-8);
}
/* Status card — centered hero column; steps re-assert left alignment. */
.ui-tx-status {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-16);
  text-align: center;
  padding-block: var(--space-24) var(--space-16);
}
/* Hero mark — 80px ring while running, 80px tone disc on outcome. The
   spinner's stroke steps up so the ring doesn't read hairline at 4xl scale. */
.ui-tx-status__spinner {
  --spinner-size: var(--space-80);
  --spinner-stroke: var(--space-4);
}
.ui-tx-status__text {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}
.ui-tx-status__title {
  font-size: var(--text-body-lg);
  font-weight: var(--fw-semibold);
  color: var(--text-primary);
}
.ui-tx-status__desc {
  margin: 0;
  font-size: var(--text-sm);
  line-height: var(--lh-sm);
  color: var(--text-secondary);
}
.ui-tx-status__desc[hidden] { display: none; }
/* Step checklist — progressive .status-row stack; full width, left-aligned
   against the card's centered text. */
.ui-tx-status__steps {
  display: flex;
  flex-direction: column;
  gap: var(--space-8);
  width: 100%;
  text-align: left;
}
.ui-tx-status__steps[hidden] { display: none; }
.ui-tx-status__steps .status-row__actions {
  display: inline-flex;
  align-items: center;
  gap: var(--space-4);
}
/* Step labels stay single-line (mock parity) — a long address short-form
   ellipsizes instead of wrapping the row to two lines. Scoped to the two
   tx checklists, not the base .status-row (other consumers may want wrap). */
.ui-tx-status__steps .status-row__label,
.ui-tx-receipts .status-row__label {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Slightly denser checklist rows than the base .status-row — the widget's
   416px column needs the extra ~16px so the wallet row's address short-form
   ("Sent to wallet: 0x____…____" + copy + explorer) fits on one line. */
.ui-tx-status__steps .status-row,
.ui-tx-receipts .status-row {
  gap: var(--space-8);
  padding-inline: var(--space-12);
}
/* Received + pair cards — flex-column stacks. */
.ui-tx-received,
.ui-tx-pair {
  display: flex;
  flex-direction: column;
}
.ui-tx-received { gap: var(--space-8); }
/* ── Outcome gating ── */
/* Hero mark: hide all four, re-show the active one (source order wins). */
.ui-tx .ui-tx-status__icon > .spinner,
.ui-tx .ui-tx-status__icon > .spot-icon { display: none; }
.ui-tx[data-tx-outcome="running"] .ui-tx-status__spinner { display: inline-flex; }
.ui-tx[data-tx-outcome="success"] [data-tx-disc="success"],
.ui-tx[data-tx-outcome="partial"] [data-tx-disc="partial"],
.ui-tx[data-tx-outcome="failed"]  [data-tx-disc="failed"]  { display: flex; }
/* Lower card: pair while running / failed; Received on success / partial. */
.ui-tx .ui-tx-received,
.ui-tx .ui-tx-pair { display: none; }
.ui-tx[data-tx-outcome="success"] .ui-tx-received,
.ui-tx[data-tx-outcome="partial"] .ui-tx-received,
.ui-tx[data-tx-outcome="running"] .ui-tx-pair,
.ui-tx[data-tx-outcome="failed"]  .ui-tx-pair { display: flex; }
/* Footer: none while running; done-pair on success / partial; failed-pair on
   failed. Later-file source order beats .action-bar's base display: flex. */
.ui-tx .action-bar { display: none; }
.ui-tx[data-tx-outcome="success"] .ui-tx-actions--done,
.ui-tx[data-tx-outcome="partial"] .ui-tx-actions--done,
.ui-tx[data-tx-outcome="failed"]  .ui-tx-actions--failed { display: flex; }


/* ── .ui-tx-summary — transaction summary card (Activity + Transaction details) ──
   Product-surface primitive (tier-3 .ui-*) shared by two screens:
   - Activity list items — add .ui-tx-summary--interactive (opens tx-details)
   - The Transaction-details top card — base (non-interactive)
   Composes .card (surface), .list-item (from / to value rows), .avatar-tandem
   (token + chain badge only — one-badge rule, no --bl provider badge),
   .detail-list (collapsible Route breakdown).
   The chevron toggle reveals the Route inline in BOTH screens; --interactive
   adds card-level navigation on top. Per CLAUDE.md → Card root element, the
   interactive root is a <div role="button">, NOT a <button>, so it can host
   the chevron <button> child without the parser auto-closing the outer button.
   Populated by swap.js → initActivity(). */
.ui-tx-summary {
  display: flex;
  flex-direction: column;
  gap: var(--space-12);
}
/* Head — date (left) + time (right), quiet caption tier. */
.ui-tx-summary__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-12);
  font-size: var(--text-caption);
  line-height: var(--lh-caption);
  color: var(--text-muted);
}
.ui-tx-summary__time { font-variant-numeric: tabular-nums; }
/* Flow — from row, ↓ connector, to row stacked. */
.ui-tx-summary__flow {
  display: flex;
  flex-direction: column;
}
/* Value rows sit flush with the card edge — cancel .list-item inline padding
   so the avatar aligns under the card's content edge. The __row / __amount /
   __connector slots also compose standalone inside the other flow cards
   (.ui-tx-pair, .ui-tx-received) — one slot vocabulary for every from/↓/to
   surface (June 2026 convergence; .ui-review-row et al. retired). */
.ui-tx-summary__row { padding-inline: 0; }
/* Amount is the row headline — display tier. State changes shift ink not
   weight, so weight stays constant across rows. */
.ui-tx-summary__amount {
  font-size: var(--text-h3);
  line-height: 1.2;
  letter-spacing: var(--tracking-tight);
}
/* ↓ connector — centered under the lead avatar column (avatar-tandem--lg = 48px). */
.ui-tx-summary__connector {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--space-48);
  height: var(--space-16);
}
.ui-tx-summary__connector svg {
  width: var(--space-16);
  height: var(--space-16);
  color: var(--text-muted);
}
/* Chevron toggle — flips the Route detail. Standalone rotate property (doesn't
   collide with transform); .15s ease per the hover-transition contract. */
.ui-tx-summary__toggle svg { transition: rotate .15s ease; }
.ui-tx-summary__toggle[aria-expanded="true"] svg { rotate: 180deg; }
/* Route detail — collapsible. .detail-list sets its own grid display which
   outranks [hidden]; re-assert collapse explicitly. */
.ui-tx-summary__detail {
  display: flex;
  flex-direction: column;
  gap: var(--space-12);
  padding-top: var(--space-12);
  border-top: 1px solid var(--border-subtle);
}
.ui-tx-summary__detail[hidden] { display: none; }
/* Detail fee rows hide when the route carries no such fee (review screen);
   .detail-list__row's own display: flex outranks the [hidden] UA rule —
   re-assert (recurring trap). */
.ui-tx-summary__detail .detail-list__row[hidden] { display: none; }
.ui-tx-summary__detail-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-12);
}
.ui-tx-summary__detail-label {
  font-size: var(--text-sm);
  font-weight: var(--fw-semibold);
  color: var(--text-primary);
}
.ui-tx-summary__detail-provider {
  width: var(--space-20);
  height: var(--space-20);
  border-radius: var(--r-full);
  flex-shrink: 0;
}
/* Route avatar stack in the detail head (review screen) — quote-card-scale
   20px marks, matching __detail-provider's single-mark size. */
.ui-tx-summary__detail-head .avatar-stack .avatar {
  --avatar-size: var(--space-20);
}
/* Foot — rate (left) + gas / eta metric pills (right). Consumed by the
   Review screen (rate + gas + eta) and the Transaction-details card
   (rate + gas, per the Exchange Completed V2 mock); Activity list omits it. */
.ui-tx-summary__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-12);
}
.ui-tx-summary__rate {
  font-size: var(--text-sm);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}
.ui-tx-summary__foot-trail {
  display: inline-flex;
  align-items: center;
  gap: var(--space-12);
}
/* Interactive variant — whole card opens tx-details. Canonical UI card hover
   (3% accent + elev-2). The chevron stops propagation in JS so it toggles
   without navigating. */
.ui-tx-summary--interactive {
  cursor: pointer;
  text-align: left;
  font-family: var(--font-sans);
  color: inherit;
  width: 100%;
  transition: background .2s ease, box-shadow .2s ease, border-color .2s ease;
}
.ui-tx-summary--interactive:hover {
  background: color-mix(in oklch, var(--accent-primary) 3%, var(--surface-card));
  box-shadow: var(--elev-2);
}
.ui-tx-summary--interactive:focus-visible {
  outline: 2px solid var(--accent-primary-mid);
  outline-offset: 2px;
}


/* ── Activity + Transaction-details screen glue ─────────────────────────────
   Two .ui-card states (activity, tx-details) wired in swap.js → initActivity().
   Layout glue only — both compose .card + .ui-tx-summary + .detail-list. The
   scroll bodies use the same clip-and-host pattern as .ui-review__body. */
.ui-activity__body,
.ui-tx-details__body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--space-16);
  margin-inline: calc(-1 * var(--space-24));
  padding-inline: var(--space-24);
  padding-bottom: var(--space-8);
}
/* Activity list — stack of interactive summary cards. Card-to-card gap reaches
   the card-gap ladder (lg = 16), not a raw --space value. */
.ui-activity-list {
  --card-gap: var(--gap-card-lg);
  display: flex;
  flex-direction: column;
  gap: var(--card-gap);
}
.ui-activity-empty { margin: var(--space-40) 0; }

/* Card section heading — "Receipts" block title (Transfer ID uses its own
   inline __title in the head). */
.ui-tx-section-title {
  font-size: var(--text-sm);
  font-weight: var(--fw-semibold);
  color: var(--text-primary);
}

/* Receipts — a stack of canonical .status-row confirmation rows (success-toned
   for completed steps, info-toned for the destination-wallet row). No bespoke
   row primitive — .status-row already owns the icon + label + action shape and
   is documented as "used in widget completion screens and any tx-confirmation
   flow". This wrapper is just the vertical stack rhythm. */
.ui-tx-receipts { display: flex; flex-direction: column; gap: var(--space-8); }
/* The destination-wallet row carries two trailing actions (copy + explorer);
   .status-row's grid is icon / label / auto, so the pair shares one auto slot. */
.ui-tx-receipts .status-row__actions {
  display: inline-flex;
  align-items: center;
  gap: var(--space-4);
}

/* Transfer ID — title + copy / external / Contact-support actions, then the
   full hash in mono below. */
.ui-tx-transfer__head {
  display: flex;
  align-items: center;
  gap: var(--space-8);
}
.ui-tx-transfer__title {
  margin-right: auto;
  font-size: var(--text-sm);
  font-weight: var(--fw-semibold);
  color: var(--text-primary);
}
.ui-tx-transfer__hash {
  margin-top: var(--space-12);
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  line-height: var(--lh-body);
  color: var(--text-secondary);
  word-break: break-all;
}


/* ── .ui-settings inner state machine (data-settings-screen) ──────────────
   Parallel-but-nested state machine for the settings flow. The outer
   .ui-card adds ONE state ("settings") — inside that, .ui-settings hosts
   the main list and 7 sub-screens. Same shape as .ui-card's state machine
   (one attribute selects which [data-settings-content] sibling is visible)
   but scoped so adding/removing settings screens doesn't churn the outer
   widget state list.

   Markup:
     <article class="ui-card" data-state="settings">
       <div data-state-content="settings">
         <div class="ui-settings" data-settings-screen="main">
           <div data-settings-content="main">…header + setting-stack…</div>
           <div data-settings-content="appearance">…</div>
           …
         </div>
       </div>
     </article>

   Each [data-settings-content] is itself a flex column so its inner
   screen-header pins at the top and the .setting-stack below it owns the
   scroll. Same clip-and-host pattern as .ui-form-stack inside the form
   state (see CLAUDE.md → "Sub-panel scroll architecture"). */
.ui-settings {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
}
.ui-settings > [data-settings-content] { display: none; }
.ui-settings[data-settings-screen="main"]            > [data-settings-content="main"],
.ui-settings[data-settings-screen="appearance"]      > [data-settings-content="appearance"],
.ui-settings[data-settings-screen="language"]        > [data-settings-content="language"],
.ui-settings[data-settings-screen="route-priority"]  > [data-settings-content="route-priority"],
.ui-settings[data-settings-screen="route-type"]      > [data-settings-content="route-type"],
.ui-settings[data-settings-screen="gas-price"]       > [data-settings-content="gas-price"],
.ui-settings[data-settings-screen="slippage"]        > [data-settings-content="slippage"],
.ui-settings[data-settings-screen="bridges"]         > [data-settings-content="bridges"],
.ui-settings[data-settings-screen="exchanges"]       > [data-settings-content="exchanges"] {
  display: flex;
  flex-direction: column;
  gap: var(--space-16);
  flex: 1 1 auto;
  min-height: 0;
  animation: ui-card-fade-in .18s ease-out;
}
@media (prefers-reduced-motion: reduce) {
  .ui-settings > [data-settings-content] { animation: none; }
}
/* Each settings screen's .setting-stack is the scroller. Bleed margins
   put the scrollbar in the card's 24 px gutter so rows keep their full
   content width. Soft bottom padding so the last row doesn't read as
   cropped against the card edge. */
.ui-settings .setting-stack {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  margin-inline: calc(-1 * var(--space-24));
  padding-inline: var(--space-24);
  padding-bottom: var(--space-8);
  scroll-padding-top: var(--space-8);
}


/* ═══════════════════════════════════════════════════════════════════════
   SEND-TO-WALLET — destination-wallet picker + the .ui-destination-wallet-card
   affordance + the confirm / bookmark sheets.
   --------------------------------------------------------------------------
   Reached from the action-footer wallet icon (data-action="open-send-wallet").
   The picker is a card sub-screen (.ui-send-wallet, parallel to .ui-settings);
   the chosen address renders as .ui-destination-wallet-card in the form stack,
   which hides the footer trigger (the card IS the affordance). Confirm +
   Bookmark are .ui-wallet-confirm compositions over .modal.
   JS: swap.js → initSendWallet(). Catalog: #ui-destination-wallet-card.
   ═══════════════════════════════════════════════════════════════════════ */

/* ── .ui-send-wallet — inner state machine (mirror of .ui-settings) ────────
   Outer .ui-card adds ONE state ("send-wallet"); inside it .ui-send-wallet
   hosts a main view + 3 sub-screens (recent / connected / bookmarked), one
   [data-send-wallet-content] visible per the data-send-wallet-screen attr.
   Same clip-and-host scroll pattern as .ui-settings. */
.ui-send-wallet {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
}
.ui-send-wallet > [data-send-wallet-content] { display: none; }
.ui-send-wallet[data-send-wallet-screen="main"]       > [data-send-wallet-content="main"],
.ui-send-wallet[data-send-wallet-screen="recent"]     > [data-send-wallet-content="recent"],
.ui-send-wallet[data-send-wallet-screen="connected"]  > [data-send-wallet-content="connected"],
.ui-send-wallet[data-send-wallet-screen="bookmarked"] > [data-send-wallet-content="bookmarked"] {
  display: flex;
  flex-direction: column;
  gap: var(--space-16);
  flex: 1 1 auto;
  min-height: 0;
  animation: ui-card-fade-in .18s ease-out;
}
@media (prefers-reduced-motion: reduce) {
  .ui-send-wallet > [data-send-wallet-content] { animation: none; }
}

/* Main-view body + sub-screen lists are the scrollers. Bleed margins put the
   scrollbar in the card's 24 px gutter (same recipe as .ui-settings). */
.ui-send-wallet__body,
.ui-send-wallet__list {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  margin-inline: calc(-1 * var(--space-24));
  padding-inline: var(--space-24);
  padding-bottom: var(--space-8);
}
.ui-send-wallet__body { gap: var(--space-16); }
.ui-send-wallet__list { gap: var(--space-2); }

/* Add-bookmark CTA pins below the bookmarked list (the list flexes to fill). */
.ui-send-wallet__footer { flex: 0 0 auto; }

/* Freeform entry — paste an address / ENS, then Done. Card surface so it
   reads as a distinct input group above the quick-access lists. The Done +
   bookmark row uses .action-bar's lead-primary auto-detect (Done fills, the
   bookmark icon stays a 40 px square). */
.ui-send-wallet__entry {
  display: flex;
  flex-direction: column;
  gap: var(--space-12);
  padding: var(--space-16);
  background: var(--surface-card);
  border-radius: var(--card-radius);
  box-shadow: var(--elev-1);
}
.ui-send-wallet__entry [data-send-wallet-feedback] { margin: 0; }
[data-send-wallet-feedback][data-tone="error"],
[data-bookmark-feedback][data-tone="error"],
[data-destination-feedback][data-tone="error"] { color: var(--danger); }

/* .ui-wallet-avatar retired June 2026 → standard .avatar-tandem (wallet-provider
   logo + chain badge --br). Both consumers (send-wallet list rows in swap.js,
   the destination card in playground.html) migrated atomically; see CLAUDE.md →
   "Primitive retirement is atomic". The tandem owns sizing + corner cutout, so
   no bespoke tile/glyph/ring rules are needed here. */

/* ── .ui-destination-wallet-card — the selected destination on the form ────
   Canonical UI card recipe (surface-card + elev-1 + card-radius). Two-tier:
   a "Receiving wallet" label over the identity row (clickable → re-opens the
   picker) + a clear ×. Hidden by default; revealed — and the footer wallet
   trigger hidden — under .ui-card[data-destination-set] (the card IS the
   affordance, so showing both would be redundant). */
.ui-destination-wallet-card {
  /* Canonical UI card recipe — mirrors .ui-amount-card's local-slot shape so
     the destination card reads as the SAME family in the form stack (not a
     lookalike). Slots default to the system tokens, overridable per-instance.
     radius + surface flow from --card-radius / --surface-card, so the
     playground corner + palette controls drive it with zero adapter code.

     Elevation uses box-shadow: var(--elev-1) — the CANONICAL elevation method.
     The destination card is NOT masked, so it uses the same method as every
     other card in the system (box-shadow is 63:1 the default). The masked
     .ui-amount-pair above it is the lone exception that MUST use
     filter: var(--elev-1-filter) (box-shadow is clipped by its FAB-cutout
     mask); that token is value-matched to --elev-1, so the masked pair and
     this box-shadow card render the same shadow. See CLAUDE.md →
     "mask-image clips box-shadow" for the canonical contract. */
  --dest-card-bg:      var(--surface-card);
  --dest-card-radius:  var(--card-radius);
  --dest-card-padding: var(--space-16);
  --dest-card-gap:     var(--space-12);
  --dest-card-shadow:  var(--elev-1);
  display: none;
  flex-direction: column;
  gap: var(--dest-card-gap);
  padding: var(--dest-card-padding);
  background: var(--dest-card-bg);
  border-radius: var(--dest-card-radius);
  box-shadow: var(--dest-card-shadow);
}
.ui-card[data-destination-set] .ui-destination-wallet-card { display: flex; }
.ui-card[data-destination-set] [data-send-wallet-trigger] { display: none; }

/* ── Private mode — receiving wallet is MANDATORY ──────────────────────────
   Houdini-routed Private swaps send output to a user-specified address (that's
   the privacy mechanism), so the receiving-wallet card is ALWAYS present once
   the wallet is connected — even empty (the required zero-state below). The
   footer "send to a different wallet" trigger is redundant in Private (the
   always-on card IS the entry point), so hide it too. Pre-connect the Connect
   CTA still leads — matching the cold-load-disconnected default; the card
   appears only after connection. See swap.js → syncPrivateGate. */
body[data-widget-mode="private"][data-playground-wallet-connected="true"] .ui-destination-wallet-card { display: flex; }
body[data-widget-mode="private"][data-playground-wallet-connected="true"] [data-send-wallet-trigger] { display: none; }

/* Required zero-state — Private + connected + no address yet. syncPrivateGate
   sets [data-required]. The prompt reuses the token-picker "Select" pill: a
   right-aligned .chip-avatar (placeholder tandem + "Enter") that opens
   the same send-wallet flow. The pill auto-mutes its label via the shared
   .chip-avatar:has(.avatar-tandem--placeholder) rule, so it reads exactly like
   the amount-card "Select" pill. The set-state __body (identity + clear ×) is
   hidden in this state — the pill IS the affordance, so there's nothing to
   clear, hence nothing to its right. The Review CTA stays disabled until an
   address is set (button-family :disabled treatment, driven by
   syncPrivateGate). */
/* Zero-state entry row — inline address input (left) + "Select" pill (right),
   mirroring .ui-amount-card__body so the destination card reads as the same
   family. Hidden outside the required zero-state; the set-state __body is
   hidden inside it. */
.ui-destination-wallet-card__entry { display: none; }
.ui-destination-wallet-card[data-required] .ui-destination-wallet-card__entry {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-12);
  min-height: var(--space-40);
}
.ui-destination-wallet-card[data-required] .ui-destination-wallet-card__body { display: none; }
.ui-destination-wallet-card__enter { flex: 0 0 auto; }

/* Trailing affordance swap — empty input shows the "Select" pill (opens the
   picker); once the input has content (typed/pasted) it swaps for the clear ×,
   mirroring the set-state __clear. Content-driven via :has(:not(:placeholder-shown)),
   no JS state flag (CLAUDE.md → auto-detect content-driven state via :has()). */
.ui-destination-wallet-card__input-clear { flex: 0 0 auto; display: none; color: var(--text-muted); }
.ui-destination-wallet-card__entry:has(.ui-destination-wallet-card__input:not(:placeholder-shown)) .ui-destination-wallet-card__enter { display: none; }
.ui-destination-wallet-card__entry:has(.ui-destination-wallet-card__input:not(:placeholder-shown)) .ui-destination-wallet-card__input-clear { display: inline-flex; }

/* Inline address field — bare (the card is the chrome), reads at the set-state
   name size so a typed/pasted address sits where the resolved name will land.
   Placeholder uses the canonical field-placeholder ink. */
.ui-destination-wallet-card__input {
  flex: 1 1 auto;
  min-width: 0;
  border: 0;
  background: transparent;
  font-family: var(--font-sans);
  font-size: var(--text-body);
  color: var(--text-primary);
  letter-spacing: var(--tracking-snug);
  line-height: 1.35;
  outline: none;
  padding: 0;
}
.ui-destination-wallet-card__input::placeholder { color: var(--field-placeholder); }

/* Validation feedback (invalid paste/entry) — same recipe as the send-wallet
   screen's feedback line; danger tone via [data-tone="error"] (below). Margin
   zeroed (the card's flex gap owns the spacing); hard-hidden outside the
   zero-state as a backstop to the `hidden` attribute. */
.ui-destination-wallet-card__feedback { margin: 0; }
.ui-destination-wallet-card:not([data-required]) .ui-destination-wallet-card__feedback { display: none; }

/* ── Limit mode — no receiving-wallet flow ─────────────────────────────────
   A limit order fills to the connected wallet by definition; routing the
   output to a different address doesn't apply. Unlike Swap / Bridge (where
   the send-to-wallet flow is offered) or Private (where it's mandatory),
   Limit has no receiving-wallet concept at all — so hide BOTH surfaces: the
   footer trigger AND the destination card (the latter even if an address was
   set in another mode before switching to Limit). The send-wallet screen is
   only reachable via those two entry points, so hiding them removes the flow
   entirely. Specificity (0,3,1) on each beats the [data-destination-set]
   (0,3,0) and connected-wallet (0,2,1) rules that would otherwise reveal
   them. */
body[data-widget-mode="limit"] .action-bar [data-send-wallet-trigger] { display: none; }
body[data-widget-mode="limit"] .ui-card .ui-destination-wallet-card { display: none; }

/* Label row — "Receiving wallet" with the optional "Required" marker sitting
   directly beside it (flex-start + 8 px gap), so the marker reads as part of
   the label rather than floating at the far edge. The 24 px min-height is the
   .chip tier, so the row height holds whether or not the marker is shown. */
.ui-destination-wallet-card__header {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: var(--space-8);
  min-height: var(--space-24);
}
/* "Required" marker — for mandatory-destination flows (Private Swap). A warn
   chip carried in the markup but revealed only in the required zero-state;
   hidden once an address is set, and simply omitted from the markup in flows
   where the destination is optional. */
.ui-destination-wallet-card__required { display: none; }
.ui-destination-wallet-card[data-required] .ui-destination-wallet-card__required { display: inline-flex; }

/* Disabled Review CTA — driven by the Private-mode required-address gate
   (syncPrivateGate). The disabled treatment was promoted June 2026 from this
   widget-scoped band-aid to the canonical button family in styles.css (search
   `.btn-primary:disabled`); this scoped rule was retired in the same commit —
   no aliases, per CLAUDE.md → Primitive retirement is atomic. The CTA now
   inherits the family treatment (same values: opacity 0.4, flat, not
   interactive), so nothing is needed here. */

/* .ui-destination-wallet-card__label composes the shared .ui-card-label class
   for its typography (see WIDGET CARD-TITLE LABEL) — no bespoke rule needed.
   This is the exemplar for new cards: add .ui-card-label to the label span. */
.ui-destination-wallet-card__body {
  display: flex;
  align-items: center;
  gap: var(--space-8);
}
/* Identity is the clickable re-open affordance — full UA reset (it's a
   <button>), neutral hover confined to its own box via padding + matching
   negative margin so the row geometry doesn't shift. */
.ui-destination-wallet-card__identity {
  appearance: none;
  -webkit-appearance: none;
  border: 0;
  background: transparent;
  font: inherit;
  font-family: var(--font-sans);
  cursor: pointer;
  text-align: left;
  display: flex;
  align-items: center;
  gap: var(--space-12);
  flex: 1 1 auto;
  min-width: 0;
  padding: var(--space-4);
  margin: calc(-1 * var(--space-4));
  border-radius: var(--space-8);
  transition: background-color .12s ease;
}
.ui-destination-wallet-card__identity:hover {
  background: color-mix(in oklch, var(--text-primary) 4%, transparent);
}
.ui-destination-wallet-card__text {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  min-width: 0;
}
.ui-destination-wallet-card__name {
  font-size: var(--text-body);
  font-weight: var(--fw-semibold);
  color: var(--text-primary);
  letter-spacing: var(--tracking-snug);
  line-height: 1.35;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ui-destination-wallet-card__addr {
  font-size: var(--text-sm);
  color: var(--text-muted);
  line-height: var(--lh-sm);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ui-destination-wallet-card__clear { flex: 0 0 auto; color: var(--text-muted); }

/* ── .ui-wallet-confirm — confirm / bookmark sheet composition over .modal ──
   Centered head (wallet / bookmark spot-icon + title) + a stacked body. The
   spot-icon default bg is theme-broken in light, so the head icon takes the
   same neutral disc the empty-state uses. Both #sendWalletConfirmModal and
   #bookmarkWalletModal share this shell. */
.ui-wallet-confirm .modal-body {
  display: flex;
  flex-direction: column;
  gap: var(--space-16);
}
.ui-wallet-confirm__head {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-12);
  text-align: center;
}
.ui-wallet-confirm__icon {
  background: color-mix(in oklch, var(--text-primary) 6%, transparent);
  color: var(--text-secondary);
}
.ui-wallet-confirm__address {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}
.ui-wallet-confirm__name {
  font-size: var(--text-body);
  font-weight: var(--fw-semibold);
  color: var(--text-primary);
}
/* __addr is a <code> — cancel the global code-chip pill so the full address
   reads as plain mono text (a 42-char chip would be huge); keep the mono
   font + add break-all so it wraps inside the narrow sheet. */
.ui-wallet-confirm__addr {
  background: transparent;
  padding: 0;
  border-radius: 0;
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  font-weight: var(--fw-regular);
  letter-spacing: 0;
  text-transform: none;
  color: var(--text-secondary);
  word-break: break-all;
}

/* ── Wallet connect WIZARD — centered intro / connecting steps ────────
   .wallet-flow-intro + .wallet-flow-connecting (+ __title) RETIRED June 2026
   → the universal .state-block primitive (styles.css → STATE BLOCK). The
   ecosystem intro and the "Waiting for {Provider}" connecting screen are
   .state-block consumers now (avatar focal + __title + __message + trailing
   spinner). This lived in swap.css, which the Portal does NOT load — so the
   shared wallet-connect wizard rendered un-centered there; the move to
   styles.css fixes it on every surface. See CLAUDE.md → "Primitive
   retirement is atomic". No aliases. */

/* The wizard's provider / ecosystem steps render their rows as cards via the
   universal .list--cards primitive (styles.css) — surface, symmetric padding,
   card hover/active, gap, and the shadow gutter all live there now. No
   wizard-scoped card CSS needed; the markup applies <div class="list
   list--cards"> + default-tier .list-item rows. */

/* Wallet-row kebab menu — plain leading line icons (not the 32 px
   .menu-item-icon tile), scoped to the send-to-wallet kebab panels so the
   global .menu-item contract is untouched. The kebab row itself uses the
   .list-item composite with a <div role="button"> root so it can host the
   real <button> trigger (button-in-button parser trap — see CLAUDE.md). */
.ui-sw-menu .menu-item > svg {
  width: var(--space-20);
  height: var(--space-20);
  flex: 0 0 auto;
  color: var(--text-secondary);
}
.ui-sw-menu .menu-item--danger > svg { color: var(--danger); }


/* ── Token row — composition over .list-item ─────────────────────────────
   The swap widget's token picker rows are .list-item composites, not a
   dedicated primitive. See design-system/index.html#token-row for the
   canonical markup recipe (Lists section). Composition pieces:
     • .list-item.list-item--interactive   row container + hover/focus
     • .list-item__lead   →   .avatar-tandem with .avatar-badge--br
     • .list-item__title  →   token symbol (ETH, USDT)
     • .list-item__desc   →   <span>name</span> + <span>address</span>
                              (both plain spans; the 2nd gets mono font
                              via the :nth-of-type(2) scoped rule. Name
                              shows at rest, address swaps in on hover.
                              Deliberately NOT <code>/.code-chip — the
                              chip's bg + radius would change the line
                              height between rest and hover and shift
                              the centered avatar.) + two
                              .btn-icon.btn-xs.btn-borderless icons
                              (hidden at rest, revealed on hover; the
                              pin stays visible when the row is
                              .is-pinned to indicate state)
     • .list-item__trail  →   amount + fiat (when balance is shown)

   Each row lifts --avatar-bg to the <li> via inline style so the
   token's brand colour cascades to the .avatar AND drives the
   row-level hover tint (one shared source).

   Rules below are the swap widget's brand conventions, scoped to
   .ui-token-list (the container) so the generic .list-item primitive
   stays untouched on every other surface.
   ----------------------------------------------------------------------- */

/* Sizing — rows pinned to var(--space-64) exact (canonical ladder tier).
   Padding 8/0 — vertical stepped down with the row, horizontal flush so
   the avatar tandem aligns with the picker body's left edge and the
   trail (amount + fiat) aligns with the right edge. Content budget 48
   (64 row − 8·2 padding). Body fills the budget:
     title 24 (--text-body-xl @ 1.2) + gap --space-2 + desc 20 (pinned)
     = 46 ✓ (2 px slack). */
.ui-token-list .list-item--lg {
  height: var(--space-64);
  min-height: 0;
  flex-shrink: 0;  /* keep ladder-snapped height inside the flex column;
                      let .ui-token-list's overflow-y: auto handle scroll
                      when content exceeds its max-height */
  padding: var(--space-8) 0;
}

/* Body gap — tightened to --space-2 so the symbol + name pair reads as
   one identity stack rather than two rows of equal weight. Mirrored on
   the __trail (below) to keep the body and trail columns visually
   balanced. */
.ui-token-list .list-item__body {
  gap: var(--space-2);
}

/* Title — step up to --text-body-xl (20 px) at the 80 tier so the symbol
   reads as a heading-style identity, not a body label. Tight line-height
   (1.2 unitless, documented in CLAUDE.md → Display line-height) keeps
   the title inside the content budget. */
.ui-token-list .list-item__title {
  font-size: var(--text-body-xl, 20px);
  line-height: 1.2;
}

/* Desc — deliberate divergence from the --lg ladder (which sets desc at
   --text-body / 16 px). For a token row, the secondary line ("Ethereum",
   "0xdAC17…31ec7") is a supporting detail, not a co-equal label. At
   the 80-tier we sit one step below the title at --text-sm (14 px),
   tighten line-height to match the title's rhythm, and use --text-muted
   so the symbol carries the identity at a glance. */
.ui-token-list .list-item__desc {
  font-size: var(--text-sm, 14px);
  line-height: 1.2;
  color: var(--text-muted, var(--text-secondary));
}

/* Hover + Selected — pseudo-element bleed. The row's content (avatar,
   body, trail) stays flush at the picker body's L/R edges; the hover /
   active / selected highlight surface is painted on a ::before that
   extends var(--space-16) past the row on each side. The body's
   overflow-x: hidden (set on .ui-token-picker__body below) clips the
   bleed at the body's edge, so the highlight reads as flush against the
   body's L/R inner-edge without forcing chrome padding back onto the row.

   isolation: isolate creates a stacking context on the row so the
   pseudo's z-index: -1 sits below the row's children but doesn't escape
   up to the picker / card layer. The base .list-item--interactive
   hover/active/selected background-color rules are suppressed below so
   only the pseudo carries the state. */
.ui-token-list .list-item--interactive {
  position: relative;
  isolation: isolate;
}
.ui-token-list .list-item--interactive::before {
  content: '';
  position: absolute;
  /* Bleed magnitude consumed from --list-hover-bleed so every linked
     site (panel side-padding reduction, body padding compensation,
     this pseudo's inset) tracks one source. See styles.css :root for
     the token. */
  inset: 0 calc(-1 * var(--list-hover-bleed));
  background-color: transparent;
  border-radius: var(--space-16);
  z-index: -1;
  pointer-events: none;
  transition: background-color .12s ease;
}
/* Hover / active / selected fills — pseudo paints the same translucent
   values as the base .list-item--interactive state rules (styles.css →
   "CANONICAL LIST-ROW STATE RECIPE"). Mixing with transparent rather
   than --surface-raised lets the pseudo composite over whatever's
   beneath it — visually identical in the panel context, but means the
   pseudo and the base rule are guaranteed to render the same color
   (no manual sync). The base rule recipe is the canonical source of
   truth; this pseudo is one of its consumers.

   No rim border on .is-selected (card recipe normally includes it;
   deliberately omitted for the list-item variant — fill only). No
   box-shadow either; lift doesn't make sense for a row inside a
   scrolling list. */
.ui-token-list .list-item--interactive:hover::before {
  background-color: color-mix(in oklch, var(--text-primary) 4%, transparent);
}
.ui-token-list .list-item--interactive:active::before {
  background-color: color-mix(in oklch, var(--accent-primary) 10%, transparent);
}
.ui-token-list .list-item.is-selected::before {
  background-color: color-mix(in oklch, var(--accent-primary) 6%, transparent);
}
/* Press feedback on already-selected rows — parallels the base
   .list-item--interactive.is-selected:active rule (styles.css). Without
   this, the pseudo's selected (6%) wins over the pseudo's :active (10%)
   via source order at equal specificity, and pressing a selected row
   shows no press signal. Higher specificity here ensures the press
   paints regardless. */
.ui-token-list .list-item--interactive.is-selected:active::before {
  background-color: color-mix(in oklch, var(--accent-primary) 10%, transparent);
}
/* Suppress the row's own background-color so the pseudo owns the surface. */
.ui-token-list .list-item--interactive,
.ui-token-list .list-item--interactive:hover,
.ui-token-list .list-item--interactive:active,
.ui-token-list .list-item.is-selected {
  background-color: transparent;
}

/* Pinned pin — flips fill from none to currentColor, painted in
   --lifi-sapphire. State indicator, not a hover affordance. */
.ui-token-list .list-item.is-pinned [data-pin] {
  color: var(--lifi-sapphire, var(--accent-primary));
}
.ui-token-list .list-item.is-pinned [data-pin] svg {
  fill: currentColor;
}

/* __desc layout — pinned to var(--space-20) tall so the body block
   height is CONSTANT regardless of state. Acts as the clip + position
   context for the slide-reveal animation (children below).
   `position: relative; overflow: hidden` define the clip; the two
   direct-child <span>s become slide tracks via `position: absolute`. */
.ui-token-list .list-item__desc {
  display: flex;
  align-items: center;
  gap: var(--space-8, 8px);
  height: var(--space-20);
  min-width: 0;
  position: relative;
  overflow: hidden;
}

/* Slide tracks — name (first <span>) sits at translateY(0) visible at
   rest; the address+icons wrapper (second <span>) sits at translateY(100%)
   hidden below the clip. On row hover the name slides up to -100%
   (clips off at the top edge) and the wrapper slides up to 0 (rises
   into view from below). Both tracks always rendered; CSS transforms
   swap visibility. */
.ui-token-list .list-item__desc > span:first-of-type,
.ui-token-list .list-item__desc > span:nth-of-type(2) {
  position: absolute;
  inset: 0;
  min-width: 0;
}
.ui-token-list .list-item__desc > span:first-of-type {
  /* Name track — single text line. display: block + line-height matching
     row height vertically centers the text and allows text-overflow
     ellipsis to truncate long names. */
  display: block;
  line-height: var(--space-20);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  transform: translateY(0);
}
.ui-token-list .list-item__desc > span:nth-of-type(2) {
  /* Address+icons track — flex row holding the inner address <span>
     and the two .btn-icon buttons. Starts off-screen below. */
  display: flex;
  align-items: center;
  gap: var(--space-8);
  transform: translateY(100%);
}

/* Motion-respecting transition — instant swap for users who prefer
   reduced motion; brisk 180 ms slide for everyone else. The easing is
   Material's standard "ease-in-out" curve (decelerate at end). */
@media (prefers-reduced-motion: no-preference) {
  .ui-token-list .list-item__desc > span:first-of-type,
  .ui-token-list .list-item__desc > span:nth-of-type(2) {
    transition: transform 180ms cubic-bezier(0.4, 0.0, 0.2, 1);
  }
}

/* Inner address <span> inside the second-track wrapper — plain sans text
   (not a code chip), truncates if too long. Default Figtree, not mono —
   the picker reads as one typographic family across symbol, name, and
   address. */
.ui-token-list .list-item__desc > span:nth-of-type(2) > span {
  font-family: var(--font-sans);
  font-size: var(--text-caption, 12px);
  letter-spacing: 0;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Adjacent meta buttons inside the wrapper — tighten the gap so the
   two icons sit visually close. */
.ui-token-list .list-item__desc > span:nth-of-type(2) > .btn-icon + .btn-icon {
  margin-left: calc(-1 * var(--space-4));
}

/* Defensive backstop — if a future row is authored without wrapping the
   address + buttons in a track <span> (the canonical pattern documented
   in the catalog Markup pane), the buttons would otherwise sit as
   direct children of __desc with no transform rule and render
   permanently visible. The `>` combinator below only matches DIRECT-
   CHILD buttons (i.e., unwrapped); buttons inside the canonical wrapper
   are two levels deep and unaffected. Unwrapped rows degrade
   gracefully to display-toggle reveal (no slide animation, but no
   stuck buttons). This catches any token whose address format wasn't
   anticipated by an automation pass — Bitcoin (bc1…), Solana base58,
   Cosmos (cosmos1…), Tron (T…), etc. */
.ui-token-list .list-item__desc > .btn-icon {
  display: none;
}
.ui-token-list .list-item--interactive:hover .list-item__desc > .btn-icon {
  display: inline-flex;
}
/* Meta buttons — standard .btn-icon.btn-xs.btn-neutral (canonical
   alpha-overlay variant) scoped to a 20 × 20 outer so they fit inside
   the pinned 20 px __desc line. Standard btn-xs is 24 × 24; here we
   step down to var(--space-20) outer with the 12 px icon. Inline
   tertiary affordance — the whole row is the primary click target.
   Hover lift + shadow from .btn-neutral:hover are neutralized below so
   the meta strip stays geometrically still under the cursor (per the
   no-shift-on-hover contract for documentation surfaces). */
.ui-token-list .list-item__desc .btn-icon.btn-xs {
  width: var(--space-20);
  height: var(--space-20);
  --btn-icon-size: var(--space-12);
  /* No border-radius override here — let .btn-circle's --r-full win
     when the consumer adds it (canonical pattern). Without .btn-circle,
     .btn-icon.btn-xs's default var(--space-8) radius applies. */
}
.ui-token-list .list-item__desc .btn-icon.btn-neutral:hover {
  transform: none;
  box-shadow: none;
}

/* Hover swap — the slide animation is driven by the transform rules
   on the two tracks above. On row hover, the name slides up (-100%)
   and the address+icons wrapper slides up into view (0). */
.ui-token-list .list-item--interactive:hover .list-item__desc > span:first-of-type {
  transform: translateY(-100%);
}
.ui-token-list .list-item--interactive:hover .list-item__desc > span:nth-of-type(2) {
  transform: translateY(0);
}

/* __trail mirrors the body column exactly so the row reads as two
   horizontally-balanced columns: amount aligns vertically with the title
   (symbol), fiat aligns vertically with the desc (name / address).
   Amount uses --text-body-lg @ 1.2 lh (21.6 px) matching the title;
   fiat is pinned to var(--space-16) matching the pinned __desc height.
   Default sans (Figtree) with tabular numerals — the picker reads as one
   typographic family across symbol, name, address, and trail. */
.ui-token-list .list-item__trail {
  flex-direction: column;
  align-items: flex-end;
  gap: var(--space-2);
  font-variant-numeric: tabular-nums;
  font-family: var(--font-sans);
}
.ui-token-list .list-item__trail > :first-child {
  font-size: var(--text-body-xl, 20px);
  font-weight: var(--fw-semibold);
  color: var(--text-primary);
  line-height: 1.2;
}
.ui-token-list .list-item__trail > :last-child {
  display: flex;
  align-items: center;
  height: var(--space-20);
  font-size: var(--text-sm, 14px);
  color: var(--text-muted, var(--text-secondary));
}


/* ── .ui-token-list / .ui-chain-list — picker list containers ─────────────
   Scrollable list of rows. Used inside the .ui-card picker state and
   inside the .split-pane chain filter sibling. Rows inside are
   .list-item composites — chain rows use .list-item--sm + .avatar.
*/
.ui-token-list,
.ui-chain-list {
  /* Card-gap ladder — same vocabulary as .ui-quote-list. Picker rows are
     dense (no card surface) so we sit at the tightest tier; consumers
     can override --card-gap inline if they need to widen on a per-list
     basis. See CLAUDE.md → Card gap ladder. */
  --card-gap: var(--gap-card-xs);
  display: flex;
  flex-direction: column;
  gap: var(--card-gap);
  max-height: 480px;
  overflow-y: auto;
  scrollbar-width: thin;
  margin: 0;
  padding: 0;
  list-style: none;
}


/* ── .ui-token-picker — scrollable picker host ────────────────────────────
   Wraps the search input + the body holding N stacked .token-list-module
   blocks. The body owns the scroll; the head pins. Used inside the
   .ui-card[data-state="picker"] state on the swap widget.

   Layout contract:
     flex column · min-height:0 · height:100%
     head — flex:0 0 auto, no scroll
     body — flex:1, overflow-y:auto
   When the picker sits in a parent with a fixed height (the picker state
   of .ui-card), the body scrolls inside that envelope. Standalone usage
   (e.g., the catalog preview) caps the height where it's instantiated.

   `data-picker="from|to"` — opt-in hook for any per-side visual cue
   (destination-side bridge-receivable tinting, etc.). Today: identical
   shape both sides, just different data.
*/
.ui-token-picker {
  display: flex;
  flex-direction: column;
  min-height: 0;
  height: 100%;
}
.ui-token-picker__head {
  flex: 0 0 auto;
  /* L/R padding mirrors the body's so the search bar aligns horizontally
     with the rows (both inset --list-hover-bleed from the picker's
     outer edge). */
  padding: 0 var(--list-hover-bleed) var(--space-12);
}
/* .ui-token-picker__search retired — picker now uses the canonical .search
   component (catalogued at #search) which handles its own width via the
   .search rule's width:100%. */

.ui-token-picker__body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  /* Clip the row hover pseudo at the body's outer edge as a defensive
     backstop. With L/R padding matching --list-hover-bleed, the pseudo
     extends fully into the padding zone and is visible up to the body's
     outer edge — clip is a no-op in the typical case. */
  overflow-x: hidden;
  scrollbar-width: thin;
  /* L/R padding hosts the row hover pseudo's bleed visibly — without
     this, the body's overflow-x clip line sits at the row's edge and
     swallows the bleed entirely. Top/bottom stay flush (search bar
     against first row, last row against bottom). */
  padding: 0 var(--list-hover-bleed);
  /* Scroll-padding so the top of the next module sits clear of the
     sticky head when keyboard-navigating between sections. */
  scroll-padding-top: var(--space-12);
}

/* Inside the picker body, each list defers scrolling to the picker —
   one continuous scroll across all stacked modules, not N nested wells. */
.ui-token-picker__body .ui-token-list {
  max-height: none;
  overflow-y: visible;
}


/* ── .ui-chain-picker — chain-as-primary-choice picker surface ────────────
   Parallel to .ui-token-picker but for chain selection. Used inside the
   .ui-card[data-state="chain-picker"] state in Bridge mode.

   Phase 2 contract: same vertical layout as the token picker (search head
   pins, body scrolls), but the body hosts a single .ui-chain-list at
   token-row-sized rows (.list-item--lg + default-tier avatar). The list
   reads as a primary commit ("pick the chain you want"), not a narrowing
   filter — that's the distinction from the sidebar .split-pane--chain-filter
   which still serves Swap mode's combined-picker flow. */
.ui-chain-picker {
  display: flex;
  flex-direction: column;
  min-height: 0;
  height: 100%;
}
.ui-chain-picker__head {
  flex: 0 0 auto;
  padding: 0 0 var(--space-12);
}
.ui-chain-picker__body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  scrollbar-width: thin;
  padding: var(--space-12) 0 var(--space-16);
  scroll-padding-top: var(--space-12);
}
.ui-chain-picker__body .ui-chain-list {
  max-height: none;
  overflow-y: visible;
}

/* Search "no-results" empty state — shared across the three picker filters
   (network panel, token-picker head, in-form chain-picker drill). The message
   itself is the universal .muted--block primitive; this rule only adds the
   vertical breathing room so the line reads as an intentional centered state
   rather than hugging the search bar (the picker bodies carry little/no
   vertical padding). Padding matches .cmd-palette__empty. Shown/hidden via
   inline style.display by the filter fns — same mechanism that hides rows
   (see picker-render.js → applyChainSearch / applyTokenSearch, swap.js drill). */
.ui-picker-empty { padding: var(--space-32) var(--space-24); }


/* ── .token-list-module — labelled block of token rows (Modules tier) ─────
   A self-contained "module" — header (title + optional meta) + list body
   + state hooks. The first inhabitant of a future Modules sidebar group;
   today documented under #lists as the canonical labelled-list pattern.

   Composes onto:
     .list-section    — eyebrow-style title (existing primitive)
     .ui-token-list   — row container (existing primitive)
     .list-item       — the row itself (existing primitive)

   Stack 2+ modules inside a .ui-token-picker__body for the picker
   surface (Featured · Portfolio · Popular). The .list-section title IS
   the separator between blocks — no hairline rule, no extra spacer.

   Variants via `data-list-variant`:
     featured   — curated, often static, no meta
     portfolio  — wallet-connected, has __meta (USD total)
     popular    — trending, optional delta-chip in row trail
   The attribute is a structural hook; today it carries no visual
   difference. Per-variant overrides land as needed (empty-state copy,
   sort affordance, row chrome).

   States via `data-state`:
     ready    — populated (default)
     loading  — skeleton rows via window.LifiPickerRender.renderSkeletonTokenList(ul, count).
                Helper sets data-state="loading" + aria-busy on the module; rows are
                .list-item.is-skeleton with .skeleton atoms in the lead + body slots.
                Real-row render path clears the state on data arrival.
     empty    — hides the list, surfaces `data-empty-message` content
     connect  — wallet-required state (portfolio variant only — TODO)
*/
.token-list-module {
  display: block;
}
.token-list-module__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-12);
  /* Flush — no padding on any side. Section title hangs at the module's
     left edge, deliberately offset from the rows' avatar column. Height
     hugs the title's line-box — first row sits directly against the
     head's bottom edge with no extra breathing room baked in. */
  padding: 0;
}
.token-list-module__head .list-section {
  margin: 0;
  /* Zero horizontal padding so the section title aligns flush with the
     module's left edge, matching the row content (.list-item avatars
     sit at the module's left edge). The 16 px sides baked into
     .list-section's universal recipe would otherwise indent the title
     past the row content. Vertical padding stays for breathing. */
  padding-inline: 0;
}
.token-list-module__meta {
  font: 600 var(--text-caption) / 1 var(--font-mono);
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* Portfolio header ("Your tokens" + balance total) hidden for now —
   may return. Markup preserved in playground.html + catalog so the
   restore is a single rule delete. */
.token-list-module[data-list-variant="portfolio"] .token-list-module__head {
  display: none;
}

/* Stacked rhythm — tight gap, the section title carries the separation. */
.token-list-module + .token-list-module {
  margin-top: var(--space-8);
}

/* Loading-state recipe (May 2026) — module shows skeleton rows in
   the same .ui-token-list shell. Callers stamp the skeletons via
   window.LifiPickerRender.renderSkeletonTokenList(ul, count); the
   helper also sets data-state="loading" + aria-busy on the module.
   .list-item.is-skeleton suppresses cursor + hover so the placeholder
   doesn't pretend to be interactive. See #skeleton-component for the
   primitive; see #token-list-module Rules pane for the contract. */
.token-list-module[data-state="loading"] .list-item.is-skeleton {
  cursor: default;
  pointer-events: none;
}
.token-list-module[data-state="loading"] .list-item.is-skeleton:hover {
  background: transparent;
}

/* Empty-state recipe — minimal first-pass. Connect state lands in a
   follow-up commit once the live data layer is wired. */
.token-list-module[data-state="empty"] .ui-token-list { display: none; }
.token-list-module[data-state="empty"]::after {
  content: attr(data-empty-message);
  display: block;
  padding: var(--space-24) var(--space-16);
  color: var(--text-muted);
  text-align: center;
  font-size: var(--text-sm);
  line-height: var(--lh-sm);
}


/* ── .chain-list-module — labelled block of chain rows (Modules tier) ─────
   Sister of .token-list-module — same wrapper shape and state machinery,
   tuned for the smaller chain row tier (.list-item--sm at 40 px) with a
   single .avatar.avatar--xs.avatar--circle lead. Stack 2+ modules inside
   a container body for sectioned chain pickers (Active · Popular ·
   Testnets), or use a single module as the flat network filter on the
   swap widget.

   Composes onto:
     .list-section    — eyebrow-style title (existing primitive, optional)
     .ui-chain-list   — row container (existing primitive)
     .list-item--sm   — row tier (existing primitive)
     .avatar--xs      — chain icon (existing primitive)

   Variants via `data-list-variant`:
     active     — chains where the user has balances (parallel to portfolio)
     popular    — top-N chains by volume / TVL (default flat-filter use)
     testnets   — testnet chains, gated behind a setting

   States via `data-state`:
     ready    — populated (default)
     loading  — skeleton rows via window.LifiPickerRender.renderSkeletonChainList(ul, count).
                Helper sets data-state="loading" + aria-busy on the module; rows are
                .list-item.is-skeleton with a circle skeleton + title text-line.
     empty    — hides the list, surfaces `data-empty-message`
*/
.chain-list-module {
  display: block;
}
.chain-list-module__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-12);
  padding: 0;
}
.chain-list-module__head .list-section {
  margin: 0;
  /* Zero horizontal padding so the section title aligns flush with the
     module's left edge, matching the row content. See
     .token-list-module__head .list-section above for the rationale. */
  padding-inline: 0;
}
.chain-list-module__meta {
  font: 600 var(--text-caption) / 1 var(--font-mono);
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* Stacked rhythm — same tight gap as .token-list-module. */
.chain-list-module + .chain-list-module {
  margin-top: var(--space-8);
}

/* Loading-state recipe — mirrors .token-list-module. Callers stamp
   skeletons via window.LifiPickerRender.renderSkeletonChainList(ul,
   count). See #skeleton-component + #chain-list-module Rules pane. */
.chain-list-module[data-state="loading"] .list-item.is-skeleton {
  cursor: default;
  pointer-events: none;
}
.chain-list-module[data-state="loading"] .list-item.is-skeleton:hover {
  background: transparent;
}

/* Empty-state recipe — mirrors .token-list-module. */
.chain-list-module[data-state="empty"] .ui-chain-list { display: none; }
.chain-list-module[data-state="empty"]::after {
  content: attr(data-empty-message);
  display: block;
  padding: var(--space-24) var(--space-16);
  color: var(--text-muted);
  text-align: center;
  font-size: var(--text-sm);
  line-height: var(--lh-sm);
}


/* ── .ui-quote-list — Receive panel container ─────────────────────────────
   Stacked quote cards inside the right-column Receive panel. Comfortable
   gap between cards. Base rule stays layout-only (used as-is by catalog
   demos and any non-receive consumer); the in-Receive scroll architecture
   is scoped below.
*/
.ui-quote-list {
  --card-gap: var(--gap-card-sm);
  display: flex;
  flex-direction: column;
  gap: var(--card-gap);
}

/* In-Receive context — interior scroll architecture.
   The .ui-card envelope already pins `max-height: var(--widget-max-h, none)`
   + `overflow: hidden`. The screen-header sibling stays intrinsic at the
   top; the .ui-quote-list owns the scroll. Same clip-and-host pattern as
   the form state's .ui-form-stack — see CLAUDE.md → "Sub-panel scroll
   architecture" and the .ui-form-stack rule above for the canonical
   anatomy.

   The horizontal bleed (margin-inline calc(-24) + matching padding-inline)
   pushes the scrollbar into the panel's 24 px gutter so it doesn't crop
   the cards' right edges. Cards keep their full content width; the
   scrollbar sits where the panel's padding would otherwise live. Bottom
   padding inside the bleed adds a soft fade space so the last card doesn't
   read as cropped under the panel's lower edge.

   Scoped to .widget-stage__receive so the base .ui-quote-list (used as-is
   in catalog demos and any non-receive consumer) is unaffected. */
.widget-stage__receive .ui-quote-list {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  margin-inline: calc(-1 * var(--space-24));
  padding-inline: var(--space-24);
  padding-bottom: var(--space-8);
  /* Anchor scroll-into-view a little below the screen-header in case focus
     jumps to a card. */
  scroll-padding-top: var(--space-8);
}


/* ── .ui-quote-card — quote shell with slotted expandable detail ──────────
   Consumes the canonical UI card recipe (May 2026): --surface-card tier
   + --elev-1 + 1 px transparent border at rest that flips to accent on
   the selected state. Reads as one family with .theme-card and
   .ui-amount-card.

   Every card in a .ui-quote-list is selectable — the consumer's click
   handler moves .is-active across the list. The system-recommended
   route lands with .is-active applied on initial render; tapping any
   sibling card moves the accent ring to that one. The "Best return"
   header tag is the semantic "this is what we recommend" label, kept
   visually distinct from the selection state so the user can pick a
   different route without losing the recommendation cue.

   Anatomy:
     header      tag pill (Best return / Fastest / Cheapest), progress dot
     body        avatar + amount + fiat • slippage% • provider, expand toggle
     footer      rate · gas · time
     detail      slot revealed when .is-expanded; hosts .ui-quote-detail-table
                 or any of the route-summary / route-steps / route-track variants
*/
.ui-quote-card {
  display: flex;
  flex-direction: column;
  /* May 2026 — padding-x dropped 20 → 16 (one tier on the ladder). */
  padding: var(--space-16) var(--space-16);
  background: var(--surface-card);
  border: 1px solid transparent;
  border-radius: var(--card-radius);
  box-shadow: var(--elev-1);
  cursor: pointer;
  transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.ui-quote-card:hover {
  background: color-mix(in oklch, var(--accent-primary) 3%, var(--surface-card));
  box-shadow: var(--elev-2);
}
/* Active (pressed) + .is-active (persistent selected) — softened May 2026.
   Rim is accent mixed 64% into transparent (keeps the hue family, drops the
   punch from a full-saturation border). Fill is a uniform 4% accent in both
   themes — calmer than the prior 5% without going so quiet that light mode
   loses the selected signal. Stays in family with .theme-card +
   .ui-amount-card; canonical UI-card recipe contract. */
.ui-quote-card:active,
.ui-quote-card.is-active {
  background: color-mix(in oklch, var(--accent-primary) 4%, var(--surface-card));
  border-color: color-mix(in oklch, var(--accent-primary) 64%, transparent);
  box-shadow: var(--elev-2);
}

/* Skeleton variant — suppresses cursor + hover + active so the loading
   placeholder card doesn't pretend to be clickable. The surface chrome
   (bg, border, shadow, padding) stays so layout reads identically when
   the real card swaps in. Authored skeleton-card markup lives in
   ui-quote-list.js → renderSkeletonCard(); CSS here is the chrome-suppression
   contract. Composed with the universal .skeleton primitive (styles.css)
   for the actual shimmer atoms inside. See #skeleton-component for the
   primitive. */
.ui-quote-card.is-skeleton {
  cursor: default;
  pointer-events: none;
}
.ui-quote-card.is-skeleton:hover {
  background: var(--surface-card);
  box-shadow: var(--elev-1);
}
.ui-quote-card__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-8, 8px);
  margin-bottom: var(--space-12, 12px);
}
/* (May 2026) The ad-hoc .ui-quote-card__progress-dot was retired in favor of
   the .refresh-timer composite primitive (styles.css + refresh-timer.js +
   catalog #refresh-timer). The dot was a static visual indicating a refresh
   policy that didn't actually run — refresh-timer ships both the visual AND
   the policy as one drop-in. */

.ui-quote-card__body {
  display: flex;
  /* Center-align the three children — the 40 px avatar tandem (left), the
     amount + meta block (tallest, defines row height), and the 28 px chevron
     (right) — so the row reads as one visually balanced unit. The amount
     block stays at the row's vertical centerline; avatar + chevron lift to
     match. */
  align-items: center;
  gap: var(--space-12, 12px);
}
/* Avatar slot — the renderer emits the canonical .avatar-tandem primitive
   (default 40 px, chain badge 16 px = 40 % of base) directly inside
   .ui-quote-card__body. No per-component wrapper class; no size modifier
   ("use DS primitives, not page-local classes"; sizing cascades via
   --avatar-size from the wrapper's default, never set on the inner
   .avatar). Every card in a quote list shows the SAME destination
   token + chain tandem so the user reads "all these quotes get me the
   same thing at different rates"; the differentiator lives in the
   amount + meta + provider line, not the avatar. Provider info stays
   in __meta-provider. See #avatar-tandem for the primitive's spec. */
.ui-quote-card__amount {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
}
.ui-quote-card__amount-value {
  font-size: var(--text-h3);
  font-weight: var(--fw-bold);
  color: var(--text-primary);
  /* Unitless 1.2 — single-line numeric quantity; --lh-h3 (1.333) leaves too
     much vertical space for a one-line value. Sits in the 1.1–1.6 exception
     range per the CLAUDE.md typography rule. */
  line-height: 1.2;
  font-variant-numeric: tabular-nums;
  letter-spacing: var(--tracking-snug);
  /* Ellipsis backstop — adaptive precision (fmtTokenAmount) keeps this short,
     but a pathological value still truncates on one line instead of wrapping
     taller or colliding with the chevron. Parent (.ui-quote-card__amount) is
     flex:1 + min-width:0, so this stretches to the column width and clips.
     Full value stays recoverable via the native title (fullTitleAttr). */
  max-width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.ui-quote-card__meta {
  margin-top: var(--space-4);
  /* Pin font-size + line-height on the wrapper so __meta-provider-name,
     __delta, and other meta children inherit a token-defined line-box
     instead of `normal` (browser default ≈ 1.2). Without the lh pin
     the meta row renders at ~14.4 px line-box in every context but the
     pair isn't system-tokenized — drops the contract for any future
     overlay or popover that hosts this primitive. */
  font-size: var(--text-caption, 12px);
  line-height: var(--lh-caption);
  color: var(--text-secondary);
  display: flex;
  align-items: center;
  flex-wrap: nowrap;
  min-width: 0;
  overflow: hidden;
  gap: var(--space-4, 4px);
}
/* Hold fixed-width meta items (fiat, %, dots) at their natural width; only the
   provider label is allowed to shrink and truncate. Single-line discipline —
   long provider names ellipsize rather than reflowing the card taller. */
.ui-quote-card__meta > * { flex-shrink: 0; }
.ui-quote-card__meta-dot {
  display: inline-block;
  width: var(--space-4); height: var(--space-4);
  border-radius: 50%;
  background: currentColor;
  opacity: 0.6;
}
.ui-quote-card__meta-provider {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  flex-shrink: 1;
  min-width: 0;
}
.ui-quote-card__meta-provider-icon {
  width: var(--space-12); height: var(--space-12);
  border-radius: 50%;
  background: oklch(0% 0 0 / 0.08);
  display: inline-block;
  flex-shrink: 0;
}
/* Label wrapper — ellipsis paints on this inline-block, not on the
   inline-flex parent (CLAUDE.md "Flex containers + text-overflow: ellipsis"). */
.ui-quote-card__meta-provider-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ── Variant: rate-delta (May 2026 — Variants Rail) ──────────────────────
   When the list is set to `data-variant="rate-delta"`, the primary line
   becomes the unit rate ("1 USDC ≈ 0.000412 ETH") instead of the output
   amount. The rate string is ~20 chars vs the amount's ~8, so the type
   scale drops one tier (--text-h3 32 → --text-body-lg 18). Tracking and
   line-height move toward body type. */
.ui-quote-card__amount-value--rate {
  font-size: var(--text-body-lg);
  letter-spacing: var(--tracking-normal);
  line-height: 1.3;
}
/* Delta chip — three-tier ink ramp mirroring priceImpact thresholds in
   ui-quote-list.js (|impact| > 3 = danger, > 1 = warning, else muted).
   Same `--danger` / `--warn` tokens used by the detail-list price-impact
   row, so the rate-delta variant and the detail table flag risk at the
   same boundaries. */
.ui-quote-card__delta {
  font-variant-numeric: tabular-nums;
  font-weight: var(--fw-medium);
}
.ui-quote-card__delta--muted   { color: var(--text-secondary); }
.ui-quote-card__delta--warning { color: var(--warn); }
.ui-quote-card__delta--danger  { color: var(--danger); }

/* ── Variant: inline-symbol — destination token suffix on the amount ────
   Renders inline AFTER the numeric amount inside .ui-quote-card__amount-value.
   Smaller and less prominent than the number (--text-body vs the parent's
   --text-h3) so the number stays the visual lead and the symbol reads as
   a label, not a co-equal value. Tracking + weight step down too —
   "0.041151 ETH" should read as one phrase with the unit deemphasised. */
.ui-quote-card__amount-symbol {
  font-size: var(--text-body);
  font-weight: var(--fw-medium);
  color: var(--text-secondary);
  letter-spacing: var(--tracking-normal);
  /* Pin to match the parent __amount-value's 1.2 ratio so the inline
     symbol sits on the same baseline rhythm as the numeric amount.
     Without the pin the inline-box collapses to the parent's leading
     today, but explicit pinning communicates the contract. */
  line-height: 1.2;
}

/* Expand toggle — composes the canonical .btn-icon.btn-neutral
   .btn-circle.btn-xs (24 × 24 filled icon button, 12 px icon, neutral
   theme-adaptive tint, no hover lift). Buttons are static by default
   since the May 2026 inversion (lift is the opt-in .btn-lift), so this
   inline chevron stays anchored in the card body row with no modifier —
   appropriate for a control that shouldn't pop out of the row. The
   chevron rotates 180° when aria-expanded is "true"; the rule is keyed
   on aria so future markup that drives the toggle via aria alone still
   rotates correctly. We use the standalone `rotate:` property (not
   `transform: rotate()`) so the rotation stays independent of the
   button's own transform — the static default zeros transform on hover
   today, but using `rotate:` keeps the rotation honest if the button
   ever composes .btn-lift or another :hover transform. */
/* Rotation only — transition for rotate (and bg/color/border) lives on the
   base .btn-* rule in styles.css, so we don't redeclare here. (Earlier the
   scoped transition redeclared the shorthand and accidentally blew away
   the base button's bg fade — restoring the rotate-only declaration here
   means the base contract holds.) */
.ui-quote-card [data-action="quote-toggle"][aria-expanded="true"] {
  rotate: 180deg;
}
/* Detail slot — graceful expand / collapse contract (May 2026)
   ------------------------------------------------------------
   Two-layer reveal so adding / changing / re-shaping the detail
   content doesn't break the animation.

     LAYER 1 — Container (always-on, content-agnostic):
       grid-template-rows: 0fr → 1fr animates the slot's height with
       NO JS height measurement. margin-top / padding-top /
       border-top-color transition in lockstep so the slot has TRUE
       zero height when collapsed (no orphan 12 px gap, no stranded
       hairline). The border-top stays 1 px throughout — only its
       colour flips between transparent (collapsed) and the divider
       tone (expanded) so we don't transition a border-width and
       risk a 1 px layout jump.

     LAYER 2 — Per-row stagger (opt-in, content-aware):
       Children matching either .detail-list__row (today's canonical
       primitive) or .ui-quote-card__detail-row (catch-all class for
       future detail variants) fade up via a :nth-child cascade.
       Up to 12 siblings get explicit delays; rows past 12 share the
       11th delay so the reveal stays bounded. :where() keeps the
       selector specificity at (0,1,0) so consumers can override
       per-row timing without a specificity war.

   CONTRACTS FOR FUTURE DETAIL VARIANTS (route-summary, route-steps,
   route-track, etc.) — author the new content so it satisfies all
   three:

     1. Wrap variant content in EXACTLY ONE direct child of
        .ui-quote-card__detail (today: .ui-quote-detail-table). The
        grid-template-rows trick sizes ONE row track — multiple
        direct children would each land in an implicit auto row,
        and implicit rows don't transition.
     2. Don't author margin-top / padding-top / border-top on the
        variant's wrapper. The container owns the chrome so the
        open / close transition handles it.
     3. To participate in the row stagger, add either the canonical
        .detail-list__row class (preferred — uses the existing
        primitive) OR .ui-quote-card__detail-row (when the variant
        renders custom row markup that can't reuse the detail-list
        primitive). Rows are counted by :nth-child against their
        DIRECT parent — keep stagger candidates as siblings of each
        other inside one row container.

   TUNING — the four custom properties below drive every timing.
   Override at :root, at .ui-quote-card, or at .ui-quote-card__detail
   for one variant to re-cadence without re-authoring the cascade.
   Five-row default total reveal ≈ 400 ms; container opens in 280 ms. */
.ui-quote-card__detail {
  /* Open + chrome (margin/padding/border). Shorter than the row
     reveal so the container is mostly settled by the time rows
     start arriving. */
  --detail-open-duration: 280ms;
  /* Each row's fade-up duration. */
  --detail-row-duration: 220ms;
  /* Delay between row starts. 5-row default = 120 ms total spread. */
  --detail-stagger-step: 30ms;
  /* Initial offset so the container has begun opening before the
     first row appears (otherwise row 1 paints over a closing slot). */
  --detail-stagger-base: 60ms;

  display: grid;
  grid-template-rows: 0fr;
  overflow: hidden;
  /* Chrome starts at zero. Divider retired May 2026 alongside the always-on
     footer divider — body→detail rhythm now relies on the margin gap + the
     "Route" header's typographic weight, not a hairline. */
  margin-top: 0;
  transition:
    grid-template-rows var(--detail-open-duration) ease,
    margin-top var(--detail-open-duration) ease;
}
.ui-quote-card.is-expanded .ui-quote-card__detail {
  grid-template-rows: 1fr;
  margin-top: var(--space-8);
}
/* Inner-wrapper contract — the single direct child must zero its
   min-height (grid items default to min-height: auto which would
   prevent the track from collapsing to 0) and clip its overflow so
   staggered rows don't paint outside the shrinking track during
   collapse. */
.ui-quote-card__detail > * {
  min-height: 0;
  overflow: hidden;
}

/* Row stagger — fade up + 4 px Y offset. Runs only when the parent
   card carries .is-expanded; collapsing simply removes the class and
   the rows are clipped by overflow:hidden as the track collapses.
   The implicit collapse reads as a graceful "tuck away" without
   needing a reverse stagger (cheaper, simpler, sufficient). */
.ui-quote-card.is-expanded .ui-quote-card__detail :where(.detail-list__row, .ui-quote-card__detail-row) {
  animation: ui-quote-detail-row-in var(--detail-row-duration) ease-out both;
}
.ui-quote-card.is-expanded .ui-quote-card__detail :where(.detail-list__row, .ui-quote-card__detail-row):nth-child(1)  { animation-delay: calc(var(--detail-stagger-base) + var(--detail-stagger-step) * 0); }
.ui-quote-card.is-expanded .ui-quote-card__detail :where(.detail-list__row, .ui-quote-card__detail-row):nth-child(2)  { animation-delay: calc(var(--detail-stagger-base) + var(--detail-stagger-step) * 1); }
.ui-quote-card.is-expanded .ui-quote-card__detail :where(.detail-list__row, .ui-quote-card__detail-row):nth-child(3)  { animation-delay: calc(var(--detail-stagger-base) + var(--detail-stagger-step) * 2); }
.ui-quote-card.is-expanded .ui-quote-card__detail :where(.detail-list__row, .ui-quote-card__detail-row):nth-child(4)  { animation-delay: calc(var(--detail-stagger-base) + var(--detail-stagger-step) * 3); }
.ui-quote-card.is-expanded .ui-quote-card__detail :where(.detail-list__row, .ui-quote-card__detail-row):nth-child(5)  { animation-delay: calc(var(--detail-stagger-base) + var(--detail-stagger-step) * 4); }
.ui-quote-card.is-expanded .ui-quote-card__detail :where(.detail-list__row, .ui-quote-card__detail-row):nth-child(6)  { animation-delay: calc(var(--detail-stagger-base) + var(--detail-stagger-step) * 5); }
.ui-quote-card.is-expanded .ui-quote-card__detail :where(.detail-list__row, .ui-quote-card__detail-row):nth-child(7)  { animation-delay: calc(var(--detail-stagger-base) + var(--detail-stagger-step) * 6); }
.ui-quote-card.is-expanded .ui-quote-card__detail :where(.detail-list__row, .ui-quote-card__detail-row):nth-child(8)  { animation-delay: calc(var(--detail-stagger-base) + var(--detail-stagger-step) * 7); }
.ui-quote-card.is-expanded .ui-quote-card__detail :where(.detail-list__row, .ui-quote-card__detail-row):nth-child(9)  { animation-delay: calc(var(--detail-stagger-base) + var(--detail-stagger-step) * 8); }
.ui-quote-card.is-expanded .ui-quote-card__detail :where(.detail-list__row, .ui-quote-card__detail-row):nth-child(10) { animation-delay: calc(var(--detail-stagger-base) + var(--detail-stagger-step) * 9); }
.ui-quote-card.is-expanded .ui-quote-card__detail :where(.detail-list__row, .ui-quote-card__detail-row):nth-child(11) { animation-delay: calc(var(--detail-stagger-base) + var(--detail-stagger-step) * 10); }
.ui-quote-card.is-expanded .ui-quote-card__detail :where(.detail-list__row, .ui-quote-card__detail-row):nth-child(n+12) { animation-delay: calc(var(--detail-stagger-base) + var(--detail-stagger-step) * 11); }

@keyframes ui-quote-detail-row-in {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  .ui-quote-card__detail,
  .ui-quote-card.is-expanded .ui-quote-card__detail,
  .ui-quote-card.is-expanded .ui-quote-card__detail :where(.detail-list__row, .ui-quote-card__detail-row) {
    transition: none;
    animation: none;
  }
}
.ui-quote-card__footer {
  display: flex;
  align-items: center;
  gap: var(--space-12, 12px);
  /* May 2026 — divider retired. Body→footer gap drops from 25 px (12 margin
     + 1 line + 12 padding) to 8 px (margin-top only). The footer reads as
     supporting meta attached to the body row rather than a separate section
     gated by a hairline, and the stack fits one more card in the same height.
     Per CLAUDE.md "Visual separation" — tighter pairs read as one unit. */
  margin-top: var(--space-8);
  /* Pin font-size + line-height on the wrapper so __footer-rate inherits
     a token-defined line-box. The .ui-quote-metric cells inside the footer
     pin their own typography (see the .ui-quote-metric block); this rule
     covers the rate text and any other footer-tier copy. */
  font-size: var(--text-caption, 12px);
  line-height: var(--lh-caption);
  color: var(--text-secondary);
}
.ui-quote-card__footer-rate {
  flex: 1;
  font-variant-numeric: tabular-nums;
}
/* .ui-quote-card__footer-cell retired May 2026 → .ui-quote-metric
   (see the .ui-quote-metric block below). Same anatomy promoted to a
   shared primitive consumed by both .ui-quote-card's footer and the
   .ui-quote-mini-card row. Atomic retirement per CLAUDE.md → "Primitive
   retirement is atomic — no aliases". The earlier Bridge-route ETA
   emphasis rule (`[data-route-kind="bridge"] ... :last-of-type strong`)
   was already retired in an earlier sweep; the `data-route-kind="bridge"`
   attribute is preserved for bridge-only affordances elsewhere
   (second avatar-badge for source chain, "Bridge fee" relabel). */

/* Non-EVM destination warning — visible only when swap.js sets the
   non-hidden state. The `.alert` rule sets `display: flex`, which has
   higher specificity than the default `[hidden]` user-agent rule
   (display: none) — so the bare `hidden` attribute doesn't actually
   hide a `.alert`. Override here with a targeted rule. */
[data-non-evm-warning][hidden] { display: none !important; }


/* .ui-result-state / .ui-activity-card / .ui-recipient-chip retired June 2026
   — zero-consumer first-pass stubs of the execution flow, superseded by the
   mock-aligned rebuild: the status hero is now .ui-tx-status composing
   .spot-icon-4xl --round + tone (the system's canonical result-disc
   convention, shared with the Portal withdraw-fees flow); the Activities
   entry is .ui-tx-summary--interactive; the recipient affordance is the
   .ui-destination-wallet-card. Atomic retirement per CLAUDE.md → "Primitive
   retirement is atomic — no aliases". */


/* ── .ui-quote-detail-table — fee breakdown stack ─────────────────────────
   Thin product-surface wrapper that pairs a header label with a
   .detail-list of fee rows. Used inside the expanded quote card's detail
   slot. The row primitive is .detail-list (universal); this wrapper just
   adds the "Route"-style header.
*/
.ui-quote-detail-table {
  display: flex;
  flex-direction: column;
}
.ui-quote-detail-table__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: var(--space-8, 8px);
}
.ui-quote-detail-table__title {
  font-size: var(--text-sm, 14px);
  /* Pair the 14 px size with its system line-height token so the
     title reads at a consistent 20 px line-box across every consumer
     (today: the expanded detail-table inside .ui-quote-card; future
     consumers may host the table standalone in modals or overlays). */
  line-height: var(--lh-sm);
  font-weight: var(--fw-bold);
  color: var(--text-primary);
  margin: 0;
}


/* ── .ui-quote-mini-list + .ui-quote-mini-card — Slim route picker ────────
   Limit-mode-only compact route selector that sits above the "Expires in"
   row in the main form. The full .ui-quote-list lives in the Receive panel
   (Swap / Bridge modes) and shows amount + meta + provider + expandable
   detail; the mini variant strips to provider-icon + name + gas-cost
   because Limit-mode orders fill at the user's set price (amounts /
   conversion / time are identical across routes), so the only meaningful
   differentiator is provider preference + execution cost.

   Sibling primitive of .ui-quote-card (NOT a modifier) — different
   anatomy (single horizontal row) and different role (Limit-mode-only).
   Composes the canonical UI card recipe per CLAUDE.md — same
   --surface-card + --elev-1 at rest, 1 px transparent border slot,
   4% accent fill + 64% accent border + --elev-2 on .is-active.

   Anatomy:
     __provider          icon + name (semibold)
     __provider-icon     24 × 24 circular avatar with brand logo
     __provider-name     provider label, single-line ellipsis
     __cost              trailing gas value, tabular-nums

   Visibility — dual gate (BOTH must be true):
     mode       .ui-card[data-mode="limit"]               (via [data-limit-only])
     readiness  .widget-stage[data-quotes-state="visible"]  (via setQuotesReady)

   The readiness signal fires when wallet is connected, both tokens are
   picked, send amount > 0, AND the quote sim returned a result —
   driven by ui-amount-card.js → setQuotesReady(), called from
   renderPair() on every input / sim event. Same single-source signal
   that drives the Receive panel's slide-in in Swap / Bridge modes;
   the mini-list and the full list consume the same flag. Empty or
   partial input collapses the list entirely (no "loading skeleton"
   tier — the list's purpose IS comparison, nothing to compare without
   amounts).
*/
.ui-quote-mini-list {
  --card-gap: var(--gap-card-sm);
  /* Consumer-owned display for the [data-limit-only] gate (see the gate
     rule below — defaults to grid for the .setting-row case). */
  --limit-only-display: flex;
  display: flex;
  flex-direction: column;
  gap: var(--card-gap);
}

.ui-quote-mini-card {
  display: flex;
  align-items: center;
  gap: var(--space-12);
  /* Each padding value sits at the next tier on the spacing ladder vs.
     the prior 8/12/8/8 shape — 12/16/12/12. Keeps the asymmetric
     hug-the-icon recipe (slightly tighter left near the avatar,
     slightly looser right near the cost) but steps each value up
     one ladder tier. Min-height follows from 40 → 48 (lg tier on the
     height ladder; 12 + 24 px icon + 12 = 48 exactly). */
  padding: var(--space-12) var(--space-16) var(--space-12) var(--space-12);
  min-height: var(--space-48);
  background: var(--surface-card);
  border: 1px solid transparent;
  border-radius: var(--card-radius);
  box-shadow: var(--elev-1);
  cursor: pointer;
  font-family: var(--font-sans);
  font-size: var(--text-sm);
  color: var(--text-primary);
  transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.ui-quote-mini-card:hover {
  background: color-mix(in oklch, var(--accent-primary) 3%, var(--surface-card));
  box-shadow: var(--elev-2);
}
.ui-quote-mini-card:active,
.ui-quote-mini-card.is-active {
  background: color-mix(in oklch, var(--accent-primary) 4%, var(--surface-card));
  border-color: color-mix(in oklch, var(--accent-primary) 64%, transparent);
  box-shadow: var(--elev-2);
}
/* Keyboard focus ring — root is <div role="radio">, so user-agent focus
   styling isn't supplied. Matches the canonical 2 px accent outline
   pattern used by .tile / .feature-card (see styles.css → "Custom
   :focus-visible"); the offset keeps the ring outside the active rim
   on the .is-active card so both signals read clearly. */
.ui-quote-mini-card:focus-visible {
  outline:        2px solid var(--accent-primary);
  outline-offset: 2px;
}

.ui-quote-mini-card__provider {
  display: inline-flex;
  align-items: center;
  gap: var(--space-8);
  min-width: 0;
  flex: 1;
}
.ui-quote-mini-card__provider-icon {
  /* 20 px so the card's rendered height lands cleanly on the 48 px
     ladder tier (20 icon + 24 padding y + 2 border = 46, floored to
     min-height 48). Bumping to --space-24 pushes the card off-ladder
     to 50 px — verified at ladder pre-ship. */
  width: var(--space-20);
  height: var(--space-20);
  border-radius: 50%;
  background-color: oklch(0% 0 0 / 0.08);
  background-size: cover;
  background-position: center;
  flex-shrink: 0;
}
.ui-quote-mini-card__provider-name {
  /* Pin font-size + line-height on the primitive (not inherited from
     .ui-quote-mini-card's 14 px ambient alone) so the label reads
     identically in every context — catalog preview, playground row,
     and any future overlay / popover that hosts this primitive. Without
     the lh pin the label inherits `normal` (~17 px line-box at 14 px)
     instead of the system-tokenized 20 px pair. */
  font-size: var(--text-sm);
  line-height: var(--lh-sm);
  font-weight: var(--fw-semibold);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* .ui-quote-mini-card__cost retired May 2026 → .ui-quote-metric--fee.
   Mini-card rows now compose the shared metric primitive (see the
   .ui-quote-metric block in §  — search for "── .ui-quote-metric ──").
   Atomic retirement per CLAUDE.md → "Primitive retirement is atomic". */


/* ── .ui-quote-metric — shared route-metric pill ──────────────────────────
   Universal-to-the-swap-widget primitive for "icon + value" route metrics
   (gas fee, execution time). Consumed by .ui-quote-card's footer cells
   (Receive panel, Swap/Bridge) AND by .ui-quote-mini-card rows (Limit-mode
   picker). Promoted May 2026 from the per-card .ui-quote-card__footer-cell
   + .ui-quote-mini-card__cost pair so both surfaces speak the same
   anatomy — fuel/clock icon-button + tooltip + value.

   Two tone modifiers:
     --fee  fuel-pump glyph, "Network fee" tooltip — gas-cost cell
     --eta  clock glyph,      "Estimated time"  tooltip — duration cell

   Customization is by SLOT OMISSION, not by modifier. A consumer that
   wants only the gas metric (e.g., the Limit-mode mini-card, where every
   route has the same ETA so showing it is noise) simply doesn't include
   the --eta span. Per CLAUDE.md → "Composable slots vs variant modifiers"
   — fee/eta are orthogonal optional regions, not different chrome for
   the same content.

   Anatomy (per cell):
     .tooltip-wrap > .btn-icon.btn-xs > svg  — leading icon-button
     .tooltip                                     — popover-API tooltip
     <strong>                                     — trailing value

   Typography:
     icon button   muted ink via .btn-muted
     value         semibold primary (--text-primary), tabular-nums, --text-sm
     gap           --space-4 — a small breath between the .btn-xs icon
                   button and the value, layered on top of the button's
                   own internal padding (6 px each side around a 12 px
                   glyph in a 24 px button).

   Skeleton — see ui-quote-list.js → slotFooterCell (route === null
   branch): circle (--space-24, icon-button footprint) + text-sm (6 ch,
   typical value width). The skeleton is single-source with the real
   markup; both flow through one slot helper. */
.ui-quote-metric {
  display: inline-flex;
  align-items: center;
  gap: var(--space-4);
  /* Pin font-size on the primitive (not at the consumer) so the metric
     pill reads identically in every context — catalog preview, big card
     footer, and the Limit-mode mini-card row. Pinned at --text-sm so the
     value reads at the same 14 px as the mini-card provider-name label,
     unifying both surfaces. Without this pin the value <strong> inherits
     parent context and drifts (catalog default ~16 px vs. the consumers'
     own ambients), defeating the unification. Pair line-height via
     --lh-sm so the type-size pair is system-tokenized end to end. */
  font-size: var(--text-sm);
  line-height: var(--lh-sm);
  font-variant-numeric: tabular-nums;
  flex-shrink: 0;
}
.ui-quote-metric strong {
  color: var(--text-primary);
  font-weight: var(--fw-semibold);
}

/* Mini quote list — readiness gate. Composes with the [data-limit-only]
   mode gate (block at line ~3763). Hidden until ui-amount-card.js →
   setQuotesReady() sets .widget-stage[data-quotes-state="visible"]. The
   selector includes [data-limit-only] so the gate is scoped to the mini-
   list specifically — bumping specificity to (0, 4, 0), which beats the
   data-limit-only "show" rule's (0, 3, 0). Source-order-independent. */
.widget-stage:not([data-quotes-state="visible"]) .ui-quote-mini-list[data-limit-only] {
  display: none;
}

/* Single presentation — the Presentation drill (swap.js → initRailPresentation
   Panel) sets body[data-presentation="single"] to lock the widget to one flow:
   the .seg tab bar hides and the static title takes its place inside
   .screen-header--main. The underlying data-widget-mode is force-synced to the
   picked type, so all mode-specific behavior (receive panel content, CTA copy,
   value-slot labels) still fires through the existing applyMode pipeline. */
.screen-header--main [data-flow-title] { display: none; }
/* Both the Single presentation AND the temporary Mode-rail presentation lock
   the body to one flow — the seg hides, the static title shows. (Mode rail also
   shows the .widget-stage__mode-rail tab rail; see below.) */
body[data-presentation="single"] .screen-header--main [data-flow-seg],
body[data-presentation="mode-rail"] .screen-header--main [data-flow-seg] { display: none; }
body[data-presentation="single"] .screen-header--main [data-flow-title],
body[data-presentation="mode-rail"] .screen-header--main [data-flow-title] { display: block; }

/* ── Mode rail — FULLY RETIRED (Phase 2c) ──────────────────────────────
   The 4-mode vertical icon rail (the old Navigation→Rail presentation) was
   dropped from the model — Presentation is now Single / Tabs / View rail.
   Its base surface rule + catalog demo were removed once the #presentation
   card was reframed to the View rail; nothing references .widget-stage__mode-rail. */

/* Catalog-only show arm — un-gates the View rail for the #presentation card's
   demo so it renders from the real .widget-stage__view-rail surface rule
   rather than a frozen inline copy. Live behaviour is unchanged. */
[data-viewrail-preview] .widget-stage__view-rail { display: flex; }

/* ── View rail — the View-rail presentation's 2-tier switcher ───────────
   A 2-icon left rail (Exchange / Trade) beside the form. Each tier sets its
   own layout (Exchange→Adaptive, Trade→Dual) and tab set (swap.js →
   initViewRail). Shown only in the View-rail presentation; the .seg tab bar
   stays in the header and the tier swaps its tabs. */
.widget-stage__view-rail,
.widget-stage__mode-rail {
  display: none;
  align-self: flex-start;
  background: var(--surface-raised);
  border-radius: var(--panel-radius);
  box-shadow: var(--elev-3);
}
body[data-presentation="view-rail"] .widget-stage__view-rail { display: flex; }
body[data-presentation="view-rail"] .widget-stage { max-width: 968px; }

/* ── Mode rail — TEMPORARY presentation (WIP, advanced-swap work) ───────────
   Reuses the view-rail surface above; shown only in the Mode-rail presentation.
   The body is Single-locked (seg hidden, static title shown — see single-lock
   block above) and forced to Adaptive (single panel) by swap.js → initModeRail,
   so the rail + one form panel flow as a flex row (no Dual grid). Retire as one
   atomic removal alongside the card + aside + initModeRail. */
body[data-presentation="mode-rail"] .widget-stage__mode-rail { display: flex; }
body[data-presentation="mode-rail"] .widget-stage { max-width: 968px; }

/* ── "topbar" mode-nav RETIRED (May 2026) ──────────────────────────────
   The horizontal top-bar presentation of the Default-flow mode switcher
   was generalized into the reusable `.side-nav--topbar` primitive
   (styles.css — sibling of `.side-nav--rail`) and removed from the swap
   widget. The Default-flow switcher now offers tabs / rail / hybrid only.
   `.mode-rail__label` was retired alongside it — the generic primitive
   reuses the family's `.side-nav__label`. Live reference for the generic
   bar: #side-nav-topbar in design-system/index.html. */

/* Right-panel Receive list — hide in Limit mode (the mini-list in the
   main form replaces it). The data-widget-mode attribute is mirrored
   onto <body> by swap.js → applyMode() so the gate reaches the receive
   panel (which is a SIBLING of the form .ui-card, not a descendant —
   the form's data-mode attribute can't reach here via CSS alone).
   In Limit mode the panel surface ALWAYS renders (regardless of
   data-quotes-state) because it hosts market-reference content —
   .ui-token-info + .ui-price-chart — which is independent of trade
   state. Trade-result content (quote list) needs an amount to be
   meaningful; market reference doesn't. See the .widget-stage__receive
   visible-state block at line ~2955 for the dual-condition gate. */
body[data-widget-mode="limit"] .widget-stage__receive .ui-quote-list {
  display: none;
}
body[data-widget-mode="limit"] .widget-stage__receive [data-limit-receive] {
  display: flex;
}
/* Limit-mode receive panel hosts the .ui-token-info + .ui-price-chart
   stack instead of the quote list. The panel-level Receive screen-header
   stays visible in this mode — its title swaps "Receive" → "Market
   price" via JS (swap.js → applyWidgetMode) and the trailing affordance
   swaps refresh-timer → expand-panel (see Receive-header trailing rule
   below).
   (May 2026 — attribute renamed from [data-limit-receive-placeholder]
   once the wrapper outgrew its scaffolding role.) */
[data-limit-receive] {
  display: none;
  flex-direction: column;
  gap: var(--space-12);
  flex: 1;
  min-height: 0;
}

/* Receive header trailing — mode-conditional affordance.
   Both buttons live in the playground.html receive header markup;
   this rule gates which one renders per widget-mode:
     Swap / Bridge  → .refresh-timer (countdown to quote refetch)
     Limit          → [data-action="expand-panel"] (chart fullscreen)
   The chart panel doesn't need a quote-refetch countdown (no quote
   list to refresh); the swap/bridge panels don't need an expand
   affordance (quote list scrolls in place). */
body[data-widget-mode="limit"] .widget-stage__receive .screen-header__trailing .refresh-timer { display: none; }
body:not([data-widget-mode="limit"]) .widget-stage__receive .screen-header__trailing [data-action="expand-panel"] { display: none; }

/* Chart-token tabs — 2-avatar switcher in the receive-panel trailing.
   Lets the user toggle which token's market price the chart + token-info
   row show, once both Send and Receive are picked. Hidden by default;
   swap.js → initChartTokenTabs() flips data-state to "visible" when
   both sides have a pick. Same chart-context gate as [data-action=
   "expand-panel"] (visible in Limit OR Dual); the Dual override
   at line ~4053 below re-asserts visibility in that layout.

   The avatar cell styling lives on the canonical .seg--borderless +
   :has(> .avatar-tandem) auto-detect rule in styles.css. The bespoke
   per-instance padding rule that used to live here (32 px width +
   2 px padding) was retired May 2026 when the chart-token tabs
   adopted the primitive — no aliases, atomic migration. */
.chart-token-tabs { margin-right: var(--space-8); }
.chart-token-tabs[data-state="hidden"] { display: none; }
body:not([data-widget-mode="limit"]) .widget-stage__receive .screen-header__trailing .chart-token-tabs { display: none; }

/* Limit mode hosts TWO cards (Market Price + Orders) in a flex column
   with --gap-card-md. Width is 416 px by default (set by the visible-
   state rule below at line ~4003); widens to var(--w-16) (704 px) in
   Advanced tier (see the Advanced widget tier block below). The column
   itself becomes a flex container so its children (the two .ui-card
   siblings) distribute the stretched column height. The column owns
   the --widget-max-h cap so the two cards share that budget — each
   card individually drops its base max-height cap below. */
body[data-widget-mode="limit"] .widget-stage__receive {
  display: flex;
  flex-direction: column;
  gap: var(--gap-card-md);
  max-height: var(--widget-max-h, none);
  min-height: 0;
}
body[data-widget-mode="limit"] .widget-stage__receive .ui-card {
  max-width: none;
  /* Column owns the height cap (above); individual cards flex
     within it. Drop the base .ui-card max-height (set in styles.css
     :40) so two cards don't each try to claim the full --widget-max-h. */
  max-height: none;
}
/* ── Market-panel split — DEFAULT (2/3 · 1/3) ─────────────────────────
   The receive column hosts Market Price (chart) + Orders. The default
   distribution gives the CHART two-thirds and ORDERS one-third of the
   capped column height — the chart is the hero in Limit mode (you read
   price history to set a level); Orders is glanceable reference. Orders
   scrolls INTERNALLY past its slice (sticky header), so the order count
   is uncapped without growing the widget. height: auto overrides the
   Dual-layout rule's `height: 100%` (sized for a single-card column;
   two cards distribute via flex here).

   The alternate "fill" model (chart fills the widget height, Orders
   stacks below + page-scrolls) is opt-in via [data-limit-panel-split=
   "fill"] — see the block right below. "fill" is the settled playground
   default — stamped statically on <body> in playground.html (the Workshop
   rail's "Market panel" selector was cleared June 2026); no attr = this base. */
body[data-widget-mode="limit"] .widget-stage__receive > .ui-card:not(.ui-orders-panel) {
  flex: 2 1 0;
  min-height: 0;
  height: auto;
}
body[data-widget-mode="limit"] .widget-stage__receive > .ui-orders-panel {
  flex: 1 1 0;
  min-height: 0;
  height: auto;
}
/* Orders body scrolls inside its 1/3 slice; the table header pins so
   columns stay labelled while rows scroll under it. The flex child is the
   .table-scroll (the gradient host — the reusable horizontal-scroll
   primitive, styles.css); the .table-scroll__body is the actual scroller
   nested inside it. flex:1 1 auto lets the viewport take the card
   height after the screen-header; min-height:0 is what actually engages
   the overflow (without it the flex item's min-content floor blocks the
   scroll). The viewport is a flex column so the body fills it and scrolls.
   The sticky thead needs an OPAQUE backdrop so scrolled rows don't bleed
   through — and it MUST match the card's own surface, which is `.panel` →
   --surface-raised (NOT --surface-card). Pinning it to --surface-card
   painted the header pure white over the raised-grey card in light mode —
   a visible white band behind the header row. Matching --surface-raised
   makes the header read as part of the card surface (opaque for scroll,
   invisible at rest) in both themes. Overrides the transparent thead in
   the Orders-card block below. z-index 2 so the sticky header sits above
   the sticky first column (z-index 1); the corner cell goes to 3. */
body[data-widget-mode="limit"] .ui-orders-panel .table-scroll {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
body[data-widget-mode="limit"] .ui-orders-panel .table-scroll__body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}
body[data-widget-mode="limit"] .data-table--orders thead th {
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--surface-raised);
}
body[data-widget-mode="limit"] .data-table--orders thead th:first-child {
  z-index: 3;
}

/* ── Market-panel split — "Stacked" variant (opt-in, data-value="fill") ─
   Two columns. LEFT = the widget (the form + its view-rail), PINNED in
   view. RIGHT = two stacked panels, Market Price on top + Orders below,
   that travel with the page scroll. The page is the scroller (the canvas
   has min-height:100vh + no overflow clip, so the document grows and the
   window scrolls); as Orders grows with the order count the page
   lengthens, and on a short viewport the left rail stays put while the
   right column scrolls past it.

   Mechanics:
   - align-items: start on the stage UNTIES the two columns from each
     other's height (the default `stretch` would cap both to the grid
     row). With start, each column takes its own content height and the
     grid row grows to fit the taller one.
   - The widget PINS via position: sticky on BOTH the form and the
     view-rail (together they are the left rail), offset 24 px from the
     viewport top. sticky needs align-self: start so the grid cell doesn't
     stretch them; they then stick within their tall cell (sized by the
     right column) as the page scrolls.
   - Market Price is a CAPPED band — min 440 / max min(--widget-max-h,
     560 px). Generally aligned with the widget height but a touch
     shorter, so the chart doesn't dominate the column and Orders gets
     honest room below. The chart canvas (flex:1, min-height:0) fills the
     band. (Earlier this variant let the chart fill 70vh; the band keeps
     it bounded.)
   - receive max-height: none drops the --widget-max-h cap so the column
     grows past the viewport.
   - Orders flex: 0 0 auto (natural, full-length) + body overflow visible
     — the list grows with the order count, lengthening the page rather
     than scrolling inside its own slice.

   vs. the 2/3·1/3 "Split" default, which holds both panels inside one
   capped column with Orders scrolling internally. */
body[data-widget-mode="limit"][data-limit-panel-split="fill"] .widget-stage {
  align-items: start;
}
body[data-widget-mode="limit"][data-limit-panel-split="fill"] .widget-stage__form,
body[data-widget-mode="limit"][data-limit-panel-split="fill"] .widget-stage__view-rail {
  position: sticky;
  top: var(--space-24);
  align-self: start;
}
body[data-widget-mode="limit"][data-limit-panel-split="fill"] .widget-stage__receive {
  max-height: none;
}
body[data-widget-mode="limit"][data-limit-panel-split="fill"] .widget-stage__receive > .ui-card:not(.ui-orders-panel) {
  flex: 0 0 auto;
  min-height: 440px;
  max-height: min(var(--widget-max-h, 560px), 560px);
}
body[data-widget-mode="limit"][data-limit-panel-split="fill"] .widget-stage__receive > .ui-orders-panel {
  flex: 0 0 auto;
}
/* Stacked — the viewport + body take their natural (full-length) height so
   the order list lengthens the page instead of scrolling inside its slice.
   overflow-y:visible computes to auto (because overflow-x is auto on the
   body) but with an unconstrained height it never engages a vertical
   scrollbar — so the page scrolls vertically while the body still scrolls
   HORIZONTALLY (the wide 10-col table). Earlier this set overflow:visible,
   which let the table spill out and get clipped by the card with no way to
   reach the hidden columns; the horizontal-scroll affordance needs a real
   scroller here. */
body[data-widget-mode="limit"][data-limit-panel-split="fill"] .ui-orders-panel .table-scroll {
  flex: 0 0 auto;
}
body[data-widget-mode="limit"][data-limit-panel-split="fill"] .ui-orders-panel .table-scroll__body {
  flex: 0 0 auto;
  overflow-y: visible;
}


/* ── Advanced widget tier — Limit-only wider receive panel ─────────────
   The Advanced View-rail tier is the "power user" surface: it's the
   only tier that exposes Limit mode (Simple = Swap / Bridge / Gas;
   Advanced = Swap / Bridge / Limit — see swap.js → TIER_MODES). When
   the user opts into Advanced + Limit, the receive panel widens from
   the canonical 416 px to 704 px (--w-16) to give the chart maximum
   real estate (the Advanced tier is where you read price history to
   set a level). Other widget modes / tiers stay at the default 416 px
   receive — no behavior change.

   Two paths to widen, mirroring the two layout strategies used in
   Limit mode:

   1. Flex path (Adaptive layout, and any non-Dual presentation that
      Limit might reach via the Tabs / Single composer) — override
      flex-basis. Wins on specificity (0,3,0) > (0,2,0) over the
      base limit-mode rule at line ~3950.

   2. Grid path (Dual layout — the canonical Advanced presentation)
      — override grid-template-columns. View-rail composes a 3-col
      grid (rail + form + chart); Tabs / Single use a 2-col grid
      (form + chart). Both need the third column widened.

   Width pick — 704 px = var(--w-16). Bounded by the MacBook Air 13"
   floor (1280 px viewport): fixed cost is 528 px (view-rail 64 + two
   24 gaps + form 416), so the widget lands at 1232 px — ~24 px margin
   each side on a 1280 screen, scaling generously on 1440+. The 7-col
   table's natural width is ~508 px; 704 - 48 (panel padding) + 24
   (table negative-margin compensation) = 680 px of effective table
   width — generous slack for the columns plus chart breathing room.
   (A brief June 2026 bump to var(--w-17) / 736 px was reverted because
   the 1280-floor margin shrank to ~8 px each side — too tight against
   the floor; 704 keeps comfortable breathing room.)
   See the device-resolutions foundation reference for the screen-size
   math.

   See the Orders col-visibility block below — the 7-col variant
   (Started + Actions cells) fires on this same Advanced-tier gate.

   :not([data-expanded]) — these set the RESTING column width only.
   When the user maximizes the chart via [data-action="expand-panel"]
   (sets data-expanded on .widget-stage), the pre-existing Dual-expand
   grid override below (search "Expand — Dual grid") must win and grow
   the receive to fill the canvas. WITHOUT the :not() guard these
   Advanced rules out-specify the expand rule — they carry one more
   attribute selector ([data-view-tier]) than the expand rule, so
   specificity (not source order) decided the tie and pinned the
   receive at --w-16 even while expanded. Scoping to the resting state
   hands the expanded state cleanly to the expand mechanism. */
body[data-widget-mode="limit"][data-view-tier="advanced"] .widget-stage__receive {
  flex-basis: var(--w-16);
}
body[data-layout="dual"][data-widget-mode="limit"][data-view-tier="advanced"] .widget-stage:not([data-expanded]) {
  grid-template-columns: var(--w-7) var(--w-16);
}
body[data-layout="dual"][data-presentation="view-rail"][data-widget-mode="limit"][data-view-tier="advanced"] .widget-stage:not([data-expanded]) {
  grid-template-columns: auto var(--w-7) var(--w-16);
}


/* Stretch both panels to equal height in Limit mode so the chart panel
   matches the form panel's height — driven by the user's Height-drill
   selection via --widget-max-h on the .ui-card max-height. The default
   .widget-stage `align-items: flex-start` sizes each panel to its own
   content, leaving the chart floating above whitespace when the form
   panel is taller. Scoped to Limit mode (no readiness clause) so the
   chart panel is properly stretched at zero-state too. Swap/Bridge
   modes keep their original sizing (their receive panel hosts a quote
   list whose natural height should drive the panel). */
body[data-widget-mode="limit"] .widget-stage {
  align-items: stretch;
}

/* Paired-panel expand affordance — Limit-mode only (buttons hidden in
   non-Limit via the gating rule above). BOTH the Market Price and Orders
   headers carry a [data-action="expand-panel"] trigger; clicking either
   toggles data-expanded on the stage (swap.js → initExpandPanel syncs both
   triggers). This rule grows the receive column's flex-basis to
   (canvas-inner-w − form − gap) so it fills every pixel from the form's
   right edge to the canvas's right padding, regardless of the resting
   width (416 px base, 640 px in Advanced tier). Both cards inside the
   column (Market Price + Orders) widen together — the maximize is purely
   HORIZONTAL; neither card collapses and the 2/3 · 1/3 vertical split is
   preserved. The form panel stays at 416 px; the visual "widget slides
   left" comes for free — the stage is `margin: 0 auto` + `justify-content
   center`, and as the receive's flex-basis grows the stage's intrinsic
   width grows with it, leaving less margin slack for `auto` to
   distribute. The form, anchored at the stage's left flex slot,
   slides toward the canvas's left padding edge. Single flex-basis
   transition (280 ms ease-out, already declared on the receive)
   drives the whole reflow — form, receive, and stage edges all
   interpolate together. Height is untouched; --widget-max-h continues
   to cap both panels' .ui-card. Reset on widget-mode change handled
   in swap.js applyMode().

   (June 2026 — the maximize previously COLLAPSED the Orders card
   (display:none) so the chart filled 100% of the height. Retired: the
   expand control is now a paired affordance on both headers and widens
   both cards horizontally; neither collapses.) */
body[data-widget-mode="limit"] .widget-stage[data-expanded] .widget-stage__receive {
  flex-basis: calc(var(--canvas-inner-w) - 416px - var(--grid-gap-x));
}

/* Icon swap — maximize at rest, minimize when expanded. Both SVGs sit
   in the trigger; aria-expanded gates which one paints. */
[data-action="expand-panel"] .expand-icon--minimize { display: none; }
[data-action="expand-panel"][aria-expanded="true"] .expand-icon--maximize { display: none; }
[data-action="expand-panel"][aria-expanded="true"] .expand-icon--minimize { display: inline; }

/* ── Responsive Orders columns — collapsed shows 8, expanded shows 9 ──
   The receive column is narrow at rest, so the Orders table hides its
   `market` analytical column until the panel is expanded via the paired
   expand control. Reached by HIDING cells on the live recipe (the
   data-col="<id>" tags orders-list.js stamps on every th/td), NOT by
   swapping recipes — so it needs no re-render and scales to more width
   breakpoints in pure CSS. Scoped to .widget-stage:not([data-expanded])
   — the catalog demos live in .ds-doc-well (no widget-stage), so they
   always show their full column set.

   (June 2026 — the `expires` column was also collapsed here when the
   Status column shared the row; with Status retired, expires occupies
   the column slot Status used to hold and stays visible.) */
.widget-stage:not([data-expanded]) .ui-orders-panel [data-col="market"] {
  display: none;
}

/* ════════════════════════════════════════════════════════════════
   Orders panel — Limit-mode-only second panel in the receive column
   ════════════════════════════════════════════════════════════════
   Sits below Market Price as a flex sibling (see the receive-column
   flex rules above for the layout shape). Hosts the 10-column rich
   .data-table of open + recent limit orders (migrated June 2026 from
   the retired full-width .ui-orders-wide baseline). Per-row kebab opens
   a Modify / Repeat / Cancel menu; handlers stub to console until the
   limit-order write API is live.

   Empty / populated toggle — flips on [data-empty="true"] on the
   .ui-orders-panel itself (matches the Market Price card's pattern of
   gating its own [data-limit-receive] body vs. its empty-state).

   Visibility — the card is markup-resident in every widget mode (it
   lives inside .widget-stage__receive); the gate below hides it outside
   Limit mode so Swap / Bridge / Gas never see it. ────────────── */

/* Hide the card outside Limit mode. */
body:not([data-widget-mode="limit"]) .ui-orders-panel {
  display: none;
}

/* ── Body-state hide — card disappears when there's nothing honest to show
   ───────────────────────────────────────────────────────────────────────
   The Orders panel hides ENTIRELY in two states, instead of revealing an
   in-panel empty-state:
     • data-disconnected="true" → wallet not connected. Set by the
       orders wiring IIFE in playground.html.
     • data-empty="true"        → connected but zero orders. Set by
       orders-list.js when getOrders() is empty.

   The card only appears when the user is connected AND has orders — a
   layout-driven exception to the canonical "Wallet-scoped surfaces gate
   their empty-state on connection" rule (see CLAUDE.md). Reasoning: this
   card sits next to an always-visible Market Price chart in the receive
   column, and the form panel beside it already carries the layout's
   primary "Connect wallet" CTA. An in-card connect-prompt would duplicate
   that affordance with no new information; a generic zero-state for
   first-time users adds visual weight to a panel that has nothing to
   tell them. Hiding is cleaner — the panel adapts to the data it has.

   The Activity feed (FAB sub-panel) stays the reference for the canonical
   connect-prompt pattern: it's a standalone wallet-scoped surface with no
   paired always-visible card and no other primary Connect CTA in the same
   panel, so the rule still applies there. */
.ui-orders-panel[data-empty="true"],
.ui-orders-panel[data-disconnected="true"] { display: none; }

/* ── Body sizing + horizontal scroll ─────────────────────────
   The .panel padding (24 px) wraps the table; cells own their own internal
   12 px L/R padding (--sm tier). The rich 9-column table is far wider than the
   narrow receive column (416 px base / 640 px Advanced), so the __body SCROLLS
   HORIZONTALLY (overflow-x: auto). The table is sized to its content
   (width: max-content, min-width: 100% on .data-table--orders) so columns keep
   their nowrap widths and overflow the card rather than compressing. Vertical
   overflow-y: auto + sticky thead are added by the limit-mode rule above
   (search "Orders body scrolls inside its 1/3 slice").

   The .table-scroll surface (--surface-raised) + the edge-to-edge negative-
   margin bleed are now the generic .table-panel concern (styles.css →
   .table-panel > .table-scroll); the orders panel composes `table-panel`, so
   they're no longer restated here. */

/* ── Orders-specific scope — composes `data-table--rich data-table--orders`
   ────────────────────────────────────────────────────────────────────────
   The geometric-alignment chassis (separate borders, 4-grid row/header heights,
   the flex .data-cell wrapper, the .data-cell-text type chassis) was PROMOTED
   June 2026 to the universal .data-table--rich + .data-cell / .data-cell-text
   family (styles.css) — retiring the orders-scoped .ui-order-cell / .ui-order-text,
   atomic, no aliases, per CLAUDE.md → "Primitive retirement is atomic". What
   stays here is only what's specific to the playground Orders table: the
   horizontal-scroll overflow sizing, the chain-column pin, the right-trim, the
   actions-cell centring, the quiet header voice, the value-size bump, the pair
   classes (SELL → BUY), and the status-chip inset.

   width: max-content + min-width: 100% lets the 9-col table overflow the narrow
   receive column for the horizontal scroll (the .table-scroll primitive).
   border-collapse: separate + the 4-grid row/header heights come from --rich,
   so they're not restated here; --rich defaults --data-table-row-h to
   --space-56 (the orders row), so orders needs no height override. The flex
   .data-cell / .data-cell--end wrappers (geometric centring) are the generic
   primitives now. */
.data-table--orders {
  width: max-content;
  min-width: 100%;
}
/* Tighten the trailing inset — the dense value columns don't need the full
   --space-12 cell gutter on their right edge. --space-4 brings the right-
   aligned numbers (and the whole table's right edge) 8px closer. Applied to
   th + td together so right-aligned header labels stay aligned with their data
   (overrides the --space-12 from .data-table--sm; swap.css loads after). */
.data-table--orders th,
.data-table--orders td { padding-right: var(--space-4); }
/* Actions (kebab) cell — CENTRE the icon button in its column. Two parts:
   (1) symmetric padding — the general right-trim gives 4px right; match the left
   to 4px so the content box is centred in the cell; and (2) override the right-
   align inherited from .data-cell--end with justify-content: center, so the
   single circular button sits centred instead of hugging the right edge with a
   lopsided left gap. (Both are needed: centring in an asymmetric content box
   wouldn't centre in the cell.) Scoped to the actions column so the first
   column's 12px left gutter — which the .table-scroll edge-bleed is calibrated
   to (Pair header ≡ Orders title) — and the right-aligned numeric columns are
   untouched. */
.data-table--orders td[data-col="actions"] { padding-left: var(--space-4); }
.data-table--orders td[data-col="actions"] .data-cell { justify-content: center; }
/* Chain column — fixed width that fits the "Chain" header label (~41px at
   the 14px header + 24px cell padding ≈ 65px) → --space-72 (72px), the
   first token clearing that. The avatar (32px) + header both hug the left
   (the cell is flex with no justify, the th is left-aligned), so the
   trailing space past the avatar is just breathing room, not a centred gap.
   The pin is explicit (not natural width) so the table's
   `width: max-content; min-width: 100%` can't distribute slack INTO this
   column when the content is narrower than the panel — chain stays fixed,
   slack flows to the flexible value columns. (Was --space-56 / no header
   while the column hugged the avatar alone; the visible "Chain" label was
   restored June 2026 for at-a-glance parsing of what the mark represents,
   and the pin grew one step to fit it.) */
.data-table--orders th:first-child,
.data-table--orders td:first-child {
  width: var(--space-72);
}
/* Header VOICE — now inherited from .data-table--rich thead th (styles.css).
   The calmer voice (normal-case 14px regular, muted ink, the global uppercase +
   loose tracking cancelled) was promoted June 2026 from this orders-only
   override into the --rich base, so it's gone from here — orders renders an
   identical header by inheritance. The limit-mode sticky rule above still
   repaints the bg opaque (--surface-raised) for scroll; that stays orders-
   specific. */

/* ── Horizontal-scroll affordance — orders-specific bits ─────────────────
   The viewport + edge gradients + < > nav + pinned-first-column are the
   reusable .table-scroll primitive (styles.css; --pinned opt-in). Wiring:
   shared.js → initTableScroll(). Only ONE thing is orders-specific now:
   the corner cell carrying BOTH the thead bottom-hairline and the pinned
   column's right-hairline (the primitive paints only the right one, and
   wins on specificity, so the orders rule restates both, scoped past it).

   (Pinned avatar scroll-gap retired June 2026 alongside the
   `.data-table--sm` 8/8 promotion — every cell, pinned or scrolling,
   now reads at symmetric 8/8.) */

/* Corner cell: thead bottom-hairline + pinned right-hairline together.
   Scoped through .table-scroll--pinned .table-scroll__body to outweigh the
   primitive's right-only first-child box-shadow (CLAUDE.md → Variant-aware
   states / specificity). */
.table-scroll--pinned .table-scroll__body .data-table--orders thead th:first-child {
  box-shadow: inset 0 -1px 0 var(--border-subtle), inset -1px 0 0 var(--border-subtle);
}

/* ── Pair classes (.ui-order-pair-*) — orders-domain (SELL → BUY) ───────────
   These stay orders-scoped (the SELL → BUY pair identity is orders-specific);
   only the generic cell-text chassis was promoted — every value reads at
   .data-cell-text, the universal primitive in styles.css. The pair label adds
   only structural geometry on top. (Renamed June 2026 from the stale .ow-
   ("orders-wide") prefix — a leftover from the retired wide panel — atomically
   with no aliases, per CLAUDE.md → Primitive retirement is atomic.) */
/* Pair name (col 2) — structural only: the arrow gap + nowrap so the
   SELL → BUY name keeps one line. Its typography (size, line-box, weight,
   primary ink) comes entirely from .data-cell-text--bold; it no longer borrows
   the .ui-card-label recipe (a card-TITLE label, not a row identifier).
   (.ui-order-pair, the old single-cell avatar+name wrapper, was retired
   June 2026 when the pair split into avatar | name columns so the scroll pins
   only the avatars — atomic, no aliases, per CLAUDE.md → Primitive retirement
   is atomic.) */
.ui-order-pair-label {
  gap: var(--space-4);
  white-space: nowrap;
}
/* Pair separator — Lucide arrow-right (icons/arrow-right.svg), muted so the
   SELL / BUY symbols on each side keep primary ink and stand out. stroke uses
   currentColor, so `color` sets the arrow tint. Sized on the 4-grid. */
.ui-order-pair-arrow {
  flex: 0 0 auto;
  width: var(--space-16); height: var(--space-16);
  color: var(--text-muted);
}
/* ── Row value type — now inherited from the --rich base ────────────────────
   The cell-text chassis (.data-cell-text + --bold / --muted / --secondary /
   --danger) was PROMOTED June 2026 to styles.css as a universal primitive,
   retiring the orders-scoped .ui-order-text + its variants + the
   --ui-order-text-weight / --ui-order-text-ink slots — atomic, no aliases, per
   CLAUDE.md → "Primitive retirement is atomic". The 16px value size USED to be
   set here (.data-table--orders { --data-cell-text-size: --text-body }) as an
   orders-only bump; June 2026 it was promoted into the .data-table--rich base
   (styles.css) because every real --rich consumer wants the comfortable look —
   so this declaration is gone and orders inherits 16px from the base, identical
   render. Terminal Expires states (expired → .data-cell-text--muted, cancelled
   → --danger) + the active countdown (--secondary) are emitted directly by the
   renderer as ink modifiers; same typography across every state, only ink
   shifts (CLAUDE.md → "State changes shift ink, not weight"). */
/* Status chips — tighten the horizontal inset (12 → 8) so the pill hugs its
   label. The Status column auto-sizes to its longest member ("Cancelled"),
   so trimming the chip's side padding is what narrows the whole column; the
   default 12px chip inset is more than a dense status pill needs. */
.data-table--orders .chip { padding-inline: var(--space-8); }

/* Compact data-table row hover INSIDE this card — neutral fill is the
   canonical list-row recipe; carried automatically by .data-table. */

/* ── Order row — column alternatives + dense type ──────────────────────
   The order row is a single fixed composition (orders-list.js → COLUMN_ORDER);
   the dense --text-sm type is the base look (set on .data-table--orders
   above). The ROW_RECIPES registry + the `compact` row modifier that used to
   live here were collapsed June 2026 once the table dropped to one recipe
   (CLAUDE.md → "Named-recipe registry"). What remains below: the dormant
   stacked-pair alternative (a structural column option, swapped into
   COLUMN_ORDER to use) + the row's pair-separator arrow sizing. ─────────── */

/* `pair-stacked` column (DORMANT) — the pair as two stacked text lines, no
   avatars (the structural alternative to the avatar + name column pair). One
   left-aligned cell; sell symbol on top (primary), → buy symbol below
   (muted, caption). Tight line-boxes keep the two lines inside the 56-row.
   Not in COLUMN_ORDER; swap `pair-stacked` in for `pair-name` to use it. */
.ui-order-pair-stacked {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  white-space: nowrap;
  line-height: var(--lh-caption);
}
.ui-order-pair-stacked__sell {
  font-size: var(--text-body);
  font-weight: var(--fw-semibold);
  color: var(--text-primary);
}
/* Buy line leads with the PAIR_ARROW Lucide glyph (same arrow source as the
   inline separator — no Unicode-glyph drift). Different ROLE though: a leading
   prefix on a caption line, not a centered inline separator. So it gets its
   own treatment — a tight flex line + the arrow scaled to the 12px caption
   size (proportional, mirroring the inline arrow's 16px-on-16px ratio), NOT a
   copy of .ui-order-pair-arrow's 16px. Tint inherits from the base rule's
   --text-muted, which equals this line's colour. */
.ui-order-pair-stacked__buy {
  display: inline-flex; align-items: center; gap: var(--space-4);
  font-size: var(--text-caption);
  color: var(--text-muted);
}
.ui-order-pair-stacked__buy .ui-order-pair-arrow {
  width: var(--space-12); height: var(--space-12);
}

/* Pair separator arrow — sized down to match the dense --text-sm (14px) row
   type; the default 16px reads oversized against it. 12px (--space-12, on the
   4-grid; there's no --space-14) keeps the arrow proportional and never larger
   than its symbols. (A graphic dimension, not a font size, so it's an explicit
   value rather than binding to the row-value token. Row height stays
   var(--space-56) on the 4-grid; the avatar tier is unchanged.) */
.data-table--orders .ui-order-pair-arrow {
  width: var(--space-12); height: var(--space-12);
}


/* ── .ui-token-info — Token identity + at-a-glance state ───────────────
   Wrapper primitive carrying the full read-only snapshot of a token:
   tandem mark (token glyph + chain badge), ticker · chain name, spot
   price, and N percent-delta chips across time windows. Designed for
   reuse across the swap widget's Limit-mode Receive panel AND any
   future token-info overlay (hover card, popover, sticky header, etc.).

   Slots are opt-in by composition — consumers include only the rows
   they need. Overlays might drop __spot for a compact identity-only
   row; the playground includes all three (identity row above, spot
   row below).

   Anatomy:
     .ui-token-info               — flex column wrapper, gap 12
       .ui-token-info__identity   — composed .list-item.list-item--lg
                                    (avatar tandem + ticker · chain)
       .ui-token-info__spot       — baseline flex row, wraps when narrow
         .ui-token-info__price    — spot price, value-display 36 px tier
         .ui-token-info__deltas   — inline-flex of N bare delta chips

   (May 2026 v2 — promoted from a .list-item modifier to a wrapper
   primitive. v1 was identity-only; the price + deltas lived in
   .ui-price-chart__header. Reused on its own (overlay, popover), the
   identity-only row read as just a labelled header — folding the price
   + deltas inside makes the primitive self-contained. The chart's
   __header slot retired atomically; the chart now carries only its
   timeframes row + canvas.) */
.ui-token-info {
  /* Per-instance slot indirection — matches the pattern on
     .ui-amount-card and .ui-limit-price-card. Consumed by
     .ui-token-info__price below. Override per-instance via inline
     style or context selector without re-authoring the rule. */
  --token-info-price-size:     var(--text-h2-lg);     /* 36px — value-display tier; shared with .ui-amount-card + .ui-limit-price-card */
  --token-info-price-weight:   var(--fw-bold);        /* 700 — cluster-wide weight; see .ui-amount-card's --amount-card-amount-weight for rationale. Per-instance override via inline style="--token-info-price-weight: var(--fw-semibold)". */
  --token-info-price-tracking: var(--tracking-firm);  /* -0.02em — value-display tier, paired with --text-h2-lg */
  --token-info-price-lh:       1.1;                    /* unitless ratio — display-tier exception (no token for this pair) */
  --token-info-price-color:    var(--text-primary);

  display: flex;
  flex-direction: column;
  gap: var(--space-12);
}

/* Identity row — composes .list-item.list-item--lg. The .ui-token-info__identity
   modifier zeros the row chrome (no padding, no min-height, no radius)
   since the row sits in a panel, not a scrolling list. */
.ui-token-info__identity {
  padding: 0;
  min-height: 0;
  border-radius: 0;
  gap: var(--space-12);
  --avatar-size: var(--space-40);
}
/* Inline-heading layout — title + desc side-by-side at matched body-lg
   so the pair reads as one heading ("ETH Ethereum") rather than as a
   title-and-subtitle stack. May 2026 experiment, restored after
   testing 32 px and reading too small for the page-title-tier role. */
.ui-token-info__identity .list-item__body {
  flex-direction: row;
  align-items: baseline;
  gap: var(--space-8);
}
.ui-token-info__identity .list-item__title,
.ui-token-info__identity .list-item__desc {
  font-size: var(--text-body-lg);
  line-height: var(--lh-body-lg);
}

/* Spot row — price + deltas. Baseline-aligned flex row so the large
   price number sits on the same baseline as the smaller chip values;
   wraps when the deltas group can't fit on the same line. */
.ui-token-info__spot {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--space-12);
}
/* Spot price — value-display tier. Matches .ui-amount-card__amount +
   .ui-limit-price-card__amount typography so all large numeric values
   across the swap widget (Sell / Buy / Limit / Spot) read at the same
   visual weight. All three primitives consume per-component slot
   custom-properties for size / weight / tracking / lh / color, so
   per-instance overrides happen via the slot AND the cluster-wide
   defaults can be flipped in unison by editing the three slot defaults
   together (e.g. bold ↔ semibold across the cluster). If any of those
   four properties move on one primitive, mirror on the other two in
   the same edit. */
.ui-token-info__price {
  font-family: var(--font-sans);
  font-size: var(--token-info-price-size);
  font-weight: var(--token-info-price-weight);
  letter-spacing: var(--token-info-price-tracking);
  line-height: var(--token-info-price-lh);
  color: var(--token-info-price-color);
  font-variant-numeric: tabular-nums;
}
/* Delta chips row — N bare delta chips with the time-window folded
   inside each via the chip family's `.chip-window` sub-slot. Gap 12 is
   the unit-to-unit rhythm between chips; per-chip internal spacing
   (arrow → value → window) comes from the chip's own gap ladder.

   Migration note (May 2026): the pair-wrapper + sibling-label pattern
   (.ui-price-chart__delta + .ui-price-chart__delta-window) retired
   atomically. Window text lives INSIDE each .chip.chip--bare as a
   .chip-window child — see styles.css → `.chip-window` and the
   catalog at design-system/index.html#chip-bare for the canonical
   recipe. */
.ui-token-info__deltas {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-12);
}


/* ── .ui-price-chart — Price chart section for Limit mode ───────────────
   Sibling of .ui-token-info; sits below it in the wide Receive panel.
   NOT a card — drops the panel/card chrome (no bg, no border, no
   shadow, no padding) so the chart fills the parent panel's content
   area edge-to-edge. The enclosing .panel.ui-card carries the surface
   recipe; this is a section primitive that just stacks the timeframes
   row above the chart canvas.

   Anatomy:
     .ui-price-chart                  — flex column, gap 12, flex 1
       .ui-price-chart__timeframes    — single-selection chip-neutral group
       .tradingview-chart             — Lightweight Charts canvas

   (May 2026 v2 — __header / __price / __deltas slots retired atomically.
   The spot price + delta chips now live in .ui-token-info above; the
   chart carries only historical visualization, the token-info row owns
   current state.) */
.ui-price-chart {
  display: flex;
  flex-direction: column;
  gap: var(--space-12);
  flex: 1;
  min-height: 0;
}

/* Timeframe quick-zoom buttons — sits between the price header and
   the chart canvas. Single-selection group; clicking re-ranges the
   chart's visible time scale (1D / 1W / 1M / All). Composes the
   canonical .chip.chip-neutral primitive so the row matches the
   chip family vocabulary. */
.ui-price-chart__timeframes {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
}


/* Chart container — fills the .ui-price-chart body and bleeds past
   the enclosing .panel.ui-card's 24 px padding on the LEFT and RIGHT
   edges so the price-axis renders flush. The BOTTOM keeps the panel's
   natural 24 px padding (canonical card-padding tier) so the time-axis
   labels (21 / 26 / May / 6 / …) breathe rather than running into the
   panel's rounded bottom edge. The panel's overflow: hidden +
   border-radius: 16px clip the chart's straight bottom corners to the
   panel's rounded shape automatically. min-height is the natural
   baseline the panel grows to in Auto / no-cap mode; when
   --widget-max-h is set via the rail's Height drill, the enclosing
   .ui-card max-height caps the whole panel and the chart shrinks to
   fit.

   The container hosts a canvas painted by TradingView's Lightweight
   Charts library (autoSize: true in chartOptions reads this element's
   dimensions and resizes the canvas to match). */
.ui-price-chart .tradingview-chart {
  flex: 1;
  width: auto;
  min-height: 0;
  margin-inline: calc(-1 * var(--space-24));
  border-radius: 0;
  overflow: hidden;
}

/* data-stable on .ui-price-chart and .ui-token-info — set by
   tradingview-chart.js whenever the buy token resolves as a stablecoin.
   No CSS branch hangs off this attribute anymore (May 2026): stables
   render the chart and delta chips like every other token because a
   pegged asset CAN still move (depeg events) and the chart is exactly
   the surface a trader reads to spot that. The attribute is retained
   as a semantic hook for any future stable-specific behavior; consumers
   should not reach for it to hide content. .ui-price-chart__empty was
   the prior empty-state element and is retired atomically with the
   CSS rules. */


/* ══════════════════════════════════════════════════════════════════════
   PLAYGROUND SHELL — three zones (Apr 2026 v2)
   ──────────────────────────────────────────────────────────────────────
   Migrated from a single `.widget-stage` page-outer that flexed three
   columns (rail / form / receive) into a layered shell:

     body.playground-body         — sets backdrop to --surface-sunk +
                                    declares CSS vars consumed by canvas
                                    + rail (so the collapse toggle changes
                                    one var and everything reflows)
     .playground-canvas           — full-bleed --surface-sunk surface,
                                    pads itself for the floating navbar
                                    (top) and the floating rail (left)
     .playground-rail             — fixed-position glass panel pinned to
                                    the left edge, theme-composer
                                    recipe (--glass-base 70% + blur 40px +
                                    inset highlight + 2-layer shadow,
                                    mirrors .ds-doc-menu-panel). Collapses
                                    to a 64px icon rail via .is-collapsed
                                    on body
     .widget-stage                  — centered widget container (form +
                                    chain-filter | receive) — keeps its
                                    existing :has() rules and inner
                                    flex layout

   Why a body-level CSS var for rail width: the canvas needs to know the
   rail's current footprint to pad correctly, but the rail is fixed-
   positioned so a sibling selector won't drive layout reflow. One var on
   body, two consumers (canvas padding + rail width), single source of
   truth — the JS toggle just sets `body.classList.toggle('rail-collapsed')`
   and both elements transition together.
   ══════════════════════════════════════════════════════════════════════ */
body.playground-body {
  /* Apr 2026 v12 — rail width bumped from 280px → 320px. With theme
     cards + comprehensive editor sections + sliders all sharing the
     column, 280px was tight (theme-card mockups read squat, two-column
     button rows in the editor wrapped). 320px gives ~40px more breathing
     room without crowding the centered widget — the canvas content area
     loses 40px which is absorbed by the widget-stage's existing slack
     (max-width 880px caps the widget pair). */
  /* Apr 2026 v18 — sized one tier below the .ui-card widget canvas
     (--w-7). Both tokens defined in styles.css, so the rail and the
     widget read as a tight, related pair side by side. */
  --rail-width: var(--w-6);
  --rail-left: 24px;
  --rail-gap: 24px;
  /* Canvas inner width — used by .widget-stage[data-expanded] to grow the
     Receive panel's flex-basis to fill the remaining stage width after
     the form + gap. Computed from the canvas's own horizontal padding
     so the receive's expanded width tracks the rail-collapsed override
     below. Single source of truth for any future "fill the canvas"
     measurement. */
  --canvas-inner-w: calc(100vw - var(--rail-width) - var(--rail-left) - (2 * var(--rail-gap)));
  /* Apr 2026 v6 — top reduced from 116px to 24px when the DS nav was
     removed. The playground is its own peer destination now; brand
     identity + back-out lives in the rail's logo lockup, not in a
     top navbar. */
  --canvas-top: 24px;
  --canvas-bottom: 24px;
  /* Shared slide transform — both the rail and the .drawer-handle
     consume this so they translate as one unit. Keeps the handle visually
     attached to the panel's top-right corner during the entire slide. */
  --rail-tx: 0px;
  background: var(--surface-sunk);
}
/* Root overscroll match (Jun 2026) — the global `html { background:
   var(--surface-page) }` in styles.css paints the ROOT white, and the
   macOS rubber-band / overscroll area beyond the scroll container reveals
   the ROOT background (the body's bg does NOT propagate to the viewport
   once html carries its own). So scrolling past the canvas's top/bottom
   edge flashed page-white instead of the sunk canvas. Paint the root to
   --surface-sunk on the playground so overscroll matches the canvas;
   scoped via :has() so every other page keeps its page-white root. */
html:has(body.playground-body) { background: var(--surface-sunk); }
/* Collapsed mode (Apr 2026 v20) — the rail and its top-right handle slide
   off as a single unit. Translation is exactly (rail-width + rail-left), so
   when the panel hits its end-state the handle ends up flush against the
   left viewport edge — the only chrome still visible. The rail keeps its
   full width throughout (inner content does not reflow), only its position
   changes; the canvas drops its left rail-padding so the widget recenters
   in the freed-up viewport. */
body.playground-body.rail-collapsed {
  --rail-tx: calc(-1 * (var(--rail-width) + var(--rail-left)));
  --canvas-inner-w: calc(100vw - (2 * var(--rail-gap)));
}
body.playground-body.rail-collapsed .playground-canvas {
  padding-left: var(--rail-gap);
}
body.rail-collapsed .playground-rail {
  pointer-events: none;
}

/* EMBED MODE — the widget alone, no playground chrome. Driven by
   /playground.html?embed=1 (see initEmbedMode in swap.js): the customization
   rail is force-collapsed (rail-collapsed, above) and every editor affordance
   is hidden, so the page is just the running widget — the surface the
   vilendesign.com "LI.FI Widget" case study iframes and screenshots. Purely
   additive and param-gated: without ?embed the playground is untouched. */
body.embed-mode #playgroundRailHandle,
body.embed-mode #playgroundVariantsRailHandle,
body.embed-mode #playgroundVariantsRail,
body.embed-mode #dsFab,
body.embed-mode .widget-stage__view-rail {
  display: none !important;
}
body.embed-mode.playground-body.rail-collapsed .playground-canvas {
  /* No rail to clear — center the widget in the full viewport. */
  padding-left: var(--rail-gap);
  padding-right: var(--rail-gap);
}

.playground-canvas {
  position: relative;
  min-height: 100vh;
  padding: var(--canvas-top)
           var(--rail-gap)
           var(--canvas-bottom)
           calc(var(--rail-width) + var(--rail-left) + var(--rail-gap));
  background: var(--surface-sunk);
  transition: padding 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
  /* The widget stage's historical form↔receive gutter is 24, not the :root 16
     default — pin --grid-gap-x here so cold load preserves it. The Spacing
     composer paints this same var inline on the canvas, overriding this. */
  --grid-gap-x: var(--space-24);
  /* Re-declare --panel-surface AT the canvas (elevated default) so it resolves
     against the canvas's THEMED --surface-raised, not :root's. Without this, a
     scoped Theme Composer pick paints --surface-raised on the canvas but the
     panels' --panel-surface stays the value :root resolved (var(--surface-raised)
     is substituted at the :root declaration, once) — so .panel backgrounds keep
     the sitewide theme while the cards inside re-colour. Mirrors portal.css →
     .portal-canvas (which re-declares it flat). June 2026 — see CLAUDE.md →
     "Theme Composer scope". */
  --panel-surface: var(--surface-raised);
}

/* ── .playground-rail — floating glass customization panel ────────────────
   Mirrors .ds-doc-menu-panel's surface treatment so the rail reads as a
   sibling of the FAB / theme-composer family. Differences vs. the
   FAB panel: pinned to the left edge (not bottom-right), full-height
   between the navbar and the bottom of the viewport, larger interior
   for the setting-row stack.
*/
.playground-rail {
  /* May 2026 — composes the universal `.panel` primitive (styles.css →
     Panel). All surface chrome (background, shadow, backdrop, border,
     padding, internal stack) flows from `.panel`. The rail adds only
     what's rail-specific: fixed positioning, full-height geometry,
     the squared top-right corner where the .drawer-handle docks,
     and the slide-in/out transform.
     Markup: <aside class="panel playground-rail" …> */
  position: fixed;
  left: var(--rail-left);
  top: var(--canvas-top);
  bottom: var(--canvas-bottom);
  width: var(--rail-width);
  /* Override panel default: top-right corner squared so it meets the
     .drawer-handle's flat left edge cleanly. */
  border-radius: var(--space-16) 0 var(--space-16) var(--space-16);
  z-index: 100;            /* above canvas; below navbar (1000) + modals (9000+) */
  /* Apr 2026 v12 — overflow-y moved to the active screen rule below.
     The rail itself is now non-scrolling so the floating footer stays
     pinned to the bottom while screen content scrolls inside. */
  overflow-x: hidden;
  transform: translateX(var(--rail-tx));
  transition: width 0.3s cubic-bezier(0.2, 0.8, 0.2, 1),
              padding 0.3s cubic-bezier(0.2, 0.8, 0.2, 1),
              transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
}

/* Light-mode shadow now token-driven — --shadow-panel-glass auto-flips
   via [data-theme="light"]. No per-component override needed. */


/* ── Rail handle positioning — consumer-specific wiring for .drawer-handle ─
   The visual chrome (28×72, glass surface, chevron, hover/focus) lives
   in the universal .drawer-handle primitive in styles.css. This block
   ONLY wires the rail's specific geometry:
     - Anchored to the rail's top-right corner (left = rail-left + rail-width)
     - Translates with the rail via the shared --rail-tx body-level var
     - Chevron rotates 180° when the rail collapses (default-expanded points
       LEFT = "push away"; flipped-collapsed points RIGHT = "pull back")

   Promoted May 2026 v15 — see styles.css → .drawer-handle for the
   universal anatomy and the consumer-wiring contract.
*/
#playgroundRailHandle {
  position: fixed;
  /* 72-px handle height puts the icon center at y = 24 + 36 = 60, exact
     same line as the three btn-sm controls in the rail header
     (y = 44 + 32/2 = 60). */
  top: var(--canvas-top);
  left: calc(var(--rail-left) + var(--rail-width));
  z-index: 99;
  /* Shared translation with the rail — both consume the same body-level
     CSS var so they slide as a single unit. */
  transform: translateX(var(--rail-tx));
  transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1),
              color 0.18s ease;
}
body.rail-collapsed #playgroundRailHandle .drawer-handle__chevron {
  transform: rotate(180deg);
}

/* ── .ds-doc-variants-rail consumer wiring (May 2026 v17) ────────────────────
   Mirror of the left .playground-rail, docked to the right viewport edge.
   Same mechanics — fixed positioning, body-level --vrail-tx CSS var that
   the rail and its drawer-handle both consume so they slide as one unit —
   but with two flipped defaults:

     1. Width tier: same --w-6 (384 px) as the left rail. The variants rail
        carries flat label + caption + class-chip cards (no preview art),
        which doesn't need more horizontal room.
     2. Default state: COLLAPSED. The left rail is always-on customization;
        the variants rail is workshop tooling, opt-in. Default = collapsed,
        click the right-edge handle to open. The body class that signals
        the expanded state is `vrail-expanded` (not the inverse, to keep
        the default DOM clean of state classes).

   The canvas's right padding reflows when the rail opens so the Receive
   panel slides left to stay fully visible — workshop use case demands
   seeing the variant cards AND the live widget at the same time. */
body.playground-body {
  --vrail-width: var(--w-6);
  --vrail-right: 24px;
  /* Default collapsed: rail sits off-screen to the right by its full width
     plus its right offset. Click the handle → set body.vrail-expanded → tx
     drops to 0 and the rail slides in. */
  --vrail-tx: calc(var(--vrail-width) + var(--vrail-right));
}
body.playground-body.vrail-expanded {
  --vrail-tx: 0px;
}
body.playground-body.vrail-expanded .playground-canvas {
  padding-right: calc(var(--vrail-width) + var(--vrail-right) + var(--rail-gap));
}

.ds-doc-variants-rail {
  position: fixed;
  right: var(--vrail-right);
  top: var(--canvas-top);
  bottom: var(--canvas-bottom);
  width: var(--vrail-width);
  z-index: 100;
  /* Inner padding mirrors the .panel default so content breathes. */
  padding: var(--space-24);
  /* Squared TOP-LEFT corner where the drawer-handle docks (mirror of the
     left rail's squared top-right). */
  border-radius: 0 var(--space-16) var(--space-16) var(--space-16);
  transform: translateX(var(--vrail-tx));
  transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
  /* Allow vertical scroll for long variant lists; horizontal hidden so
     the slide-out doesn't reveal off-canvas content. */
  overflow-y: auto;
  overflow-x: hidden;
}
/* When collapsed, the rail is off-canvas — disable pointer events so it
   doesn't intercept clicks meant for the canvas underneath. */
body.playground-body:not(.vrail-expanded) .ds-doc-variants-rail {
  pointer-events: none;
}

/* Rail handle (right edge) — same primitive as the left rail's handle,
   consumer-wired to the right. The handle is the ONLY chrome visible
   when the rail is collapsed; it sticks out at the right viewport edge
   at the same y-coordinate as the left rail's handle for visual symmetry. */
#playgroundVariantsRailHandle {
  position: fixed;
  top: var(--canvas-top);
  /* Anchor to the rail's LEFT edge. When the rail is expanded (tx = 0),
     the handle sits at right = rail-width + rail-right, peeking out into
     the canvas. When collapsed (tx = rail-width + rail-right), the handle
     ends up flush against the viewport's right edge — the only chrome
     left. */
  right: calc(var(--vrail-width) + var(--vrail-right));
  z-index: 99;
  transform: translateX(var(--vrail-tx));
  /* Drawer-handle's chrome assumes left-edge dock (square left, rounded
     right). For the right-edge dock, flip: rounded left, square right.
     Override the universal primitive's border-radius here since the
     primitive intentionally doesn't ship a "right-edge" variant. */
  border-radius: var(--space-12) 0 0 var(--space-12);
  transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1),
              color 0.18s ease;
}
/* Chevron direction: when COLLAPSED (default), the chevron points LEFT
   (`<`) — "click to pull the rail in from the right." When EXPANDED, it
   rotates 180° to point RIGHT (`>`) — "click to push the rail away." This
   is the inverse of the left rail's chevron (which points left at rest
   because the left rail starts expanded). */
#playgroundVariantsRailHandle .drawer-handle__chevron {
  transform: rotate(0deg);
}
body.vrail-expanded #playgroundVariantsRailHandle .drawer-handle__chevron {
  transform: rotate(180deg);
}

/* ── .playground-rail__top-actions — top-right action cluster ──────────
   Three icon buttons (.btn-icon.btn-borderless.btn-circle) that
   live INSIDE the rail's header (Apr 2026 v16 — promoted from
   absolute-positioned sibling). Mode toggle + reset + settings. The
   header row's `justify-content: space-between` puts the brand lockup
   on the left and this cluster on the right, all on one centered line.
   May 2026: recipe unified with the widget header buttons (which use
   the same composition) — both surfaces are now one button family with
   one hover treatment (transparent at rest; 8 % primary-wash on hover;
   icon color steady; smooth 0.2s fade). Previously this cluster used
   .btn-tertiary.btn-neutral.btn-sm.btn-static which painted a resting
   alpha fill and a different hover curve — visible drift against the
   widget header right next door. */
.playground-rail__top-actions {
  display: inline-flex;
  align-items: center;
  /* gap: 0 — icons sit shoulder-to-shoulder, mirroring the widget
     header's .screen-header__trailing slot (the canonical icon-cluster
     gap for borderless icon buttons next to each other). The 40 px
     button hit-targets already include their own internal padding so
     the icons read as a row, not a tight clump. */
  gap: 0;
  flex-shrink: 0;
}

/* Header (lockup + top-action cluster) — visible only when the rail is
   expanded. Flex row: brand-lockup on the left (LI.FI mark + wordmark +
   "Playground" sub-product label, with chevron→back-arrow hover), action
   cluster on the right.

   The navbar's own .navbar-site__logo is hidden on the playground (rule below) so
   the brand identity reads from a single place per surface. The lockup
   inside the rail keeps its `<a href="../index.html">` target — clicking
   the brand still routes home, just from a different position. See
   `.brand-lockup` in styles.css for the canonical component. */
.playground-rail__head {
  /* Apr 2026 v16 — header is a flex ROW (was column). The logo lockup
     sits on the left, the top-action cluster on the right. The cluster
     is now a structural child of the header instead of an absolute-
     positioned sibling, so the row composition is honest and the
     padding-right hack is gone. */
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-12, 12px);
  padding: 0 var(--space-4);
}

/* Hide the navbar's logo on the playground — the brand identity has
   moved into the rail's header. Keep all other nav chrome (tabs, theme
   toggle, hamburger) so the page still has working cross-page nav. */
body.playground-body .navbar-site__logo { display: none; }
/* Apr 2026 v12 — `.playground-rail__seg` + `.playground-rail__seg-btn`
   removed. The Design/Code seg in the rail header was retired (no Code
   view existed yet); quick actions moved to the floating footer
   (.playground-rail__footer below). */

/* ── .playground-rail__footer — JS hook + button-specific overrides ─────
   Two-button quick-access row pinned to the bottom of the rail on the
   ROOT screen only. Sits OUTSIDE every `.playground-rail__screen` in
   markup, but scoped via `:not([data-screen="root"])` below so it
   only renders on the home panel. Secondary panels (theme-edit,
   layout-*, etc.) hide it — Copy code / Read docs are top-level
   shortcuts and duplicating them inside focused sub-panels reads as
   noise. Buttons: Copy code (snippet to clipboard) and Read docs
   (external link).

   Layout chrome (horizontal equal-width + top hairline + bottom-pin)
   migrated to the universal .action-bar.action-bar--footer in May 2026
   v16 — the markup carries both classes: `.action-bar.action-bar--footer
   .playground-rail__footer`. The `.playground-rail__footer` class
   remains as a JS hook (swap.js → initRailFooter() reads it for event
   delegation). The button-specific override below stays — it carries
   gap + truncation tuning for the rail's narrow column. */
.playground-rail:not([data-screen="root"]) .playground-rail__footer {
  display: none;
}
/* Drop the .action-bar--footer divider — the rail's panel gap already
   separates the footer; a hairline reads as noise in the narrow column.
   Per-consumer opt-out; the shared component keeps its divider for modals.
   Matches the Portal rail footer's treatment (portal.css). */
.playground-rail__footer.action-bar--footer {
  border-top: 0;
}
.playground-rail__footer-btn {
  /* Compose on .btn-secondary.btn-sm — overrides only the gap so the
     icon and label sit tighter inside the narrow rail column. */
  gap: var(--space-8);
  white-space: nowrap;
  /* The .btn-* primitive truncates with ellipsis when its content
     overflows; min-width:0 lets that work inside the grid track. */
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
.playground-rail__color-swatches {
  display: inline-flex;
  align-items: center;
  gap: 4px;
}
.playground-rail__color-swatches > span {
  width: var(--space-12); height: var(--space-12);
  border-radius: var(--space-4);
  display: inline-block;
}

/* ══════════════════════════════════════════════════════════════════════
   Site preview — host-site navigation above the widget
   ──────────────────────────────────────────────────────────────────────
   body[data-nav-preview="lifi|jumper"] reveals the matching .host-nav
   (styles.css → .host-nav) at the canvas top, pushing the widget down —
   the widget demos in situ under a host site's chrome. The attribute is
   absent when the preview is Off (no "off" value — keeps the bare
   [data-nav-preview] selector honest). Rail control: the Settings
   screen's "Site preview" drill row → swap.js → initRailNavPreview();
   persisted in lifi-playground-nav-preview (reset-modal scan covers it).
   ══════════════════════════════════════════════════════════════════════ */
.playground-canvas > .host-nav {
  display: none;
  /* Side gutter — real host navs (jumper.xyz / lifi.fi) inset their logo +
     actions from the bar edges rather than hugging them. Scoped to the
     playground canvas (NOT the .host-nav primitive) so the catalog #host-nav
     preview in its .ds-doc-well is left untouched. Both brand variants inherit. */
  padding-inline: var(--space-32);
}
/* Nav on → the host bar sits FLUSH to the canvas top edge (drop the 24px
   --canvas-top inset) so it reads as the host site's real top nav. Scoped
   override on the consumer; default --canvas-top still applies off-preview
   (CLAUDE.md → scoped override, not a shared-default edit). */
body[data-nav-preview] .playground-canvas { padding-top: 0; }
/* Reveal the active variant, plus a comfortable bottom gap that pushes the
   widget clear of the chrome. */
body[data-nav-preview="jumper"] .playground-canvas > .host-nav--jumper,
body[data-nav-preview="lifi"]   .playground-canvas > .host-nav--lifi {
  display: flex;
  margin-bottom: var(--space-64);
}
/* With a host nav present the widget no longer vertical-centers — it sits
   directly below the nav (host-site layout): kill the stage's centering
   clamp so the nav's bottom gap (above) is the only thing setting its top.
   Applies to BOTH brand variants — general principle. */
body[data-nav-preview] .widget-stage { margin-top: 0; }

/* ══════════════════════════════════════════════════════════════════════
   .widget-stage — centered widget container
   ──────────────────────────────────────────────────────────────────────
   Holds the mode form + the chain-filter | receive sibling. Page-outer
   responsibilities (full-viewport height, --surface-sunk backdrop, top
   padding for the navbar, left padding for the rail) moved up to
   .playground-canvas. This stays a pure widget shell — flex with
   center justification and the existing :has() picker-state rules.

   Renamed June 2026 from .swap-stage — the widget spans five modes
   (swap · bridge · limit · private · gas), so the swap-specific prefix
   was a misnomer; "widget" matches the existing umbrella vocabulary
   (data-widget-mode, --widget-max-h, the "Widget" catalog group). Atomic
   rename, no aliases (CLAUDE.md → Primitive retirement is atomic). The
   __receive / __form / __view-rail suffixes were left unchanged.
   ══════════════════════════════════════════════════════════════════════ */
.widget-stage {
  display: flex;
  align-items: flex-start;
  justify-content: center;
  /* Column gutter (form ↔ receive) = the Spacing composer's horizontal axis;
     row-gap covers the narrow-viewport column wrap (see the @media below).
     --grid-gap-x defaults to 24 on .playground-canvas (this surface's historical
     gutter) — see styles.css :root + the .playground-canvas override below. */
  column-gap: var(--grid-gap-x);
  row-gap: var(--grid-gap-y);
  margin: 0 auto;
  /* Track the gutter so a wider gap never clips the two 416 panels (+24 slack). */
  max-width: calc(416px + var(--grid-gap-x) + 416px + var(--space-24));
  background: transparent;
  /* May 2026 — vertical-centering clamp. On tall viewports the widget sits
     lower than the canvas top edge so it feels balanced about ⅓ down (the
     -64px offset accounts for visual-vs-mathematical center — the eye
     reads top-to-bottom, so a true mid-height feels too low). On short
     viewports the clamp falls to 0 so the canvas's existing `--canvas-top`
     (24px) is the only top inset and the widget never clips off-screen.
     Cap at 136px so 4K displays don't push the widget into the lower half.
     Fallback widget height of 640px when --widget-max-h is unset (Auto). */
  margin-top: clamp(0px, calc((100vh - var(--widget-max-h, 640px)) / 2 - 64px), 136px);
}
/* `width` is definite, not just `flex: 0 0 416px`. In the single/adaptive
   (flex, non-grid) presentation .widget-stage shrink-wraps its content, and
   under that intrinsic-sizing constraint a flex-basis is treated as
   content-based — so a state whose content is narrower than 416 (Settings,
   modes, tx-details) would collapse the whole form column to its max-content.
   A definite width holds the column at 416 across every state. Harmless in
   the dual-grid layouts (the form is a grid item in a 416px track). */
.widget-stage__form { flex: 0 0 416px; width: 416px; }
.widget-stage__receive {
  /* Receive panel hidden by default. Reveals on EITHER of two
     conditions (whichever fires first):

       (a) Readiness — wallet connected, both tokens picked, send
           amount > 0, quote sim returned a result. The flag is
           data-quotes-state="visible" on .widget-stage, written by
           ui-amount-card.js → renderPair() (single source of truth —
           runs on every sim event, input change, and post-pick
           re-render). This is the Swap / Bridge path; in those modes
           the panel hosts the route comparison (.ui-quote-list),
           which legitimately can't render without an amount.

       (b) Limit mode (body[data-widget-mode="limit"]) — the panel
           ALWAYS renders in Limit mode because it hosts the market
           reference (.ui-token-info + .ui-price-chart), which is
           independent of trade state. The user looks at the chart
           BEFORE typing a limit price, so the panel must be visible
           at zero-state in this mode.

     May 2026 history: was "both tokens picked" only → tightened to
     full readiness → relaxed to "OR Limit mode" once the panel's
     Limit-mode content became market-reference instead of trade-
     result. The empty/idle state in Swap / Bridge is "no panel"
     (form sits centered in the canvas), matching how Uniswap /
     Jumper / 1inch handle it. In Limit mode the empty state is
     "panel visible with chart, mini-list collapsed in the form."

     Hidden-state recipe — flex-basis: 0 collapses the panel out of the
     layout (the widget re-centers in the canvas). opacity: 0 + a small
     translateX(-16px) nudge make the panel appear to slide outward
     from the widget's right edge when it transitions in. The opacity
     fade is offset 40 ms so the slide leads slightly — the eye reads
     "the panel slid out, then revealed itself", a calmer cadence than
     a simultaneous fade-and-slide. 280 ms ease-out is the subtle-but-
     deliberate tier — long enough to feel intentional, short enough
     to stay out of the way. pointer-events: none prevents the
     collapsed panel from intercepting clicks while transitioning in.
     overflow: hidden clips the inner 416 px content to the animated
     flex-basis so nothing leaks past the panel's outer boundary.

     Picker-state override below uses transition: none for a snap-hide
     — the chain-filter sibling has its own animation taking this
     slot, no need to chain.

     overflow swap — the panel's own --elev-3 panel-material shadow (24 px
     y-offset, 56 px blur) and the inner quote-cards' --elev-1 / --elev-2
     shadows all need to paint past the receive panel's box-edge. At rest
     we want NO clip so shadows fade naturally; during the collapsed /
     transitioning state we still need `overflow: clip` so the inner
     416 px content doesn't leak while flex-basis animates from 0. The
     `transition-behavior: allow-discrete` makes overflow flip mid-
     animation (50 % of the 280 ms slide-in — ~140 ms — by which time
     the panel is mostly extended; the brief content reveal during the
     remaining slide is imperceptible against a transparent flex slot).
     */
  flex: 0 0 416px;
  flex-basis: 0;
  opacity: 0;
  transform: translateX(-16px);
  overflow: clip;
  pointer-events: none;
  /* min-width: 0 — flex items default to min-width: auto = min-content,
     which means the panel can't shrink below its contained chart's
     intrinsic width. After the expand→collapse cycle the TradingView
     chart's internal DOM has been resized to the expanded width, and
     min-content would hold the panel open at that width. min-width: 0
     lets flex-basis shrink the panel freely; the chart's ResizeObserver
     re-renders to the smaller size on the next frame. Safe at every
     state — the hidden (flex-basis: 0) state already needs to shrink to
     0, and the default visible state (416 px) is enforced by flex-basis
     not min-content. */
  min-width: 0;
  /* Cancel the form↔receive column-gap while the panel is COLLAPSED so the
     zero-state row width matches across modes. A zero-width but display:block
     flex item still paints the container's 24px column-gap; Private hides the
     panel with display:none (no gap), so without this the centered widget sat
     12px further left in Swap/Bridge/Gas than in Private and jumped sideways
     on the mode switch. The negative margin nets the collapsed gap to 0 and
     animates to 0 in lockstep with flex-basis 0→416 on reveal. Reset to 0 in
     the visible-state rule below AND the dual always-visible rule (the grid
     track owns its own gutter there). June 2026 — flex column-gap quirk, not
     a margin; see CLAUDE.md "Flex containers + text-overflow" family. */
  margin-left: calc(-1 * var(--grid-gap-x));
  transition:
    flex-basis 280ms ease-out,
    opacity    220ms ease-out 40ms,
    transform  280ms ease-out,
    margin     280ms ease-out,
    overflow   280ms allow-discrete;
}
/* Visible state — fires on EITHER readiness OR Limit mode.
   - Readiness path (Swap / Bridge / Limit): wallet + tokens + amount
     > 0 + quote returned → setQuotesReady(true) sets data-quotes-state.
   - Limit-mode path: panel ALWAYS visible because it hosts the market
     reference (.ui-token-info + .ui-price-chart), which is independent
     of trade state. Trade-result content (quote list) needs an amount;
     market reference doesn't.
   Mini-list visibility is gated SEPARATELY on readiness alone (see
   .ui-quote-mini-list rule near line ~2247) — it's trade-result
   content, so it still requires amounts. */
.widget-stage[data-quotes-state="visible"] .widget-stage__receive,
body[data-widget-mode="limit"] .widget-stage__receive {
  flex-basis: 416px;
  opacity: 1;
  transform: translateX(0);
  overflow: visible;
  pointer-events: auto;
  margin-left: 0;            /* restore the form↔receive gutter on reveal */
}
.widget-stage:has(.ui-card[data-state="picker"]) .widget-stage__receive,
.widget-stage:has(.ui-card[data-state="chain-picker"]) .widget-stage__receive,
.widget-stage:has(.ui-card[data-state="modes"]) .widget-stage__receive,
.widget-stage:has(.ui-card[data-state="settings"]) .widget-stage__receive,
.widget-stage:has(.ui-card[data-state="review"]) .widget-stage__receive,
.widget-stage:has(.ui-card[data-state="tx-progress"]) .widget-stage__receive,
.widget-stage:has(.ui-card[data-state="activity"]) .widget-stage__receive,
.widget-stage:has(.ui-card[data-state="tx-details"]) .widget-stage__receive {
  flex-basis: 0;
  opacity: 0;
  transform: translateX(-16px);
  transition: none;
}
@media (prefers-reduced-motion: reduce) {
  .widget-stage__receive { transition: none; }
}

/* ══════════════════════════════════════════════════════════════════════
   Compact layout variant — single panel
   ──────────────────────────────────────────────────────────────────────
   body[data-layout="compact"] collapses the widget to ONE panel.
   The receive aside + the token-picker's chain-filter sidebar are removed;
   routes move INTO the form: an inline preview of the selected route under
   the amount cards (.ui-quote-inline), plus a "show more" drill into the
   full list (the canonical .ui-quote-list is relocated into the form's
   `quotes` state by swap.js → initRailVariantPanel).

   Scope note — Compact targets Swap / Bridge / Gas only. Limit mode keeps
   its two-panel layout (the receive aside hosts the price chart + token
   info, which is market reference, not a trade result), so every Compact
   rule that touches the receive aside is gated `:not([data-widget-mode="limit"])`.
   ══════════════════════════════════════════════════════════════════════ */

/* Hide the receive aside in Compact (Swap / Bridge / Gas). display:none
   removes it from the flex row → the form re-centers as a single column.
   Wins over the readiness/limit reveal rules above (display beats every
   geometric property). Limit keeps the aside (its chart lives there). */
body[data-layout="compact"]:not([data-widget-mode="limit"]) .widget-stage__receive {
  display: none;
}

/* Hide the token-picker's chain-filter sidebar in Compact (always) and in
   Dual+Limit. Matches the reveal selector's condition (picker open) at
   higher specificity so no !important is needed.
     • Compact — chain selection is a drill (the in-form chain-picker
       state), not a side panel; no room for a sidebar in one column.
     • Dual+Limit — single-chain by design (the Buy chain is locked to
       Sell), so a chain filter would be useless chrome; the persistent
       right column keeps the market-price chart.
   Dual+Swap/Bridge is the EXCEPTION (carved out below): there the sidebar
   IS shown, repositioned into the `chart` grid area, replacing the Receive
   panel during token selection. */
body[data-layout="compact"] .widget-stage:has(.ui-card[data-state="picker"]) .split-pane.split-pane--chain-filter,
body[data-layout="dual"][data-widget-mode="limit"] .widget-stage:has(.ui-card[data-state="picker"]) .split-pane.split-pane--chain-filter {
  display: none;
}

/* Chain-shortcut grid inside the token picker — the COMPACT replacement
   for the adaptive-variant chain-filter sidebar (which can't sit side-by-
   side in Compact's single panel). Hidden by default; revealed in compact
   only, with breathing below before the search input. Dual does NOT use
   this grid — its persistent right column hosts the full chain picker
   instead (the .split-pane--chain-filter panel repositioned into the
   `chart` grid area; see the Dual-layout block below), so the in-picker
   shortcut row is redundant there and stays hidden.
   The primitive is fill-to-width (auto-fit + minmax(56 px, 1fr) — see
   .card-avatar-grid in styles.css): it packs as many ~56 px columns as
   the ~352 px picker-head usable width allows, then stretches them to
   meet both edges. A full chain set wraps to two rows; a customised
   config with only a few chains stretches those tiles wide to fill the
   single row. .ui-token-picker__head's L/R padding already aligns the
   grid with the search and the rows below. Limit mode in compact
   is single-chain by design — no filtering needed, so the slot stays
   hidden there too. */
[data-chain-shortcuts] { display: none; }
body[data-layout="compact"]:not([data-widget-mode="limit"]) .ui-card[data-state="picker"] [data-chain-shortcuts] {
  display: grid;
  margin-bottom: var(--space-12);
}
/* Single-chain ecosystem collapse (Solana, Tron) for the All-networks
   shortcut tile — sibling of the wide list-row rule (see .ui-chain-list
   .list-item-all__multi .avatar:only-child). The ecosystem-aware preview
   yields one mark, so the cluster's lone avatar spans the full 2 × 2 box
   and reads as a single chain mark. Scoped to the All tile so the shared
   .card-avatar--cluster primitive (always fed four marks elsewhere) is
   untouched. */
[data-card-avatar-all] .card-avatar__cluster .avatar:only-child {
  grid-column: 1 / -1;
  grid-row: 1 / -1;
  --avatar-size: var(--card-avatar-mark);
}

/* Inline route preview — hidden by default; revealed in two cases, both
   ready (data-quotes-state) + form state. It's a read-only projection of
   the selected route.
     • Compact + non-Limit — the full list lives behind the "show more"
       drill. (Dual was dropped May 2026: its persistent right panel shows
       the full route list directly, so an in-form preview would be
       redundant — and there's no `quotes` drill to "show more" into.)
     • Private (any layout) — Private surfaces a SINGLE Houdini route, so the
       big receive panel is suppressed (see the Private block below) and this
       inline card is the only quote surface. No "show more" drill (single
       route → renderInline hides the button; initCompactQuotes skips the
       drill in Private). Sits after the receiving-wallet card per the form
       order Send → Receive → Receiving wallet → Route → Review. */
.ui-quote-inline { display: none; }
body[data-layout="compact"]:not([data-widget-mode="limit"]) .widget-stage[data-quotes-state="visible"] .ui-card[data-state="form"] .ui-quote-inline,
body[data-widget-mode="private"] .widget-stage[data-quotes-state="visible"] .ui-card[data-state="form"] .ui-quote-inline {
  display: flex;
  flex-direction: column;
  gap: var(--space-8);
}
/* Private inline is read-only (single route) — no drill, so it shouldn't
   read as clickable. The footer tooltip info-buttons stay interactive. */
body[data-widget-mode="private"] .ui-quote-inline .ui-quote-card { cursor: default; }
/* Catalog accommodation — the preview pane can't reproduce the Compact +
   ready app state that reveals the inline preview, so un-gate it inside a
   .ds-doc-viewport-pane. Visibility-only; appearance is unchanged. */
.ds-doc-viewport-pane .ui-quote-inline {
  display: flex;
  flex-direction: column;
  gap: var(--space-8);
}

/* Quotes drill host — the relocated .ui-quote-list scrolls inside the
   `quotes` state. The state-content envelope (flex:1 1 auto; min-height:0)
   is set by the shared gating rule above; this owns the scroll. */
.ui-quotes-drill {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  scrollbar-width: thin;
  /* Same gutter-bleed as the form stack so the scrollbar sits in the
     panel's padding instead of cropping the quote cards' right edge. */
  margin-inline: calc(-1 * var(--space-24));
  padding-inline: var(--space-24);
}
/* Phase 2: when the form card opens its picker state, slide in the
   chain-filter .split-pane sibling beside it. The host page (.widget-stage)
   is responsible for marking the picker state — we read it via :has(). */
.widget-stage:has(.ui-card[data-state="picker"]) .split-pane.split-pane--chain-filter {
  display: flex;
}

/* ══════════════════════════════════════════════════════════════════════
   Dual layout variant — persistent two-panel: form + chart + quick settings
   ──────────────────────────────────────────────────────────────────────
   body[data-layout="dual"] reuses Compact's single-panel drill
   (routes/pickers collapse INTO the form — the chain-filter sidebar hide and
   the inline-route-preview show are shared via the :is(compact, dual) gates
   above; the .ui-quote-list relocates into the form's `quotes` drill in
   swap.js). On top of that it adds two PERSISTENT panels that don't slide:

     • Right column — the market-price chart (.ui-token-info + .ui-price-chart),
       always visible, spanning the full left-column height.
     • Left column — the form on top + a quick-settings sub-panel beneath it.

   Layout is CSS Grid (Compact / Adaptive stay flex, untouched). The `chart` area
   spans BOTH rows so the chart fills whatever height form + quick occupy.
   All three mode-nav presentations (tabs / rail / hybrid) compose with the
   grid — see the mode-nav composition block below. rail / hybrid each get a
   leading grid column; tabs is the base 2-col grid.
   ══════════════════════════════════════════════════════════════════════ */
body[data-layout="dual"] .widget-stage {
  display: grid;
  column-gap: var(--grid-gap-x);
  row-gap: var(--grid-gap-y);
  grid-template-columns: 416px 416px;
  grid-template-areas: "form chart";
  /* Base dual layout is the 2-col (tabs) grid: width:auto + max-width
     caps it at the two-panel width. max-width tracks the gutter so a wider
     --grid-gap-x can't clip the columns. The rail / hybrid rules below widen
     it to a 3-col fit-content grid. */
  width: auto;
  max-width: calc(416px + var(--grid-gap-x) + 416px);
}
body[data-layout="dual"] .widget-stage__form    { grid-area: form;  }
body[data-layout="dual"] .widget-stage__receive { grid-area: chart; }

/* The View-rail presentation composes with the Dual grid: the view-rail
   takes a leading `rail` column beside the form; the .seg tabs stay in the
   form header. (Tabs / Single use the base 2-col grid above. The retired
   icon-rail's grid rules were removed in Phase 2c.) */
body[data-layout="dual"][data-presentation="view-rail"] .widget-stage {
  grid-template-columns: auto 416px 416px;
  grid-template-areas: "rail form chart";
  width: fit-content;
  max-width: 100%;
}
body[data-layout="dual"][data-presentation="view-rail"] .widget-stage__view-rail { grid-area: rail; }

/* Receive panel ALWAYS visible (no slide). Override the hidden default, the
   readiness/limit reveal transform, AND the picker-state hide so the chart
   stays put through every form state. */
body[data-layout="dual"] .widget-stage__receive,
body[data-layout="dual"] .widget-stage:has(.ui-card[data-state="picker"]) .widget-stage__receive,
body[data-layout="dual"] .widget-stage:has(.ui-card[data-state="chain-picker"]) .widget-stage__receive,
body[data-layout="dual"] .widget-stage:has(.ui-card[data-state="modes"]) .widget-stage__receive,
body[data-layout="dual"] .widget-stage:has(.ui-card[data-state="settings"]) .widget-stage__receive,
body[data-layout="dual"] .widget-stage:has(.ui-card[data-state="review"]) .widget-stage__receive,
body[data-layout="dual"] .widget-stage:has(.ui-card[data-state="tx-progress"]) .widget-stage__receive,
body[data-layout="dual"] .widget-stage:has(.ui-card[data-state="activity"]) .widget-stage__receive,
body[data-layout="dual"] .widget-stage:has(.ui-card[data-state="tx-details"]) .widget-stage__receive,
body[data-layout="dual"] .widget-stage:has(.ui-card[data-state="quotes"]) .widget-stage__receive {
  display: flex;
  opacity: 1;
  transform: none;
  overflow: visible;
  pointer-events: auto;
  flex-basis: auto;
  transition: none;
  height: 100%;             /* fill the 2-row grid area */
  margin-left: 0;           /* always-visible grid track — gutter is the grid column-gap, not the collapsed-state negative margin */
}
body[data-layout="dual"]:not([data-widget-mode="limit"]) .widget-stage__receive > .ui-card {
  height: 100%;             /* the chart grows vertically to the column height */
}
/* June 2026 — Limit mode hosts TWO cards in the receive column (Market
   Price + Orders), so the single-card height:100% rule above is scoped
   out. Limit mode's own flex distribution rules (search "Market Price
   card —" in the Limit-mode block above) take over: Market Price gets
   flex:1 1 0 and Orders gets flex:0 1 auto. */

/* ── Dual + Swap/Bridge — chain picker takes the Receive column ──────────
   During token selection, the persistent Receive column has nothing useful
   to show (no amount → idle placeholder), so we hand that 416-px column to
   the chain picker instead. This reuses the SAME .split-pane--chain-filter
   primitive that Adaptive shows as a side sidebar — its chain rows and
   click-to-filter wiring already run for every stage (picker-render.js →
   renderChainList + initChainDelegation render/wire it regardless of
   layout), so this is placement + visibility only: NO JS. It also replaces
   the in-picker compact shortcut grid that Compact uses (hidden in Dual by
   the [data-chain-shortcuts] rule above) — the full list on the right is
   the Dual equivalent.

   Scope: Swap + Bridge only. Limit keeps the chart in this column (single-
   chain — no filter needed; the sidebar is hidden by the Compact/Dual+Limit
   hide rule above). Gas is a Simple-tier mode → Adaptive, never Dual.

   Header-less, matching the Adaptive sidebar (search + chain list, no
   title). Repositioned into the `chart` grid area (both the rail and non-
   rail Dual templates define it); width:auto lets it fill the 416-px
   column (the base .split-pane is a fixed 320 px); height:100% fills the
   grid area exactly as the Receive panel does. */
body[data-layout="dual"]:is([data-widget-mode="swap"], [data-widget-mode="bridge"]) .widget-stage:has(.ui-card[data-state="picker"]) .split-pane.split-pane--chain-filter {
  display: flex;
  grid-area: chart;
  width: auto;
  height: 100%;
}
/* Hide the Receive panel in that same case. The always-visible block above
   keeps Receive shown through every Dual form state (including picker), so
   this higher-specificity override hides it specifically while the picker
   is open in Swap/Bridge, freeing the `chart` column for the chain picker.
   Receive returns on picker-back (the override stops matching). */
body[data-layout="dual"]:is([data-widget-mode="swap"], [data-widget-mode="bridge"]) .widget-stage:has(.ui-card[data-state="picker"]) .widget-stage__receive {
  display: none;
}

/* Side-panel content per mode (the canonical mode→panel mapping from
   design/components/swap.md, which Adaptive already honors — Dual now does
   too). Limit → market-reference stack (token-info + chart). Swap / Bridge /
   Gas → the route list. The non-Limit case isn't asserted here: the base
   `[data-limit-receive] { display: none }` already hides the chart, and the
   base `.ui-quote-list { display: flex }` already shows the list, so the
   route list falls through correctly. The routes-idle placeholder below
   handles the "no amount yet" state (Dual's panel is persistent, so unlike
   Adaptive it can't just slide away when there's nothing to compare).
   (May 2026 — replaced an unconditional chart-on / list-off pair that forced
   the synthetic price chart into every mode, contradicting the spec mapping
   and showing a near-meaningless chart for Bridge. See CLAUDE.md.) */
body[data-layout="dual"][data-widget-mode="limit"] .widget-stage__receive [data-limit-receive] { display: flex; }
body[data-layout="dual"][data-widget-mode="limit"] .widget-stage__receive .ui-quote-list { display: none; }

/* Receive header trailing — chart context (Limit) offers the expand
   affordance + chart-token tabs and hides the refresh-timer; route context
   (Swap/Bridge/Gas) falls through to the base mode rules (refresh-timer
   shown, expand + tabs hidden), exactly like Adaptive. Scoped to Limit so
   the route-panel modes inherit the correct route-context chrome. */
body[data-layout="dual"][data-widget-mode="limit"] .widget-stage__receive .screen-header__trailing .refresh-timer {
  display: none;
}
body[data-layout="dual"][data-widget-mode="limit"] .widget-stage__receive .screen-header__trailing [data-action="expand-panel"] {
  display: inline-flex;
}
/* Chart-token tabs — visibility in Dual+Limit mirrors the expand button.
   The both-picked condition still gates the JS-managed data-state. */
body[data-layout="dual"][data-widget-mode="limit"] .widget-stage__receive .screen-header__trailing .chart-token-tabs {
  display: inline-flex;
}
body[data-layout="dual"][data-widget-mode="limit"] .widget-stage__receive .screen-header__trailing .chart-token-tabs[data-state="hidden"] {
  display: none;
}

/* Routes-idle placeholder — Dual non-Limit only, when the persistent panel
   has no amount entered yet (no routes to compare). Honest .empty-state in
   place of the route list — NOT faked routes, NOT the synthetic chart. The
   readiness flag data-quotes-state="visible" (set by ui-amount-card.js on
   tokens-picked + amount > 0, wallet-optional) is the trigger: not-visible →
   idle placeholder shown, list hidden; visible → list shown (its own
   "no routes found" empty state covers the amount-entered-but-unroutable
   case). Hidden everywhere else: Adaptive hides the whole panel at idle;
   Limit's chart owns the panel. The refresh-timer is also suppressed at idle
   (no quote to count down to). */
[data-routes-idle] { display: none; }
body[data-layout="dual"]:not([data-widget-mode="limit"]) .widget-stage:not([data-quotes-state="visible"]) .widget-stage__receive .ui-quote-list {
  display: none;
}
body[data-layout="dual"]:not([data-widget-mode="limit"]) .widget-stage:not([data-quotes-state="visible"]) .widget-stage__receive [data-routes-idle] {
  display: flex;
}
body[data-layout="dual"]:not([data-widget-mode="limit"]) .widget-stage:not([data-quotes-state="visible"]) .widget-stage__receive .screen-header__trailing .refresh-timer {
  display: none;
}

/* Expand — Dual grid. Mirrors Limit's flex-basis grow: the chart column
   widens to fill the canvas while the form/quick column stays 416. For the
   2-col layout (tabs) the px→calc-px track interpolates, so the reflow
   animates like Limit's slide; the rail 3-col case grows via 1fr (snaps —
   rail+expand is an edge combo). The chart's ResizeObserver re-renders the
   canvas to the new width. */
body[data-layout="dual"] .widget-stage {
  transition: grid-template-columns 280ms ease-out, max-width 280ms ease-out;
}
body[data-layout="dual"] .widget-stage[data-expanded] {
  grid-template-columns: 416px calc(var(--canvas-inner-w) - 416px - var(--grid-gap-x));
  max-width: var(--canvas-inner-w);
}
body[data-layout="dual"][data-presentation="view-rail"] .widget-stage[data-expanded] {
  grid-template-columns: auto 416px minmax(0, 1fr);
  width: var(--canvas-inner-w);
  max-width: var(--canvas-inner-w);
}

/* ══════════════════════════════════════════════════════════════════════
   Private mode — single-panel, inline quote
   ──────────────────────────────────────────────────────────────────────
   Private surfaces a SINGLE Houdini route, so the separate receive panel
   (the route-comparison column) is redundant. Suppress it in EVERY layout
   and let the compact inline preview in the form carry the quote (revealed
   by the .ui-quote-inline rule above). This runs after the Dual / Adaptive
   reveal rules so it wins.

   • Receive panel hide — the first selector matches at (0,5,1) so it beats
     the Dual `:has(.ui-card[...])` reveals (same specificity, declared
     later); the second handles Adaptive (flex — the form re-centers).
   • Dual grid collapse — drop the `chart` column so the hidden panel
     doesn't leave an empty 416 px track. Base (tabs / single) → one
     `form` column; view-rail → `rail form`.
   ══════════════════════════════════════════════════════════════════════ */
body[data-layout="dual"][data-widget-mode="private"] .widget-stage:has(.ui-card) .widget-stage__receive,
body[data-widget-mode="private"] .widget-stage__receive {
  display: none;
}
body[data-layout="dual"][data-widget-mode="private"] .widget-stage {
  grid-template-columns: 416px;
  grid-template-areas: "form";
  width: auto;
  max-width: 416px;
}
body[data-layout="dual"][data-presentation="view-rail"][data-widget-mode="private"] .widget-stage {
  grid-template-columns: auto 416px;
  grid-template-areas: "rail form";
  width: fit-content;
  max-width: 100%;
}

/* ── Quick settings — Dual + Bridge/Swap only ──────────────────────── */
/* A compact stack of deep-link shortcut rows near the bottom of the form,
   directly ABOVE the action footer, as a power-user section — the settings
   summary the user reviews right before the final CTA. Each row is a
   `.setting-row--sm --drill` that opens its setting's sub-screen as a
   full-panel takeover (swap.js → initQuickSettings).

   Two gates compose: layout=dual (the spacious Advanced-tier variant —
   keeps the rows out of Compact / Adaptive where the form is tighter)
   AND widget-mode. Swap and Bridge each get their OWN block keyed by
   data-quick-settings-mode, because their settings vocabularies differ:
   Swap surfaces route type / exchanges / slippage (3 rows); Bridge
   surfaces route priority / exchanges / bridges / slippage (4 rows).
   Only the block matching the active mode shows. Limit and Gas are
   excluded entirely — their axes (price/expiry, native gas) don't share
   either vocabulary. */
.ui-quick-settings { display: none; }
body[data-layout="dual"][data-widget-mode="swap"]   .ui-quick-settings[data-quick-settings-mode="swap"],
body[data-layout="dual"][data-widget-mode="bridge"] .ui-quick-settings[data-quick-settings-mode="bridge"] {
  display: flex;
  flex-direction: column;
  gap: var(--space-8);
  margin-top: var(--space-16);
}

/* Icon column — hidden by default in this surface (June 2026). The rows
   (Swap: route type / exchanges / slippage · Bridge: route priority /
   exchanges / bridges / slippage) read as a tight summary cluster above
   the action CTA; the leading glyphs
   added visual noise without disambiguating value (the labels alone are
   unambiguous in this short list). The icons stay in the DOM so the
   Workshop's "Show icons" switch (Jumper scope) can flip them back via
   `body[data-quick-settings-icons="on"]` without any markup mutation.
   The grid collapses from 3-col to 2-col so the label slot reclaims the
   reserved icon column; opt-in restores the original 3-col grid.

   Scoped to .ui-quick-settings ONLY — other .setting-row--sm consumers
   (rail navigation, catalog renderings) keep their icons. */
.ui-quick-settings .setting-row__icon { display: none; }
.ui-quick-settings .setting-row--sm { grid-template-columns: 1fr auto; }

body[data-quick-settings-icons="on"] .ui-quick-settings .setting-row__icon,
.ui-quick-settings[data-icons="on"] .setting-row__icon {
  display: inline-flex;
}
body[data-quick-settings-icons="on"] .ui-quick-settings .setting-row--sm,
.ui-quick-settings[data-icons="on"] .setting-row--sm {
  grid-template-columns: var(--space-20) 1fr auto;
}

/* ── Workshop "Show icons" toggle card ─────────────────────────────────
   Variant-card variant that hosts an inline .switch instead of the
   canonical chevron toggle. Composes onto .ds-doc-variant-card (root
   surface + grid + hover/active recipe) — only two adjustments here:

   (a) Root is <label> so clicking anywhere on the card toggles the input
       inside via label-input association. The canonical card style sets
       `cursor: pointer` already; the label inherits it.

   (b) Active state is content-driven by the switch — when the input is
       checked, the card paints its accented rim + 4% wash via :has().
       Per CLAUDE.md → "Auto-detect content-driven state via :has()" —
       the switch IS the state, no .is-active flag to sync. */
.ds-doc-variant-card--toggle:has(.switch-input:checked) {
  background: color-mix(in oklch, var(--accent-primary) 4%, var(--surface-card));
  border-color: color-mix(in oklch, var(--accent-primary) 64%, transparent);
  box-shadow: var(--elev-2);
}
/* Marker tone tracks the same state — accent when on, neutral when off,
   mirroring the .ds-doc-variant-card.is-active recipe (line 18642). */
.ds-doc-variant-card--toggle:has(.switch-input:checked) > .marker {
  --marker-tone: var(--accent-primary);
}
/* Center the switch in its grid cell — the canonical chevron-toggle slot
   uses `align-self: start` + margin-top, sized for a circular button. The
   switch (sm) is shorter; vertically center it against the marker tile
   so the right column reads as balanced. */
.ds-doc-variant-card--toggle > .switch {
  align-self: center;
}

/* ── Empty state — no token picked (.ui-price-chart[data-empty]) ─────────
   The canonical .empty-state primitive (catalog: #empty-state, CSS:
   styles.css § EMPTY STATE) fills the whole panel body when no token
   is picked. .screen-header stays visible with full functionality
   (refresh timer, expand-panel, chart-token tabs); .ui-token-info AND
   .ui-price-chart hide entirely; the .empty-state sibling shows and
   stretches via its own `flex: 1 1 auto` — vertically centered in the
   full panel body.

   Why .ui-price-chart hides as a WHOLE container (not just its
   children): .ui-price-chart is `flex: 1` inside [data-limit-receive].
   Hiding only its children (.ui-price-chart__timeframes + .tradingview-
   chart) collapses the visible content but leaves the parent flex item
   itself claiming its flex share — so the sibling .empty-state (also
   flex:1) gets pushed into the bottom half of the panel instead of
   centering across the full height. The whole container has to come
   out of layout for .empty-state to occupy the panel body alone.

   Still triggered by .ui-price-chart[data-empty] (set in tradingview-
   chart.js → syncToToken). Routed through :has() so the sibling
   .ui-token-info and the .empty-state wrapper respond off the same
   attribute — no second state flag.

   (Atomic retirement May 2026 — replaced .ui-price-chart__placeholder
   + .ui-price-chart__baseline + the .ui-price-chart__placeholder .muted
   nested rule with the universal .empty-state primitive. No aliases;
   see CLAUDE.md → Primitive retirement is atomic — no aliases.) */
[data-limit-receive] > .empty-state { display: none; }
[data-limit-receive]:has(> .ui-price-chart[data-empty]) > .empty-state {
  display: flex;
}
[data-limit-receive]:has(> .ui-price-chart[data-empty]) > .ui-token-info,
.ui-price-chart[data-empty] {
  display: none;
}

/* Limit mode is single-chain (see CLAUDE.md → Single-chain constraint in
   design/components/swap.md). When the Buy picker is open, the chain rail
   would collapse to one row — useless chrome that claims a third of the
   widget for an already-locked chip. Hide the entire pane instead; the
   constraint stays visible via the chain badges on every token row in the
   list. Sell picker is unaffected (multi-chain — user still picks). */
.widget-stage:has(.ui-card[data-mode="limit"][data-active-picker="to"]) .split-pane.split-pane--chain-filter {
  display: none;
}

/* Chain-filter pane respects the widget's viewport-cap (May 2026).
   The pane is a sibling of .ui-card inside .widget-stage, not a child, so
   the cap on .ui-card doesn't reach it on its own. Apply the same recipe
   here (cap + overflow:hidden), let the search header stay intrinsic, and
   make the chain-list-module the scroller. The override on .ui-chain-list
   removes its standalone 480 px cap inside this pane so one continuous
   scroll runs through the module, matching the .ui-token-picker__body
   pattern in the form card. Scoped to .widget-stage so other consumers of
   the universal .split-pane primitive aren't affected. */
.widget-stage .split-pane--chain-filter {
  max-height: var(--widget-max-h, none);
  overflow: hidden;
}
.widget-stage .split-pane--chain-filter > .chain-list-module {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  scrollbar-width: thin;
}
.widget-stage .split-pane--chain-filter .ui-chain-list {
  max-height: none;
  overflow-y: visible;
}

/* "All networks" row — first item in the chain sidebar. Adaptive-variant
   sibling of the compact picker's .card-avatar--cluster tile. The row
   itself is a plain .list-item--interactive; this rule sizes the
   2 × 2 mini-avatar cluster inside its lead slot so the cluster fits
   the sm-tier 24-px slot without expanding the list's leftmost
   `auto` column (which would push every chain row's avatar inward).
   Sized at 24 px outer + --space-2 gap → each mini reads at ~11 px,
   small but the cluster shape carries the multichain affordance and
   the "All networks" label disambiguates. The compact shortcut's own
   .card-avatar__cluster rule (in styles.css) stays untouched. */
.ui-chain-list .list-item-all__multi {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-2);
  width:  var(--space-24);
  height: var(--space-24);
}
.ui-chain-list .list-item-all__multi .avatar {
  --avatar-size: calc((var(--space-24) - var(--space-2)) / 2);
  width:  var(--avatar-size);
  height: var(--avatar-size);
}
/* Single-chain ecosystem collapse (Solana, Tron): the ecosystem-aware
   preview yields one mark, so the cluster has a lone avatar. Span it
   across the full 2 × 2 box so it reads as a single chain mark instead
   of a quarter-filled corner dot. Content-driven via :only-child — no
   opt-in flag; fires only when exactly one avatar is present. */
.ui-chain-list .list-item-all__multi .avatar:only-child {
  grid-column: 1 / -1;
  grid-row: 1 / -1;
  --avatar-size: var(--space-24);
}

/* ── Chain-filter panel — the two documented compositions ────────────────
   ONE node, two compositions, switched by picker-render.js →
   applyChainPanelComposition() (driven by body[data-layout]):

     • MINI    — base .split-pane--chain-filter. The Adaptive sidebar:
                 .search--sm, default-tier rows with avatar--sm (32px),
                 NO header. (Everything above this block is the mini base.)
     • REGULAR — .split-pane--chain-filter--regular. The Dual Swap/Bridge
                 receive-column picker: default .search, list-item--lg rows
                 with default avatars (40px), a directional From/To-network
                 header (picker-render.js → syncChainPanelTitle mirrors the
                 active picker side, like the token picker's Send/Receive).
                 Balanced 1:1 with the token picker on the left — same
                 416px width, same 40px header row, same 64px row height,
                 same default search tier.

   The render emits the lg row classes (renderChainList reads the modifier)
   and the JS swaps the search tier; these rules cover the CSS-only deltas
   the modifier owns — header presence + the All-networks cluster size.
   See CLAUDE.md → "Composable slots vs variant modifiers" (a size/chrome
   variant on one primitive) and design/components/swap.md → Layout variants. */
.split-pane--chain-filter > .screen-header { display: none; }
.split-pane--chain-filter--regular > .screen-header {
  display: grid;          /* re-assert the screen-header grid (base is grid) */
  flex: 0 0 auto;         /* hold the 40px row under the panel's max-height cap */
  /* Header→search gap: the .panel column gap is --space-12, but the token
     picker on the left uses the card-screen rhythm — [data-state-content]'s
     --space-16 between its screen-header and body. +--space-4 lifts this
     panel's header→search gap to 16px (12 gap + 4 margin) so the two panels'
     search bars sit on the same row in Dual. Only the regular composition
     shows a header; the mini sidebar hides it, so this never applies there. */
  margin-bottom: var(--space-4);
}
/* All-networks cluster sized to the regular row's 40px default avatar
   (the mini variant's 24px sizing is the unqualified rule above; these
   higher-specificity rules win only inside the regular panel). */
.split-pane--chain-filter--regular .ui-chain-list .list-item-all__multi {
  width: var(--space-40);
  height: var(--space-40);
}
.split-pane--chain-filter--regular .ui-chain-list .list-item-all__multi .avatar {
  --avatar-size: calc((var(--space-40) - var(--space-2)) / 2);
  width: var(--avatar-size);
  height: var(--avatar-size);
}
.split-pane--chain-filter--regular .ui-chain-list .list-item-all__multi .avatar:only-child {
  --avatar-size: var(--space-40);
}

/* Chain filter search alignment — the panel's side padding is reduced
   by --list-hover-bleed (see the "Picker panel side-padding compensation"
   block near the top of this file), placing the search bar's outer
   edges at the panel's content edge. The chain rows inherit
   .list-item--interactive's default --space-16 L/R padding, which pushes
   the avatar one tier further inward to panel-outer + --panel-padding.
   Match the search's outer edges to the avatar's left edge via
   margin-inline of --list-hover-bleed — the same compensation magnitude
   that aligns the token picker's search-to-row layout via __head padding.

   The width override is non-optional: .search carries width: 100% in its
   base rule, so margin alone would shift its position without shrinking
   the box — the right edge would overflow the panel (clipped by the
   panel's overflow: hidden, but layout-wrong). Explicit width matches
   the search's effective container width, accounting for the two
   margins. (Margin, not padding: padding would extend the search box
   outward; margin insets its outer edges so the icon + input sit at
   their original positions inside the now-narrower bar.) */
.widget-stage .split-pane--chain-filter > .search {
  width: calc(100% - 2 * var(--list-hover-bleed));
  margin-inline: var(--list-hover-bleed);
  /* Hold the ladder-tier height. The chain filter panel sits under
     `max-height: var(--widget-max-h)` (line ~3233) so its flex column
     squeezes children when content overflows; the chain-list-module
     sibling takes the slack via flex:1 1 auto. Without flex-shrink:0
     the search collapses to ~19 px when the panel is capped. */
  flex-shrink: 0;
}

/* ── Action footer — consumes the universal .action-bar ───────────────
   The swap form / review / tx-progress footers are .action-bar instances
   (styles.css — search ".action-bar"). The lead-primary + trailing-icon
   shape (Review fills, Wallet icon stays 40 px) is the .action-bar
   `> .btn-icon { flex: 0 0 auto }` auto-detect, no modifier. May 2026:
   absorbed the retired .widget-stage__actions BEM block — see CLAUDE.md →
   Primitive retirement is atomic. The swap form's contextual rhythm (the
   16-px lift above the footer inside .ui-form-stack) lives at the
   .ui-card[data-state="form"] .ui-form-stack > .action-bar rule earlier in
   this file.

   Playground usage — the form footer carries two wallet states, gated on
   body[data-playground-wallet-connected]:
     • disconnected (default) → full-width "Connect wallet" primary
     • connected              → "Review" primary (fills) + "Wallet"
                                 icon-only secondary (40 px)
   The buttons stay static on hover (product UI is static by default since the
   May 2026 inversion; lift is the opt-in .btn-lift) — the right call here
   anyway, since a lift at the very bottom of the card would clip the panel
   padding. Deliberately no .btn-lift. Visibility lives on the
   [data-wallet-action] rules below (global, not scoped to this row), so the
   same toggle works for the header's "Disconnect wallet" icon button and any
   other connected-state-only affordance the playground grows. */

/* ── Playground wallet-state visibility — applies wherever the attribute
   is set. Authoring contract: every wallet-gated control tags itself
   with data-wallet-action="connected" or "disconnected" and the rules
   below toggle visibility from the body attribute. Buttons in the
   playground render as inline-flex (.btn-icon / .btn-primary),
   so the "show" rule below uses inline-flex; if a non-button consumer
   ever joins, set its own display value at the rule site. Toggle from
   the console via window.lifiPlayground.connect() / .disconnect(). */
[data-wallet-action="connected"] { display: none; }
body[data-playground-wallet-connected="true"] [data-wallet-action="disconnected"] { display: none; }
body[data-playground-wallet-connected="true"] [data-wallet-action="connected"] { display: inline-flex; }

/* .ui-wallet-mount promoted to styles.css (June 2026) — shared by the
   playground rail AND the Portal footer (2nd product consumer). See
   styles.css → "8q0. WALLET MOUNT". */

/* ══════════════════════════════════════════════════════════════════════
   PLAYGROUND RAIL — multi-screen / drill-down primitives (Apr 2026 v8)
   ──────────────────────────────────────────────────────────────────────
   The rail is a single-pane, multi-screen container. The ROOT screen
   shows the logo+seg header plus three sections (Layout / Theme /
   Developer). Each setting row carries `data-drill="<screen-id>"`;
   clicking it sets `data-screen="<screen-id>"` on the rail, which
   reveals the matching sub-panel screen and hides every other screen
   (incl. root). Sub-panels render their own header — back button +
   title + optional caption — so the rail's *vertical* footprint stays
   stable as you drill in and out.

   Why one rail (vs. a stack of separate panes that slide in): keeps
   the floating-glass footprint identical across screens, avoids
   layout reflow when navigating, and means the icon-rail collapse
   (body.rail-collapsed) keeps working on every screen.

   Components introduced here (all `.playground-rail__*`):
     .playground-rail__screen           — single view (root or sub-panel)
     .playground-rail__section          — group of rows under an eyebrow
     .playground-rail__section-title    — eyebrow caption above a section
     .playground-rail__caption          — descriptive paragraph in a sub-panel
     .playground-rail__color-pad-grid   — 3-up grid of color pads
     .playground-rail__color-pad        — large color swatch + role label
     .playground-rail__stub-note        — "coming soon" placeholder text

   Universal primitives the rail composes (see styles.css + catalog):
     .option-card / .option-card-grid   — horizontal picker tiles
     .drawer-handle                     — drawer-pull tab (May 2026 v15)

   Plus one modifier on the existing `.setting-row`:
     .setting-row--drill                — adds the trailing chevron
     .setting-row--toggle               — row with a switch instead of value
   ══════════════════════════════════════════════════════════════════════ */

/* The View-rail presentation owns the layout choice (Exchange→Adaptive,
   Trade→Dual), so the standalone Layout drill row is redundant there —
   hide it. Single / Tabs keep the free Layout pick. data-presentation is
   set by swap.js → initRailPresentationPanel (Phase 2a). */
body[data-presentation="view-rail"] [data-drill="layout-variant"] { display: none; }

/* View-rail tier tabs — exactly three per tier. swap.js → initViewRail's
   syncTierSeg writes data-mode + label into all three slots per tier
   (Advanced → Swap·Bridge·Limit; Simple → Bridge·Private·Gas), so markup
   order IS display order — no CSS `order` hack. (The old Swap-first
   `order:-1` was retired June 2026 when the fixed pair became tier-dependent
   — Simple now leads with Bridge, Advanced with Swap; a tier's first mode
   simply occupies the first slot.) View-rail is the only presentation that
   shows the seg (Single hides it); the overflow chevron was retired June
   2026 — the .seg-menu wrapper holds just the .seg-item--menu label, which
   rides as each tier's third tab.

   Companion rule on the label half: .seg-item--menu carries an asymmetric
   `padding-right: 12px` (styles.css → `.seg-menu .seg-item--menu`) sized to
   leave trailing room for a chevron sibling — correct in the catalog
   #seg-menu primitive (popover backend, chevron present), but with no
   chevron here the third tab must read as a regular .seg-item. Restore the
   right padding to the base seg-item value so the pill is symmetric and
   matches its peers — otherwise Limit/Gas renders ~8px narrower than
   Swap/Bridge with the label off-centered toward the right edge. */
body[data-presentation="view-rail"] .screen-header--main [data-flow-seg] .seg-menu .seg-item--menu { padding-right: var(--space-20); }

/* Screen visibility ─────────────────────────────────────────────────── */
/* ADDING A NEW SUB-SCREEN? You MUST add its `data-screen-id` to the
   selector list below — CSS can't match data-screen=X to data-screen-id=X
   dynamically, so each screen is registered explicitly. Skip this and the
   drill opens to a BLANK panel (the screen stays display:none even though
   the drill set data-screen correctly). The JS drill routing is generic
   (data-screen = data-drill), so the JS won't warn — only this list gates
   visibility. */
.playground-rail__screen { display: none; }
.playground-rail[data-screen="root"]                  .playground-rail__screen[data-screen-id="root"],
.playground-rail[data-screen="layout-mode"]           .playground-rail__screen[data-screen-id="layout-mode"],
.playground-rail[data-screen="layout-variant"]        .playground-rail__screen[data-screen-id="layout-variant"],
.playground-rail[data-screen="layout-height"]         .playground-rail__screen[data-screen-id="layout-height"],
.playground-rail[data-screen="nav-preview"]           .playground-rail__screen[data-screen-id="nav-preview"],
.playground-rail[data-screen="theme-appearance"]      .playground-rail__screen[data-screen-id="theme-appearance"],
.playground-rail[data-screen="theme-edit"]            .playground-rail__screen[data-screen-id="theme-edit"],
.playground-rail[data-screen="theme-typography"]      .playground-rail__screen[data-screen-id="theme-typography"],
.playground-rail[data-screen="settings"]              .playground-rail__screen[data-screen-id="settings"],
.playground-rail[data-screen="widget-type"]           .playground-rail__screen[data-screen-id="widget-type"],
.playground-rail[data-screen="checkout-host"]         .playground-rail__screen[data-screen-id="checkout-host"],
.playground-rail[data-screen="checkout-merchant"]     .playground-rail__screen[data-screen-id="checkout-merchant"],
.playground-rail[data-screen="checkout-charge"]       .playground-rail__screen[data-screen-id="checkout-charge"] {
  display: flex;
  flex-direction: column;
  gap: var(--space-12, 12px);
  /* Apr 2026 v12 — active screen takes the remaining vertical space
     between the rail's header padding and the floating footer; its
     own content scrolls inside. `min-height: 0` is the magic flex
     incantation that lets a flex child with overflow shrink below
     its intrinsic content size (without it the screen would push
     the footer off the bottom on tall content). */
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  /* The pin button is absolute-positioned on top-right of the rail
     (z-index above the screen content). Sub-panels' .__sub-head clears
     the pin's footprint with padding-right; the root screen's
     .__head already clears it via padding-right: 36px. */
}

/* Section group ─────────────────────────────────────────────────────── */
/* Card-gap ladder (per CLAUDE.md → "Card gap ladder"): rail sections
   stack .setting-row card-shaped siblings, so the gap reaches for the
   --gap-card-* family, not raw --space-*. --gap-card-sm (8 px) is the
   tier for compact rail lists — same precedent as
   .playground-rail__theme-cards directly below. Publishing the local
   --card-gap lets per-instance overrides happen via inline style or a
   parent selector without touching this rule. */
.playground-rail__section {
  --card-gap: var(--gap-card-sm);
  display: flex;
  flex-direction: column;
  gap: var(--card-gap);
}
.playground-rail__section + .playground-rail__section {
  /* Slightly larger gap between sections than between rows in a section,
     so the eyebrow has room to breathe. The eyebrow's mono caption
     carries enough visual weight to delineate sections without a rule
     line — keep margin only, no border. */
  margin-top: var(--space-8, 8px);
  padding-top: var(--space-8, 8px);
}
.playground-rail__section-title {
  font-family: var(--font-mono, 'Geist Mono', monospace);
  font-size: var(--text-caption);
  font-weight: var(--fw-bold);
  letter-spacing: var(--tracking-wider);
  text-transform: uppercase;
  color: color-mix(in oklch, var(--text-primary) 38%, transparent);
  padding: var(--space-8) var(--space-12) var(--space-4);
}

/* Apr 2026 v17 — `.playground-rail__sub-head` + `.playground-rail__back`
   + `.playground-rail__sub-title` removed. The sub-panel header
   composes the universal `.screen-header` primitive (defined in
   styles.css → .screen-header section).

   May 2026 v18 — `.panel-head` retired entirely in favour of the
   canonical `.screen-header` + size modifier ladder. Mode / Variant /
   Height rail sub-screens use the default tier (40 px chrome, 20 px
   title); Edit-theme uses the `--compact` tier (32 px chrome, 16 px
   title, btn-sm circular back). FAB sub-panels + theme composer
   also migrated to `--compact` + `--sticky-glass` in the same pass. */

/* Sub-panel caption — short descriptive paragraph below the header.
   Single-line policy doesn't apply here (this is body copy, not a menu
   row description) but should still stay terse — 2 lines max. */
.playground-rail__caption {
  /* May 2026 — bumped --text-caption (12 px) → --text-sm (14 px) so the
     sub-view caption reads as body copy, not a footnote. Used in every
     rail sub-screen (Mode / Variant / Height / Corners / Theme-edit /
     Theme-typography); component-level change applies uniformly. */
  font-size: var(--text-sm);
  line-height: 1.5;
  color: var(--text-muted);
  margin: 0 var(--space-4, 4px) var(--space-8, 8px);
}

/* Compact row variant (Apr 2026 v15) — tightens padding + drops the
   icon size so the row visually matches the mini theme cards stacked
   below it in the rail's Theme section. Used for "Manage themes" as
   the entry-point first item. */
.setting-row--compact {
  padding: var(--space-8) var(--space-12);
}
.setting-row--compact .setting-row__icon { width: 20px; height: 20px; }
.setting-row--compact .setting-row__icon svg { width: 16px; height: 16px; }
.setting-row--compact .setting-row__label { font-size: var(--text-sm, 14px); }

/* Toggle row variant — replaces value+chevron with a .switch.
   Switches to a 2-column flex layout (no icon) so technical event
   labels (e.g. "onTransactionCompleted") have the full row width
   before the switch. Labels stay single-line; very long names
   truncate with ellipsis so the row height never reflows on
   narrower viewports. */
.setting-row--toggle {
  display: flex;
  align-items: center;
  gap: var(--space-8, 8px);
  cursor: pointer;
}
.setting-row--toggle .setting-row__label {
  flex: 1;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: var(--text-sm, 14px);
}
.setting-row--toggle .switch { margin-left: auto; flex-shrink: 0; }

/* Height drill — inline numeric input row revealed under the preset
   grid when "Custom" is active. Uses the universal `.input` + `.btn-*`
   primitives; this rule is layout glue only (eyebrow + row composition).
   May 2026. */
.playground-rail__height-custom {
  display: flex;
  flex-direction: column;
  gap: var(--space-8);
  margin-top: var(--space-4);
}
/* Restore the user-agent's [hidden] default — our display:flex above wins
   over the UA rule by specificity, so opt back in to "hidden = invisible". */
.playground-rail__height-custom[hidden] { display: none; }
.playground-rail__height-custom-label {
  font-size: var(--text-caption);
  font-weight: var(--fw-medium);
  color: var(--text-secondary);
  letter-spacing: var(--tracking-loose);
  text-transform: uppercase;
}
.playground-rail__height-custom-row {
  display: flex;
  gap: var(--space-8);
  align-items: center;
}
.playground-rail__height-custom-row > .input { flex: 1 1 auto; min-width: 0; }
.playground-rail__height-custom-row > .btn-primary { flex: 0 0 auto; }

/* .playground-rail__chrome-list retired June 2026 — the rail's "Chrome"
   dropdown row was absorbed into the 3-state apply-scope seg
   (.theme-scope-seg, styles.css) above the theme cards. See CLAUDE.md →
   "Theme Composer scope". */

/* Theme-card stack — used in the rail's Theme section root (Apr 2026 v9).
   Reuses the existing `.theme-card--sample` primitive from the global
   theme composer (defined in styles.css around line 10190); we add a
   `--rail` modifier that switches the stack from a 3-column grid (the
   FAB's default) to a 1-column vertical stack so each tile fills the
   rail's column. Card mockup + footer + edit pencil all inherit from
   the canonical primitive — only layout differs.

   Each card is rendered by swap.js → initRailThemeCards() from the
   composer's saved presets list (localStorage `lifi-theme-presets`).
   Click the card body to apply the theme; click the pencil to drill
   into the per-theme editor. */
.playground-rail__theme-cards {
  --card-gap: var(--gap-card-sm);
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--card-gap);
  /* The cards have their own border + shadow (from .theme-card), so
     drop the row-style padding the rail's other sections use. */
}
.playground-rail__theme-cards-empty {
  padding: var(--space-12, 12px);
  font-size: var(--text-caption, 12px);
  color: var(--text-muted);
  text-align: center;
  border: 1px dashed color-mix(in oklch, var(--text-primary) 18%, transparent);
  border-radius: var(--space-12);
}

/* Apr 2026 v17 — `.theme-card--mini` modifier removed. The rail now
   renders the canonical `.theme-card.theme-card--row` directly via
   swap.js → initRailThemeCards(); no rail-specific overrides exist or
   are needed. Keep this comment as a breadcrumb so a future author
   doesn't reintroduce a one-off variant for the rail context. */

/* Per-theme editor — comprehensive sub-panel (Apr 2026 v9).
   Replaces the v8 color-pad grid. The editor is structured as a stack
   of subsections (Corners + Color palette) plus an action row. Section
   eyebrows + captions + inter-section dividers were retired May 2026 —
   the sections now flow with the screen's gap alone.

   Components introduced:
     .playground-rail__editor-section   — subsection container
     .playground-rail__editor-row       — label + swatch + hex line
     .playground-rail__editor-swatch    — color swatch on the row
     .playground-rail__editor-label     — text label (left)
     .playground-rail__editor-hex       — hex value (right, mono)
     .playground-rail__editor-value     — non-hex value (right, mono)
     .playground-rail__editor-slider    — slider with head + range
     .playground-rail__editor-actions   — bottom action button stack
*/
.playground-rail__editor-section {
  display: flex;
  flex-direction: column;
  gap: var(--space-8, 8px);
}
/* Whitespace between sections (Name/Desc · Corners · Color palette) — the
   sections separate on breathing room alone now that the eyebrows +
   dividers are gone. Adds to the screen's own 12px flex gap. */
.playground-rail__editor-section + .playground-rail__editor-section {
  margin-top: var(--space-16, 16px);
}
.playground-rail__editor-row {
  display: grid;
  grid-template-columns: 28px 1fr;
  align-items: center;
  gap: var(--space-12, 12px);
  padding: var(--space-8, 8px) var(--space-12, 12px);
  background: color-mix(in oklch, var(--text-primary) 3%, transparent);
  border: 1px solid color-mix(in oklch, var(--text-primary) 8%, transparent);
  border-radius: var(--space-12);
  font-family: inherit;
  text-align: left;
  cursor: pointer;
  transition: border-color 0.15s, background 0.15s;
}
.playground-rail__editor-row:hover {
  border-color: color-mix(in oklch, var(--text-primary) 18%, transparent);
}
.playground-rail__editor-swatch {
  width: 28px;
  height: 28px;
  border-radius: 8px;
  box-shadow: inset 0 0 0 1px color-mix(in oklch, currentColor 12%, transparent);
}
.playground-rail__editor-label {
  font-size: var(--text-sm, 14px);
  font-weight: var(--fw-semibold);
  color: var(--text-primary);
}
.playground-rail__editor-hex,
.playground-rail__editor-value {
  font-family: var(--font-mono, 'Geist Mono', monospace);
  font-size: var(--text-caption, 12px);
  font-weight: var(--fw-medium);
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}
/* .playground-rail__editor-slider + -slider-head retired June 2026 (Theme
   Composer merge) — the per-axis corner sliders moved into the shared
   LifiThemeEditor (now the universal `.slider-field--compact` units after
   the Edit-view redesign), so they render in the FAB popover too. */
.playground-rail__editor-actions {
  display: flex;
  flex-direction: column;
  gap: var(--space-8, 8px);
  margin-top: var(--space-12, 12px);
  padding-top: var(--space-12, 12px);
  border-top: 1px solid color-mix(in oklch, currentColor 8%, transparent);
}

/* Corners glue (.theme-editor-drawer__body + .corners-pill-row) moved to
   styles.css June 2026 (Theme Composer merge) so the editor's Corners
   section styles identically in the FAB popover + the rail — see
   styles.css → "THEME EDITOR — editor-local layout glue". */

/* Color pad — first-pass theme composer integration ──────────────────
   Three-up grid of large swatches (Primary / Secondary / Tertiary)
   reflecting the active palette. Phase 2 will replace this with inline
   OKLCH editors; for now clicking a pad opens the global Palette
   Composer FAB. The layout is set up so the inline editor can drop in
   as a `[hidden]` panel below each pad without restructuring. */
.playground-rail__color-pad-grid {
  --card-gap: var(--gap-card-sm);
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: var(--card-gap);
}
.playground-rail__color-pad {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--space-8, 8px);
  padding: var(--space-8, 8px);
  border: 1px solid color-mix(in oklch, var(--text-primary) 12%, transparent);
  border-radius: 12px;
  background: color-mix(in oklch, var(--text-primary) 3%, transparent);
  font-family: inherit;
  cursor: pointer;
  text-align: left;
  transition: border-color 0.15s, background 0.15s;
}
.playground-rail__color-pad:hover {
  border-color: color-mix(in oklch, var(--text-primary) 22%, transparent);
}
.playground-rail__color-pad-swatch {
  display: block;
  height: 48px;
  border-radius: 8px;
  /* Inset shadow gives the swatch internal depth so it reads as a
     coloured surface, not a flat tile. */
  box-shadow: inset 0 0 0 1px color-mix(in oklch, currentColor 10%, transparent);
}
.playground-rail__color-pad-label {
  font-size: var(--text-caption, 12px);
  font-weight: var(--fw-semibold);
  color: var(--text-primary);
  text-align: center;
  letter-spacing: var(--tracking-normal);
}

/* Stub placeholder — used for sub-panels that aren't built out yet. */
.playground-rail__screen--stub .playground-rail__stub-note {
  margin: var(--space-12, 12px) var(--space-4, 4px);
  padding: var(--space-16, 16px);
  border: 1px dashed color-mix(in oklch, var(--text-primary) 18%, transparent);
  border-radius: var(--space-12);
  font-size: var(--text-caption, 12px);
  color: var(--text-muted);
  text-align: center;
}


/* ── .ui-limit-price-card — limit-mode price slot ──────────────────────
   Sibling primitive of .ui-amount-card. Sits above the .ui-amount-pair
   inside the same .ui-card panel and is part of the same card family
   visually — surface tokens, radius, padding, internal gap, label
   typography, and amount typography all default to the same values as
   .ui-amount-card so the limit price card reads as another sub-card,
   not a different surface.

   Card-family contract (May 2026):
     surface  → --surface-card        (matches .ui-amount-card)
     shadow   → --elev-1              (matches .ui-amount-card)
     radius   → 16 px                 (matches .ui-amount-card)
     padding  → --space-16 / --space-20  (matches .ui-amount-card)
     gap      → --space-12            (matches --amount-card-gap)
     label    → 14 px / 600 / primary (matches .ui-amount-card__label)
     amount   → 36 px / 700 / -0.02em (matches .ui-amount-card__amount)

   When a property changes on .ui-amount-card, this primitive's slot
   default should be updated in the same edit (and vice versa) so the
   two stay in lockstep.

   Layout shape differs from .ui-amount-card — this card has four rows
   (lead label + invert chip / amount input / preset chips / proximity),
   the amount card has three (header / body / footer) — so a modifier-of-
   amount-card approach was deliberately rejected; this is a sibling, not
   a variant.

   The lead row hosts the label ("When BASE is worth") on the left and
   the invert chip ("[QUOTE-symbol] ⇄") on the right — baseline-aligned
   so the chip text sits on the same baseline as the lead label. The
   main row carries the price value input alone (May 2026 v3 — the invert
   button moved back in-flow into the lead row as a labeled .chip,
   replacing the v2 corner-anchored icon-only button. The chip combines
   the QUOTE token symbol display + the invert action in one element).

   Label symbols are read-only mirrors of the Buy / Sell tokens picked
   via the amount-cards below — they reflect, they don't reselect.
   Token sync is handled by ui-limit-price-card.js; on token change
   the MutationObservers in swap.js call renderAll(). The chip's
   [data-limit-ref="main-symbol"] inner span paints the QUOTE symbol.

   Surface recipe matches .ui-amount-card so the limit-price-card and
   the pair below read as one card family — same --surface-card +
   --elev-1, same 16 px radius, same 16 / 20 padding.

   Mode gate: hidden by default; revealed via
   .ui-card[data-mode="limit"] .ui-limit-price-card. The cascade keeps
   the markup live in the DOM (no JS toggle for visibility).

   Catalog: design-system/index.html#ui-limit-price-card (Phase 7).
   ----------------------------------------------------------------------- */
.ui-limit-price-card {
  /* Card-family slot defaults — every value matches the equivalent
     --amount-card-* slot so the limit price card and the .ui-amount-pair
     below render as one card family. Per-instance overrides still
     supported via inline style on this primitive's slots. */
  --limit-price-card-bg:              var(--surface-card);
  /* Slot default reads from the system-wide --card-radius (16 px default;
     overridden via the rail's "Card radius" control). May 2026 sweep. */
  --limit-price-card-radius:          var(--card-radius);
  --limit-price-card-padding-y:       var(--space-16);
  /* May 2026 — padding-x dropped 20 → 16 (one tier on the ladder). */
  --limit-price-card-padding-x:       var(--space-16);
  --limit-price-card-gap:             var(--space-12);  /* matches --amount-card-gap */
  --limit-price-card-shadow:          var(--elev-1);

  /* Lead label slots default to the shared --ui-card-label-* single source
     (see WIDGET CARD-TITLE LABEL) so the limit label matches "Send"/"Receive". */
  --limit-price-card-lead-size:       var(--ui-card-label-size);
  --limit-price-card-lead-weight:     var(--ui-card-label-weight);
  --limit-price-card-lead-color:      var(--ui-card-label-color);

  --limit-price-card-amount-size:     var(--text-h2-lg);    /* 36px — value-display tier; shared with .ui-amount-card + .ui-token-info__price */
  --limit-price-card-amount-weight:   var(--fw-bold);        /* 700 — cluster-wide weight; see .ui-amount-card's --amount-card-amount-weight for rationale. Per-instance override via inline style="--limit-price-card-amount-weight: var(--fw-semibold)". */
  --limit-price-card-amount-tracking: var(--tracking-firm);  /* -0.02em — value-display tier, paired with --text-h2-lg */
  --limit-price-card-amount-lh:       1.1;                   /* unitless ratio — display-tier exception (no token for this pair) */
  --limit-price-card-amount-color:    var(--text-primary);

  background: var(--limit-price-card-bg);
  border-radius: var(--limit-price-card-radius);
  padding: var(--limit-price-card-padding-y) var(--limit-price-card-padding-x);
  box-shadow: var(--limit-price-card-shadow);
  display: none;
  flex-direction: column;
  gap: var(--limit-price-card-gap);
}

/* Mode gate — only render the price card when the panel is in Limit mode. */
.ui-card[data-mode="limit"] .ui-limit-price-card {
  display: flex;
}

/* Catalog preview escape hatch — design-system/index.html#ui-limit-price-card
   shows the primitive standalone in a .ds-doc-viewport-pane, without the
   .ui-card[data-mode="limit"] widget shell. Scoped to the Preview pane so it
   doesn't leak into Markup or other panes (which carry code blocks, not the
   live primitive). */
.ds-doc-viewport-pane[data-pane="preview"] .ui-limit-price-card {
  display: flex;
}

/* ── Modify-order modal — modify-order.js → window.LifiModifyOrder ──────────
   The modal hosts a prefilled, constrained Limit form (pair locked; price /
   amount / expiry editable). The inner .ui-modify-card is a bare .ui-card used
   only to scope LifiLimitPriceCard.findReferenceTokens — it carries no .panel,
   so it has no surface of its own; the .modal.panel is the only surface. The
   price card + amount cards bring their OWN card surfaces, exactly as they do
   inside the live form panel. */
.ui-modify-card {
  display: flex;            /* .ui-card sets `gap` but no display — assert it. */
  flex-direction: column;
  gap: var(--space-12);
  max-width: none;          /* fill the modal-body (base .ui-card caps at --w-7) */
  max-height: none;         /* never inherit the body's viewport-cap inside a modal */
  /* The base .ui-card sets overflow:hidden for the compose widget's
     viewport-cap scroll (irrelevant in a modal) — it clips the child cards'
     elev-1 bottom shadow at the container edge. Restore visible so the
     stacked cards cast their shadows. */
  overflow: visible;
}
/* Shadow gutter — the shared .modal-body is overflow:auto (which clips paint
   on BOTH axes, even when not scrolling), so the flush card stack would lose
   its --elev-1 shadow at the body's box edge. A NET-ZERO inline gutter
   (padding + matching negative margin, the portal-wizard recipe — portal.css
   → .iwiz .modal-body) gives the shadows room inside the clip box while the
   content stays flush with the modal chrome (title + action bar). Vertical
   stays bare: the body's box edges only meet flat rows (expiry / receipts),
   and the elev-1 shadows are offset downward into the stack. */
#modifyOrderModal .modal-body {
  padding: 0 var(--space-8);
  margin: 0 calc(-1 * var(--space-8));
}
/* Show the price card here — base rule is display:none outside
   .ui-card[data-mode="limit"]; same escape-hatch shape as the catalog preview. */
#modifyOrderModal .ui-limit-price-card { display: flex; }
/* Stack the locked Sell / Buy cards — small card-gap tier, like .ui-amount-pair
   (no FAB-seam mask: modifying can't flip direction, so there's no arrow). */
.ui-modify-pair {
  display: flex;
  flex-direction: column;
  gap: var(--gap-card-sm);
}
/* Locked token chips use the universal .chip-avatar--locked modifier (styles.css)
   — transparent fill + hairline outline + muted label + pointer-events:none. They
   show the order's pair for context and feed findReferenceTokens, but never open
   the picker (they're <span>s, and swap.js init skips the [data-standalone] card).
   .ui-modify-chip retired June 2026 → .chip-avatar--locked (no aliases, per
   CLAUDE.md → Primitive retirement is atomic). */

/* "37% filled · editing remaining" header note — explains why the Sell field
   prefills the REMAINING amount, not the original (modify mode, partials only).
   NOT .muted: that's the faintest tier (footnote-you-can-ignore), but this line
   is load-bearing — without it the smaller prefilled number reads as wrong. So
   it rides the SAME size as the .ui-amount-card__label beside it (--ui-card-label-*,
   14px / 20px line-box) but stays --fw-regular — size carries the prominence, not
   weight. The percentage emphasis is INK only (--text-primary vs the clause's
   --text-secondary), per "state shifts ink, not weight" — never bold. Inline-flex
   so the trailing info-tooltip trigger sits tight to the right of the text;
   [hidden] re-asserted because an author `display` beats UA `[hidden]`. */
.ui-modify-filled {
  display: inline-flex;
  align-items: center;
  gap: var(--space-4);
  font-size: var(--ui-card-label-size);
  font-weight: var(--fw-regular);
  line-height: var(--ui-card-label-lh);
  color: var(--text-secondary);
}
.ui-modify-filled[hidden] { display: none; }
.ui-modify-filled__pct {
  font-weight: var(--fw-regular);
  color: var(--text-primary);
}

/* Stepper row — .steps--circles-inline above the modal title (the portal-
   wizard composition; the .steps primitive is universal in styles.css, this
   is just the modal-top glue). Mirrors portal.css → .iwiz__top — promotion
   candidate to styles.css when a 3rd modal-stepper consumer appears. */
.ui-modify-steps {
  display: flex;
  align-items: center;
  margin-top: var(--space-8);
}
.ui-modify-steps .steps { flex: 1 1 auto; min-width: 0; }

/* Step panels — each step's modal-body content is one <section>, toggled via
   [hidden] by modify-order.js → renderStep(). The flex stack mirrors
   .modal-body's own rhythm so the edit step keeps its prior spacing exactly;
   [hidden] re-asserted because the authored display beats the UA attribute. */
.ui-modify-panel {
  display: flex;
  flex-direction: column;
  gap: var(--cluster-sm);
}
.ui-modify-panel[hidden] { display: none; }

/* Signing-state footer button — spinner + label gap (mirrors portal.css →
   .wfee__signing-btn; the spinner is a <span>, so [class*="btn-"] > svg
   hiding doesn't apply). */
.ui-modify-signing-btn .spinner { margin-right: var(--space-8); }

.ui-limit-price-card__lead {
  display: flex;
  /* Baseline alignment so the in-flow invert chip's text sits on the
     same baseline as the lead label. The chip is 24 px tall (matches
     the row's min-height); align-items: center would visually work
     too, but baseline reads as more deliberate when both children
     carry text. */
  align-items: baseline;
  gap: var(--space-8);
  font-size: var(--limit-price-card-lead-size);
  font-weight: var(--limit-price-card-lead-weight);
  color: var(--limit-price-card-lead-color);
  letter-spacing: var(--ui-card-label-tracking);
  line-height: var(--ui-card-label-lh);
  min-height: var(--space-24);
}

/* Label span — owns the "BASE price in QUOTE" copy. Single line, no wrap.
   Typography is inherited from .ui-limit-price-card__lead (matches
   .ui-amount-card__label: 14 px / 600 / primary). The inner symbol spans
   carry [data-limit-ref="lead-symbol" | "main-symbol"] for ui-limit-price-card.js
   to repaint on token + denom changes. */
.ui-limit-price-card__label {
  white-space: nowrap;
  min-width: 0;
}

/* Invert chip anchored to the far right of the lead row (May 2026 v3 —
   moved back in-flow from v2's corner-anchored absolute icon button into
   the lead row as a labeled .chip; combines QUOTE-symbol display with
   the invert action in one element). Composes the default `.chip` tier
   (24 px tall, 12 px text, 4 px gap) at the .chip-neutral tone — sits
   in the lead row at the same min-height as the row's 24 px floor, and
   baseline-aligns with the 14 px label text via the parent's
   align-items: baseline. margin-left: auto pushes it to the row's right
   edge without grasping for grid / justify-content. The chip's text is
   [data-limit-ref="main-symbol"] (QUOTE token), painted by
   ui-limit-price-card.js on token + denom changes. The trailing
   .chip-icon (12 × 12 invert arrows) signals the action affordance. */
.ui-limit-price-card__invert {
  margin-left: auto;
}

.ui-limit-price-card__main {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-12);
  min-height: var(--space-40);
}

.ui-limit-price-card__amount {
  flex: 1;
  min-width: 0;
  border: 0;
  background: transparent;
  font-family: inherit;
  font-size: var(--limit-price-card-amount-size);
  font-weight: var(--limit-price-card-amount-weight);
  font-variant-numeric: tabular-nums;
  letter-spacing: var(--limit-price-card-amount-tracking);
  line-height: var(--limit-price-card-amount-lh);
  color: var(--limit-price-card-amount-color);
  outline: none;
  padding: 0;
  width: 100%;
}

.ui-limit-price-card__amount::placeholder {
  color: var(--text-tertiary);
}

/* Percent-preset chip row. Four chips: Market / +1% / +5% (one-shot
   pickers) + Custom (chip-edit proximity readout / control). Gap matches
   the .chip-shortcuts row's --space-4 rhythm so the two chip clusters
   read at the same density. */
.ui-limit-price-card__chips {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-4);
  margin-top: var(--space-4);
}

/* Active-state visual — mirrors the canonical .chip-accent1 recipe
   (accent-tinted background fill, accent ink, no border emphasis).
   Same treatment that the catalog labels as the chip's "Active" tone
   at #chips, applied here when paintActiveChip() flags whichever
   chip matches the current displayed percent.

   Specificity (0,3,0). Wins over the [data-theme="light"]
   .chip-neutral:not(.chip--outline) tone override (0,3,0) by source
   order — swap.css loads after styles.css.

   Migration history (May 2026 v4 → v5): v4 retired chip-edit on the
   Custom chip + the collapse-to-active behaviour to fix the visual
   noise complaint, but lost the inline % input that let users type
   a custom offset without leaving the chip. v5 restores chip-edit
   with two scoped overrides (locked input width + suppressed focus-
   within deeper-tint) so the editing affordance is back but the chip
   doesn't resize or change tone on click — visually it stays an
   equal peer of its preset siblings. */
.ui-limit-price-card__chips > .chip.is-active {
  background: color-mix(in oklch, var(--accent-primary) 12%, transparent);
  color: var(--accent-primary);
}

[data-theme="light"] .ui-limit-price-card__chips > .chip.is-active {
  background: color-mix(in oklch, var(--accent-primary) 10%, transparent);
}

/* ── Custom chip — scoped .chip-edit overrides ──────────────────────────
   The Custom chip composes the universal .chip-edit primitive for its
   click-to-input affordance, but we don't want the standard chip-edit
   visual cues (deeper-tint bg on focus-within, input that grows with
   typed content) — those make the chip visually distinct from its
   preset siblings, which is exactly the complaint v4 tried to fix.

   Two overrides:
     1. Lock the input width to a fixed 5 ch (same visual width as the
        "Custom" label at chip font-size). The universal primitive
        defaults to `field-sizing: content`, which grows the input
        with typed content; that growth is what made the chip "expand
        bigger" on click. `field-sizing: fixed` (the form-control
        default) holds the width steady.
     2. Suppress the focus-within deeper-tint bg. The universal
        primitive's rule `(0,3,1) specificity` applies a
        `color-mix(currentColor 22%, transparent)` fill while the input
        is focused; this scope's rule wins on `(0,4,1)` and reverts the
        bg back to whatever the chip's tone class is rendering (chip-
        neutral at rest, accent fill when .is-active).
   The remaining edit-mode cues — text content change ("Custom" →
   "+N.N %"), blinking cursor, the chip's accent fill when .is-active
   is on — carry the signal that the user is editing. */
.ui-limit-price-card__chips > .chip-edit .chip-edit__input {
  field-sizing: fixed;
  width: 5ch;
}

.ui-limit-price-card__chips > .chip.chip-edit:focus-within[data-chip-edit] {
  background: color-mix(in oklch, var(--text-primary) 8%, transparent);
}

[data-theme="light"] .ui-limit-price-card__chips > .chip.chip-edit:focus-within[data-chip-edit] {
  background: color-mix(in oklch, var(--text-primary) 6%, transparent);
}

.ui-limit-price-card__chips > .chip.chip-edit.is-active:focus-within[data-chip-edit] {
  background: color-mix(in oklch, var(--accent-primary) 12%, transparent);
}

[data-theme="light"] .ui-limit-price-card__chips > .chip.chip-edit.is-active:focus-within[data-chip-edit] {
  background: color-mix(in oklch, var(--accent-primary) 10%, transparent);
}

/* Custom chip — composes .chip-edit for click-to-input affordance,
   with scoped overrides (see Active-state block above) that keep its
   silhouette identical to its preset siblings at rest AND in edit mode.
   paintActiveChip() flags it with .is-active when the displayed
   percent falls outside every preset band.

   Migration history:
       v1 — .ui-limit-price-card__proximity scope class + inline tone-
            tier rebuilds (chip-success / -neutral / -warn / -danger).
       v2 — composed .chip-edit (toggle to inline % input).
       v3 — retired tone-tier rebuilds; .is-active accent fill only.
       v4 — retired chip-edit + collapse-to-active behaviour; Custom
            became a plain .chip-neutral focusing the price input on
            click. Lost the inline % input affordance.
       v5 — restored chip-edit with scoped width-lock + focus-within
            bg-suppression overrides; chip stays visually equal to its
            preset siblings AND keeps the inline % input affordance.
       v6 — added label-sync hook (syncCustomChipLabel in JS) so the
            chip's resting label reflects the committed value
            (e.g., "+7.5%") instead of reverting to the static
            "Custom" word after Enter / blur. Re-clicking re-opens
            the input pre-filled with that value.

   The [data-limit-proximity] attribute is the JS hook; the
   .ui-limit-price-card__proximity scope class is gone from CSS, JS,
   and markup. */


/* ── .ui-limit-price-card--zero — zero / no-selection state ────────────
   Sibling of .ui-amount-card--zero (swap.css → "State modifier — zero
   state"). Applied by ui-limit-price-card.js → renderZeroState() when
   EITHER referenced token (Sell / Buy, mirrored from the amount cards
   below) isn't picked yet — the cold-load / no-selection state. The card
   stays in the DOM to give context of what it'll hold once tokens are
   picked; everything with no honest value at zero is dimmed (the price
   value) or hidden (the invert chip + preset chips, both of which
   reference a market rate that needs tokens).

   Mirrors the amount-card philosophy:
   - Price value → --text-faint (same ink as the .muted primitive).
     Weight / size / tracking / line-height stay CONSTANT per the
     "state changes shift ink, not weight" rule — the slot's identity IS
     the weight, color is the state signal.
   - Static label ("Limit price") stays at full --text-primary ink,
     exactly as "Sell" / "Buy" do on the amount cards. The label swap is
     CSS-driven via two sibling spans (--dynamic / --zero) so no JS has
     to rebuild the "When BASE is worth" copy.
   - Invert chip + preset chips hidden — no QUOTE symbol to display and
     no market rate to offset from, so both would be dishonest
     affordances (same reason the amount card hides its balance row +
     MAX shortcuts at zero). */
.ui-limit-price-card--zero {
  --limit-price-card-amount-color: var(--text-faint);
}
.ui-limit-price-card--zero .ui-limit-price-card__amount::placeholder {
  color: var(--text-faint);
}
.ui-limit-price-card--zero .ui-limit-price-card__invert,
.ui-limit-price-card--zero .ui-limit-price-card__chips {
  display: none;
}

/* Label swap — the dynamic "When BASE is worth" sentence and the static
   "Limit price" placeholder are two sibling spans; --zero toggles which
   one renders. Default shows --dynamic; zero shows --zero. */
.ui-limit-price-card__label--zero { display: none; }
.ui-limit-price-card--zero .ui-limit-price-card__label--dynamic { display: none; }
.ui-limit-price-card--zero .ui-limit-price-card__label--zero { display: inline; }


/* ── [data-limit-only] — Limit-mode visibility gate ────────────────────
   Marker attribute carried by rows that should appear only when the swap
   widget is in Limit mode. Authored on the attribute (rather than a
   wrapping custom class) so the underlying row stays a canonical DS
   primitive — currently `.setting-row` for the "Expires in" row. Mirrors
   the `.ui-limit-off-market` mode-gate pattern below.

   Migration note (May 2026): the previous `.ui-limit-expiry` /
   `__label` / `__select` rule family was retired when the row was
   migrated to `.setting-row.setting-row--sm.setting-row--no-icon`
   — see playground.html. Old class names appear in this comment only.
   ----------------------------------------------------------------------- */
.ui-card [data-limit-only] { display: none; }
/* Display type is consumer-owned via --limit-only-display (default grid
   for the .setting-row case). Consumers like .ui-quote-mini-list set
   --limit-only-display: flex to keep their own layout type — this gate
   only handles visibility, not display semantics. */
.ui-card[data-mode="limit"] [data-limit-only] { display: var(--limit-only-display, grid); }


/* ── .ui-limit-off-market — Limit-mode loud warning wrapper ────────────
   Shown when the proximity-chip math returns the danger tier (|distance|
   >= 10%). Wrapper handles the data-mode gate; the inner .alert--warn
   keeps its primitive visual. JS in ui-limit-price-card.js →
   renderProximity() toggles [hidden] based on the tier and rewrites the
   message text with the current percent. Two visibility conditions
   compose: mode === limit AND not [hidden].
   ----------------------------------------------------------------------- */
.ui-limit-off-market {
  display: none;
}

.ui-card[data-mode="limit"] .ui-limit-off-market:not([hidden]) {
  display: block;
}


/* ══════════════════════════════════════════════════════════════════════ */


/* ── Responsive ───────────────────────────────────────────────────────────
   The floating rail is **always visible** at every viewport width
   (Apr 2026 v7) — only the user-driven pin collapses it (to the 64px
   icon-rail mode handled by `body.rail-collapsed` further up). The
   previous 1180px breakpoint that auto-slid the rail off-canvas was
   removed: hiding chrome the user didn't ask to hide is too aggressive,
   and the icon-rail collapse already covers narrow viewports. A future
   phase will add proper reflow (rail as a sheet / drawer below some
   threshold); for now we accept that very narrow viewports may crowd
   the widget — the rail's visibility is the priority.

   The 900px breakpoint stays — when the viewport is narrow enough that
   form + receive can't sit side-by-side, the widget stacks. */
@media (max-width: 900px) {
  .widget-stage { flex-direction: column; align-items: center; }
}
