Playground anatomy
The whole composition — left rail, widget stage, variants rail, and the Theme Composer FAB. Each region links to its primitive.
This map mirrors the live build at playground.html. The shell is a fixed left rail + a scrolling canvas that holds the widget stage; an optional variants rail docks on the right, and the shared Theme Composer FAB floats bottom-right. The left and right rails are playground-specific (documented here); the side-nav, drawer handle, command palette, and FAB are shared with Portal and documented on Components.
Schematic, not to scale — click any region to open its documentation.
Top-level skeleton. The shell is the shared .app-shell / .app-rail / .app-canvas foundation (Components → Side nav); the playground fills the rail with its customization screens and the canvas with the widget stage.
<body class="playground-body">
<!-- Left customization rail (playground-specific) -->
<aside class="panel playground-rail" data-screen="root"> … </aside>
<button class="drawer-handle"> … </button>
<!-- Canvas — scrolls; hosts the widget stage -->
<main class="playground-canvas">
<div class="widget-stage">
<aside class="side-nav side-nav--rail widget-stage__view-rail"> … </aside>
<section class="widget-stage__form"> … </section>
<section class="widget-stage__receive"> … </section>
</div>
</main>
<!-- Optional right rail for live A/B comparison -->
<aside class="panel variants-rail"> … </aside>
<!-- Theme Composer FAB — injected by shared.js (shared) -->
</body>
Each region of the composition, the primitive that builds it, and where it's documented. Playground-specific regions live on this page; shared chrome is documented on Components (referenced, never duplicated — it's shared with Portal).
| Region | Primitive | Role | Scope | Documented at |
|---|---|---|---|---|
| Left rail | .playground-rail | Customization drawer — drill screens for theme, layout, mode | Playground | Playground rail |
| Drawer handle | .drawer-handle | Collapse / expand the rails | Shared | Drawer handle |
| View rail | .side-nav--rail | Simple / Advanced tier switch (sets the layout) | Shared + Playground | Presentation |
| Widget stage | .widget-stage | Center layout — form + receive, dual or compact | Playground | Dual layout |
| Form | .ui-card | Send / Receive / Review state machine | Playground | Widget shell |
| Receive panel | .widget-stage__receive | Quotes, market chart, orders (mode-dependent) | Playground | Dual layout |
| Variants rail | .variants-rail | Right-docked live A/B comparison (workshop) | Playground | Variants rail |
| Theme Composer FAB | .fab | Bottom-right — theme composer + surface switcher | Shared | FAB menu |
| Command palette | .cmd-palette | ⌘K search + scope filter | Shared | Command palette |
- Playground-specific lives here; shared chrome is referenced. The left rail and variants rail are unique to the Playground and documented on this page. The app-shell foundation (
.app-rail/.app-canvas/.side-nav/.drawer-handle/.cmd-palette/.fab) is shared with the Portal — documented once on Components and linked from the map above, never duplicated. - The stage is the host. The widget stage holds whichever product the Playground is running — today the swap / bridge / limit widget (the groups below). New widget types drop into the same stage without changing the shell.
- The view rail drives the layout. Picking Simple vs Advanced sets the stage's layout (adaptive vs dual) — see Presentation and Dual layout.
- LI.FI 1.0 is the cold-load default. The FAB's Theme Composer can repaint the theme in-session, but every load starts on LI.FI 1.0 — the composer never auto-restores a saved palette (CLAUDE.md → "LI.FI 1.0 is the only default").
- Markup:
playground.html(the live build — rail, canvas, stage, variants rail). - Shell CSS:
styles.css→ "App shell" (.app-shell/.app-rail/.app-canvas);swap.css→.widget-stage,.playground-rail,.variants-rail. - Behavior:
shared.js→initDesignFab()(FAB),lifiInitAppRailScreens()/lifiInitAppRailCollapse()(rail drill + collapse);swap.js→ rail screens + view rail. - Canonical contract: CLAUDE.md → "Playground default config" + "App shell".
Playground rail .playground-rail
The shell — a fixed-position glass panel that hosts the rail's head, screens, sections, and footer. Composes the universal .panel for its surface chrome.
The mock below is the rail's actual root-screen markup from playground.html, with two surgical changes: position: fixed → position: relative on the rail and handle (so they live inside this documentation viewport instead of floating over the catalog page), and JS hooks (id attributes, data-drill attributes, click handlers) stripped. Every visible part is a real composed primitive — Brand lockup, Setting row, Theme card, Action bar, Drawer handle. Theme cards in the live rail are populated by JS from localStorage; the mock hardcodes three.
Live composition from playground.html. Two surgical changes for the static mock: position: fixed → position: relative overrides on the rail and handle, and stripped JS hooks (ids, data-drill attributes). All visible chrome is real .playground-rail__* CSS from swap.css.
<!-- The rail composes .panel for its surface chrome and adds rail-specific
geometry (fixed positioning, full-height, multi-screen container). -->
<aside class="panel playground-rail"
id="playgroundRail"
data-screen="root"
aria-label="Widget customization">
<!-- One .playground-rail__screen per drill destination.
The root screen is visible by default (data-screen="root"). -->
<div class="playground-rail__screen" data-screen-id="root">
<!-- Head — brand lockup + top action cluster -->
<header class="playground-rail__head">
<a class="brand-lockup brand-lockup--back" href="…">…</a>
<div class="playground-rail__top-actions">
<button class="btn-tertiary btn-neutral btn-icon btn-sm btn-circle">…</button>
</div>
</header>
<!-- Sections — group of setting rows under a quiet eyebrow -->
<div class="playground-rail__section">
<div class="playground-rail__section-title">Layout</div>
<button class="setting-row setting-row--drill" data-drill="layout-mode">…</button>
</div>
</div>
<!-- Sub-panel screens — siblings to the root, revealed via data-screen.
The header composes the universal .screen-header primitive — same
chrome the swap widget's pickers use. See #screen-header-sub. -->
<div class="playground-rail__screen" data-screen-id="layout-height">
<header class="screen-header">
<button class="screen-header__back btn-icon btn-borderless btn-circle" data-action="back">…</button>
<h3 class="screen-header__title">Height</h3>
<div class="screen-header__trailing"></div>
</header>
<p class="playground-rail__caption">…</p>
<!-- option-card grid, custom input, etc. -->
</div>
<!-- Footer — pinned to the bottom, action strip -->
<div class="playground-rail__footer">…</div>
</aside>
<!-- Sibling: the drawer handle (catalogued at #drawer-handle) -->
<button class="drawer-handle" id="playgroundRailHandle">…</button>
| Property | Value | Source |
|---|---|---|
| Width | var(--w-6) (384 px) | Apr 2026 v12 — bumped from --w-5 for theme cards + editor |
| Position | fixed · left edge · full-height | top: var(--canvas-top); bottom: var(--canvas-bottom) |
| Surface | Composes .panel | Background, shadow, backdrop, padding flow from .panel |
| Border-radius | 16 0 16 16 (top-right squared) | Squared corner is where the drawer handle docks |
| z-index | 100 | Above canvas, below navbar (1000) and modals (9000+) |
| Transform | translateX(var(--rail-tx)) | Slide-out toggle via the drawer handle |
| Active-screen rule | .playground-rail[data-screen="X"] .playground-rail__screen[data-screen-id="X"] | One screen visible at a time — see the drill card below |
| Sections internal stack | gap: var(--space-12) | From .panel's flex column |
| Section eyebrow | --text-caption · --fw-semibold · uppercase mono | .playground-rail__section-title |
| Footer | 2-button action strip · .btn-sm · flex: 1 each | Pinned to bottom via margin-top: auto |
| Collapsed state | body.rail-collapsed | Persists in localStorage (key: lifi-playground-rail-collapsed) |
The rail composes ten parts. The shell owns positioning and the screen system; everything else is either a universal primitive (composed) or a rail-specific helper (.playground-rail__*).
| Part | Class | Source |
|---|---|---|
| Shell | .playground-rail | Composes .panel · fixed positioning · multi-screen container |
| Screen | .playground-rail__screen | One per drill destination. Active one is revealed by the data-screen rule. See drill card below. |
| Head | .playground-rail__head | Flex row · brand lockup left, action cluster right · root screen only |
| Top action cluster | .playground-rail__top-actions + __top-btn | Three icon buttons — mode toggle (light/dark), reset, settings |
| Section | .playground-rail__section | Group of setting rows under one eyebrow · flex column · 4 px gap |
| Section title | .playground-rail__section-title | Quiet uppercase-mono eyebrow above a section |
| Caption | .playground-rail__caption | Descriptive paragraph under a sub-panel head — primary use is to introduce the controls below |
| Footer | .playground-rail__footer + __footer-btn | Pinned action strip · Copy code · Read docs |
| Setting row | .setting-row | Universal primitive — see #setting-row |
| Drawer handle (sibling) | .drawer-handle | Universal primitive · toggle affordance · see #drawer-handle |
- Compose, don't fork. The rail's body is built from universal primitives (
.panel,.setting-row,.brand-lockup,.option-card,.theme-card,.drawer-handle,.btn-tertiary). Don't author new.playground-rail__*classes for visual chrome that already exists in the universal layer. - Multi-screen footprint stays stable. All screens are siblings inside the shell. Only one is visible at a time via the
data-screenrule. This keeps the rail's vertical footprint identical across screens — drilling in and out never reflows the page. - Root screen owns the brand head. The root
.playground-rail__headcarries the brand lockup + top-action cluster — light/dark toggle, reset, settings. Sub-panels swap this out for the universal.screen-header(back arrow + centered title + optional trailing slot) — the same chrome the swap widget's pickers use, so every back-arrow header on the page reads as one primitive. Don't duplicate the brand lockup inside a sub-panel. - Footer is pinned, not pushed. The footer's
margin-top: autofloats it to the bottom of the screen so it's always visible. Long screen content scrolls inside the active.playground-rail__screen; the footer never moves. - Width is
--w-6(384 px). Don't author the rail at a different width — the universal width ladder is the contract. If the rail needs more room, step to--w-7; the rail's content (theme cards, sliders, option-cards) targets the 384 px column. - The drawer handle is a sibling, not a child. It lives outside
.playground-railso it can stay flush against the panel's edge during the translate transition without being clipped by the panel'soverflow. Both consume the shared--rail-txbody-level var.
- CSS:
swap.css— search.playground-railfor the shell rule +PLAYGROUND RAIL — multi-screen / drill-down primitivesfor the section comment block. - Markup:
playground.html— the<aside class="panel playground-rail">at the top of.playground-canvas. - JS wiring:
swap.js—initRailScreens()(drill handler),initRailCollapse()(drawer toggle),initRailFooter()(Copy code / Read docs),initRailHeightPanel()(Height drill),initRailThemeCards()(theme card list), plus per-panel inits. - Universal primitives composed: .panel · .brand-lockup · .setting-row · .option-card · .drawer-handle · .theme-card.
Multi-screen drill data-screen · data-screen-id
Drill-down contract — sibling screens inside the shell, one visible at a time, toggled by a single data attribute. Footprint stays stable.
Root screen mock — the rail's top-level menu. Clicking a drill row (highlighted below) sets data-screen="layout-height" on the rail, swapping the visible .playground-rail__screen for the matching sub-panel; the back arrow restores data-screen="root". No layout reflow — the shell footprint is identical because every screen renders inside the same fixed-height container.
data-screen="root"
Click the highlighted row → data-screen="layout-height".
<!-- The contract: data-screen on the rail picks one screen by id -->
<aside class="panel playground-rail" data-screen="root">
<!-- Root screen — visible when data-screen="root" -->
<div class="playground-rail__screen" data-screen-id="root">
<button class="setting-row setting-row--drill"
data-drill="layout-height">
<!-- click sets data-screen="layout-height" on the rail -->
<!-- …icon / label / value / chevron… -->
</button>
</div>
<!-- Sub-panel — visible when data-screen="layout-height".
Header composes .screen-header (see #screen-header-sub). -->
<div class="playground-rail__screen" data-screen-id="layout-height">
<header class="screen-header">
<button class="screen-header__back btn-icon btn-borderless btn-circle" data-action="back">
<!-- click sets data-screen="root" -->
</button>
<h3 class="screen-header__title">Height</h3>
<div class="screen-header__trailing"></div>
</header>
<!-- …screen content… -->
</div>
</aside>
<!-- Adding a new sub-panel:
1. Pick a kebab-case id (e.g. "developer-events")
2. Add <div class="playground-rail__screen" data-screen-id="developer-events">
3. Author the screen content (.screen-header + caption + controls)
4. Add the matching selector to swap.css's active-screen rule
5. Add the data-drill="developer-events" attribute to the root setting-row
The drill handler in swap.js → initRailScreens() is generic. -->
| Attribute | Lives on | Role |
|---|---|---|
data-screen | .playground-rail (shell) | The active screen id. Default: "root". Set by JS on drill / back clicks. Read by CSS to reveal the matching child. |
data-screen-id | Each .playground-rail__screen | The child screen's id. Kebab-case. Matches a data-screen value to be revealed. |
data-drill | Drill row (e.g. .setting-row--drill) | Target screen id. initRailScreens() sets data-screen="<value>" on the rail when clicked. |
data-action="back" | Back button in .screen-header | Returns to root. initRailScreens() sets data-screen="root". |
CSS contract — one selector per screen, collapsed into a single rule
.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-height"] .playground-rail__screen[data-screen-id="layout-height"],
/* … one selector per registered screen … */ {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
- One shell, many siblings. Every screen is a direct child of the shell. Don't nest screens inside screens — the drill model is flat, not tree-shaped.
- Always include the root screen.
data-screen-id="root"is the default; the back action returns here. If you remove root, drilling out has nowhere to go. - One
data-screen-idper screen, kebab-case. Group-prefix to keep the namespace tidy (layout-mode,theme-edit,developer-events). Register every new id inswap.css's active-screen selector chain. - Footprint stays stable. All screens render inside the shell's fixed vertical footprint. Long screen content scrolls inside its own screen — never push the footer off the bottom.
- Drill clicks bubble through the rail.
initRailScreens()uses event delegation — one listener on the rail, finds the nearest[data-drill]or[data-action="back"]ancestor. Don't author per-row click handlers. - Drill from anywhere, back to root. The back button always returns to
"root", never to a previous sub-panel — no nested drill stack. If a sub-panel needs to drill further, register that destination as a sibling and drill directly; the back button still returns to root.
- CSS contract:
swap.css— searchScreen visibilityfor the selector chain and the active-screen rule with its flex envelope. - JS handler:
swap.js—initRailScreens()(event-delegated drill + back). Looks for[data-drill]and[data-action="back"]viaclosest()on a single rail-level listener. - Registered screens today:
root·layout-mode·layout-variant·layout-height·theme-appearance·theme-edit·theme-list·theme-typography. - Bug history: Apr 2026 v12 — moved overflow-y from the shell to the active screen so the footer could pin to the bottom while screen content scrolls. The
min-height: 0on the active screen is the flex incantation that lets it shrink past its intrinsic content size.
Variants rail .ds-doc-variants-rail
Glass panel · slide-in from right · hosts a labeled group of .ds-doc-variant-card items. Consumer wires positioning and the variant-switch handler.
A contained rendering of the rail at its natural width (384 px). The live primitive is position: fixed and slides in from the viewport's right edge — see it in flight in the playground. The drawer-handle in the preview is decorative; in production it docks at the rail's left edge and shares the slide transform.
<!-- Drawer-handle — consumer-wired to the rail's left edge -->
<button type="button" class="drawer-handle" id="variantsRailHandle"
aria-label="Toggle variants rail"
aria-controls="variantsRail" aria-expanded="false">
<svg class="drawer-handle__chevron" viewBox="0 0 24 24" aria-hidden="true">
<path d="m15 18-6-6 6-6"/>
</svg>
</button>
<!-- The rail — composes .panel for glass chrome -->
<aside class="panel ds-doc-variants-rail" id="variantsRail"
aria-label="Compare variants">
<header class="ds-doc-variants-rail__head">
<h2 class="ds-doc-variants-rail__title">Compare variants</h2>
<p class="ds-doc-variants-rail__caption">Click a card to swap the live widget.</p>
</header>
<div class="ds-doc-variants-rail__group">
<p class="ds-doc-variants-rail__group-label">Receive — Quote card</p>
<div class="ds-doc-variants-rail__cards"
data-variants-target=".widget-stage__receive .ui-quote-list">
<!-- .ds-doc-variant-card siblings, one per variant -->
</div>
</div>
</aside>
/* Consumer wiring — body-level CSS vars + fixed positioning */
body.playground-body {
--vrail-width: var(--w-6);
--vrail-right: 24px;
/* Default collapsed — rail sits off-screen-right */
--vrail-tx: calc(var(--vrail-width) + var(--vrail-right));
}
body.playground-body.vrail-expanded { --vrail-tx: 0px; }
.ds-doc-variants-rail {
position: fixed;
right: var(--vrail-right);
top: var(--canvas-top);
bottom: var(--canvas-bottom);
width: var(--vrail-width);
transform: translateX(var(--vrail-tx));
}
| Property | Value | Source |
|---|---|---|
| Width | var(--w-6) (384 px) | Same tier as the left .playground-rail — pairs visually |
| Height | calc(100vh - canvas-top - canvas-bottom) | Full-height between the canvas's top/bottom insets (24 px each) |
| Surface | var(--surface-panel-glass) | Inherited from .panel — auto-flips light / dark |
| Shadow | var(--shadow-panel-glass) | Inherited from .panel |
| Backdrop blur | var(--backdrop-panel-glass) | Inherited from .panel |
| Border-radius | 0 var(--space-16) var(--space-16) var(--space-16) | Top-left squared where the drawer-handle docks |
| Padding | var(--space-24) | Consumer-set in the playground wiring |
| Internal stack gap | var(--space-24) | Between __head and each __group |
| Cards gap | var(--gap-card-md) (12 px) | Via local --card-gap on .ds-doc-variants-rail__cards — override per-instance |
| z-index | 100 | Above canvas; below navbar (1000) and modals (9000+) |
| Slide transition | 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) | Shared between rail and drawer-handle via --vrail-tx |
| Title typography | var(--text-h3) · var(--fw-bold) | Heading-scale, sans |
| Caption typography | var(--text-sm) · var(--lh-sm) | Reads as one paragraph of supporting prose |
| Group label typography | var(--text-caption) · uppercase mono · --text-faint | Quiet eyebrow over the cards |
| Layer | Owned by | What's in it |
|---|---|---|
| Surface chrome | .panel (composed) | Glass background, shadow, backdrop-filter — all theme-aware via --surface-panel-glass family |
| Geometry | .ds-doc-variants-rail | Flex column, vertical gap, border-radius corner override for the docked edge, internal padding |
| Positioning | Consumer | position: fixed, right/top/bottom offsets, width, z-index, the translateX slide transform |
| State binding | Consumer | The body class that toggles --vrail-tx between collapsed (off-screen-right) and expanded (0) |
| Variant-switch handler | Consumer | Click on .ds-doc-variant-card → move .is-active → set data-variant on the live target → re-render |
Sub-parts
| Part | Class | Role |
|---|---|---|
| Root | .ds-doc-variants-rail | Glass panel · flex column · slide-in transform host. Composed on <aside> with .panel. |
| Head | .ds-doc-variants-rail__head | Title + caption stack at the top of the rail |
| Title | .ds-doc-variants-rail__title | h2-scale heading — visible name of the variant set |
| Caption | .ds-doc-variants-rail__caption | One paragraph — what's being tested, how stakeholders should use it |
| Group | .ds-doc-variants-rail__group | Labeled cluster of variant cards |
| Group label | .ds-doc-variants-rail__group-label | Mono uppercase eyebrow naming the component under test |
| Cards container | .ds-doc-variants-rail__cards | Flex column of .ds-doc-variant-card items. Reads data-variants-target (CSS selector for the live widget) |
- Default-collapsed, always. Workshop tooling is opt-in. The only chrome visible at rest is the drawer-handle. Auto-expanding pollutes the canvas every page load.
- Don't reflow the canvas without freeing host chrome. Both rails open simultaneously squeezes the widget. Auto-collapse existing chrome on open (the playground pattern:
vrail-took-railmarker on body, restore on close) or let the rail overlay — never let the layout fight itself. - Variant switch goes through
data-variant+ re-render. Click a card → move.is-active→ setdata-varianton the live target → call the consumer's render function.data-variant="default"removes the attribute so the canonical render path applies. - Don't make the live target re-fetch data on variant switch. Variants are renderer branches over cached state. Re-fetching causes flicker and breaks the workshop feel.
- Document the canonical card in the catalog; variants stay in the rail. The catalog points at the default. Alternates live as renderer branches behind
data-variantflags — easy to keep, easy to drop if the workshop rejects them.
CSS: styles.css § VARIANTS RAIL — .ds-doc-variants-rail / .ds-doc-variant-card — search for .ds-doc-variants-rail {.
Consumer wiring: swap.css § .ds-doc-variants-rail consumer wiring.
Spec: design/components/design-feedback.md → ## Variants rail.
Live reference: playground.html — the inline script at the bottom of the file wires the handle toggle + variant-card click delegation + the auto-collapse-left-rail behavior.
.ui-card shell. Every widget primitive carries the .ui- tier-3 prefix; the live build is playground.html.Widget modes — Bridge · Swap · Limit · Gas · Private
Five modes share one .ui-card shell — each flexes vocabulary, picker route, and side panel. Private (Houdini-routed) also requires a receiving wallet.
Four modes, four compositions. Each silhouette below renders the mode in isolation — the tab strip is omitted so the mode-specific anatomy reads cleanly. The tab strip itself lives in the form's .screen-header--main when the playground's flow drill is set to "Default" (see playground.html).
Bridge · any token, any chain
Both sides fully independent — any token on any chain → any token on any chain. No chain mirror, no symbol mirror. The unconstrained mode.
Swap · token → token, same chain
Swap
Both sides snap to the same chain on every pick. Standard tandem chip (token + chain badge) on both sides.
Limit · trade at a target price, same chain
Limit
Market price
Adds limit-price card + expires-in row to the form. Side panel swaps from quote list to market price + chart. Slot labels reframe to Sell / Buy.
Gas · top up native gas on a destination chain
Gas
Gas is the only mode with a chain-only picker route — the receive chip stays a tandem, with the token auto-locked to the destination chain's native gas (ETH on Arbitrum, POL on Polygon, BNB on BSC, …). The chain list is limited to chains with a native-gas avatar.
Private · any token, any chain — via Houdini Swap
Private
Route
- Network cost
- $2.87
- Bridge fee
- $2.55
- Price impact
- -0.49%
- Max. slippage
- 0.30%
- Min. received
- 1,117.87 USDC
Bridge's any-token / any-chain mechanics, routed via Houdini Swap (privacy aggregator). A single route, so the quote renders as a compact inline .ui-quote-card in the form — after the receiving wallet — and the separate route panel is suppressed; its header chip reads shield + “Private” (not “Best return”). Once connected, the .ui-destination-wallet-card is always shown and a receiving address is mandatory: the zero-state shows an inline address input (type / paste, or “Select” to open the picker) with a “Required” warn chip beside the label, and the Review CTA stays disabled until it's filled.
<article class="ui-card" data-state="form" data-mode="bridge | swap | limit | gas | private">
<header class="screen-header screen-header--main">
<div class="seg seg--borderless" role="tablist">
<button data-mode="bridge">Bridge</button>
<button data-mode="swap">Swap</button>
<button data-mode="limit">Limit</button>
<button data-mode="gas">Gas</button>
</div>
<h2 class="screen-header__title">Swap</h2>
</header>
<div class="ui-form-stack">
<!-- Limit only --- hidden via [data-limit-only] in other modes -->
<div class="ui-limit-price-card" data-limit-only>…</div>
<!-- Always: send + arrow + receive -->
<div class="ui-amount-pair">
<div class="ui-amount-card" data-amount-side="send">
<!-- Label: "Send" (bridge/swap/gas) | "Sell" (limit) -->
</div>
<button class="ui-swap-arrow ui-swap-arrow--lg">…</button>
<div class="ui-amount-card" data-amount-side="receive">
<!-- Label: "Receive" (bridge/swap/gas) | "Buy" (limit) -->
<!-- Chip: tandem (token + chain) in every mode -->
<!-- Gas: tandem but TOKEN auto-locked to chain's native gas -->
</div>
</div>
<!-- Limit only -->
<div class="setting-row setting-row--sm" data-limit-only>
<span>Expires in</span> <div class="dropdown">…</div>
</div>
<div class="ui-quote-mini-list" data-limit-only>…</div>
</div>
<!-- CTA: "Review" in every mode -->
<button class="btn-primary">Review</button>
</article>
<!-- To-side picker route in initCard():
bridge | swap | limit → token-picker (full token list + chain-filter sidebar)
gas → chain-picker (chain-only; no follow-up token-picker step)
Gas: on chain commit, swap.js → snapToToNativeGas() forces the
To token to LifiPickerData.findChain(chainKey).nativeSymbol. -->
<!-- Sibling panel (.widget-stage__receive) - CSS-gated via body[data-widget-mode]:
bridge | swap | gas → .ui-quote-list (full route comparison)
limit → [data-limit-receive] (.ui-token-info + .ui-price-chart) -->
| Axis | Bridge | Swap | Limit | Gas | Private |
|---|---|---|---|---|---|
| Tier placement | Both tiers | Advanced tier | Advanced tier (3rd tab) | Simple tier (3rd tab) | Simple tier (2nd tab) |
| Purpose | any token → any token, any chain | token → token, same chain | trade at a target price, same chain | top up native gas on a destination chain | any token → any token, any chain — via Houdini Swap (privacy) |
| Slot labels | Send / Receive | Send / Receive | Sell / Buy | Send / Receive | Send / Receive |
| Picker headings | Send token / Receive token | Send token / Receive token | Sell token / Buy token | Send token / — (To opens chain-picker) | Send token / Receive token |
| To-side picker route | token-picker | token-picker | token-picker | chain-picker | token-picker |
| Receive chip | .chip-avatar tandem (token + chain) | .chip-avatar tandem (token + chain) | .chip-avatar tandem (token + chain) | .chip-avatar tandem — token auto-locked to chain's native gas | .chip-avatar tandem (token + chain) |
| Chain constraint | independent — any chain → any chain | both sides snap to From chain | To-side chain snaps to From | sides may differ (cross-chain by intent) | independent — any chain → any chain (same as Bridge) |
| Symbol constraint | independent | independent | independent | To-symbol locked to To-chain's native gas (ETH / POL / BNB / AVAX / …) | independent |
| Adds to form | — | — | .ui-limit-price-card + Expires in setting-row + .ui-quote-mini-list | — | .ui-destination-wallet-card — mandatory, always shown once connected |
| Receiving wallet | Optional — footer trigger | Optional — footer trigger | None — not applicable (fills to the connected wallet) | Optional — footer trigger | Mandatory — always-shown .ui-destination-wallet-card |
| Side panel content | .ui-quote-list | .ui-quote-list | .ui-token-info + .ui-price-chart | .ui-quote-list | None — route panel suppressed. The single Houdini route renders as a compact inline .ui-quote-card in the form, after the receiving wallet. |
| Side panel header | "Receive" | "Receive" | "Market price" | "Receive" | "Receive" |
| Side panel trailing | .refresh-timer | .refresh-timer | Expand-panel button | .refresh-timer | .refresh-timer |
| Primary CTA | "Review" | "Review" | "Review" | "Review" | "Review" — disabled until a receiving address is set |
| CSS gates | [data-mode="bridge"] · body[data-widget-mode="bridge"] | [data-mode="swap"] · body[data-widget-mode="swap"] | [data-mode="limit"] · body[data-widget-mode="limit"] · descendant [data-limit-only] | [data-mode="gas"] · body[data-widget-mode="gas"] | [data-mode="private"] · body[data-widget-mode="private"] |
| Persisted | localStorage["lifi-playground-widget-mode"] — default bridge; restores on next visit. | ||||
- Modes are organized by view tier, not a single nav. The
.segtab bar shows exactly three modes per tier: Advanced → Swap · Bridge · Limit; Simple → Bridge · Private · Gas. Bridge is the only mode in both tiers — it's the re-home anchor when a tier switch drops the active mode. Cold-load default is the Advanced tier on Bridge. (The old same-token-across-chains "Bridge" mode was retired; the cross-chain swap now owns the Bridge name, since any-token-any-chain subsumes same-token-cross-chain.) - The shell never changes. Every mode renders inside the same
.ui-cardwith the same.ui-amount-pairin the middle. What flexes is what surrounds the pair (limit-price card, expires-in row, mini-quote list) and what the side panel hosts (quote list vs. market-price chart). - Two attributes drive every gate.
data-modeon the form.ui-carddrives in-card behaviors.data-widget-modeon<body>drives behaviors on siblings of the form — chiefly the Receive panel, which the form card's attribute can't reach via CSS alone.swap.js → applyMode()keeps them in sync. - Mode-only primitives carry
[data-limit-only]. CSS hides them in Bridge / Swap / Gas. The attribute is the contract; never gate visibility via mode-specific class names — they bypass the seg-tab handoff and drift when the next mode lands. - Vocabulary travels. Limit re-labels the slots (Sell / Buy) and the picker headings (Sell token / Buy token). Picker headings flip via
MODE_LABELSinswap.jsso the screen the user navigates INTO matches the screen they navigated FROM. The primary CTA reads “Review” in every mode (June 2026 — dropped the Limit-only “Review Limit Order” copy for cross-mode consistency). - Chain constraints are mode-specific. Bridge is the unconstrained baseline — both sides fully independent, any chain → any chain, no mirror at all. Swap is Bridge PLUS a symmetric chain mirror (both sides snap to the same chain). Limit snaps the To-side chain to From (single-chain, like Swap) but keeps the token pickers independent. Gas lets chains differ (cross-chain by intent) and locks the destination TOKEN to the destination chain's native gas symbol.
- Gas auto-locks the destination token. Gas is the only mode with a chain-only picker route — the To-side chip opens
[data-state-content="chain-picker"], never a token list. On chain commit,swap.js → snapToToNativeGas()readsLifiPickerData.findChain(chainKey).nativeSymboland resolves the token throughfindToken()(so symbol→file aliases like POL→matic.svgare honoured — never string-buildavatars/tokens/<sym>.svg), then stamps the trigger viaapplyTokenToTrigger(). The receive chip's tandem repaints with that chain's native gas token (ETH on Ethereum / Arbitrum / Base / Optimism, POL on Polygon, BNB on BSC, AVAX on Avalanche, …). The chain IS the token pick — no separate token affordance. - The Gas chain list is limited to chains with a native-gas avatar.
picker-render.js → renderChainPickerList()filters the Gas-mode destination chain-picker to chains wherefindToken(nativeSymbol, chainKey)resolves — chains whose native gas token has no avatar in the library (Mantle/MNT, Gnosis/xDAI, Celo, Sonic, …) are filtered OUT rather than offered with a broken token glyph. As native-gas avatars are added to/avatars/tokens/, those chains become selectable automatically. - Private is Bridge routed through Houdini Swap, with a mandatory receiving wallet. Mechanically identical to Bridge (unconstrained any-token / any-chain), with two differences: it surfaces a single Houdini Swap route (
ui-quote-list.jspassesoptions.forceProvidertogetRoutes()whenbody[data-widget-mode="private"]— Houdini aggregates to one privacy path rather than a competing-bridge list). Because there's only one route, the separate receive panel is suppressed (body[data-widget-mode="private"] .widget-stage__receive { display: none }+ the Dual grid collapses to one column) and the quote renders as a compact inline.ui-quote-cardin the form, placed after the receiving-wallet card; its header chip reads shield + “Private”. Second, the privacy mechanism sends output to a user-specified address — so once the wallet is connected, the.ui-destination-wallet-cardis always shown (abody[data-widget-mode="private"][data-playground-wallet-connected]gate, not[data-destination-set]) and a receiving address is required.swap.js → syncPrivateGate()drives the inline-input + “Required” warn-chip zero-state and disables the Review CTA until an address is set (the gate is presentation-independent). Private is the Simple tier's 2nd tab (not in the Advanced seg) and is also selectable as a Single-presentation lock (the single-type picker). - The receiving-wallet flow is per-mode — offered, required, or suppressed. Swap / Bridge / Gas offer it (the optional footer
[data-send-wallet-trigger]; choosing an address swaps in the.ui-destination-wallet-card). Private requires it (always-shown card, mandatory address). Limit suppresses it entirely — a limit order fills to the connected wallet by definition, sobody[data-widget-mode="limit"]hides both the footer trigger and the card (even if an address was set in another mode before switching). The send-wallet screen is only reachable via those two entry points, so hiding them removes the flow. See the Receiving wallet matrix row + the.ui-destination-wallet-cardRules pane. - The side panel is the biggest visual shift. Bridge / Swap / Gas:
.ui-quote-listslides in when an amount is entered. Limit: the panel ALWAYS renders (regardless of amount) because.ui-token-info+.ui-price-chartis market-reference content — it's true whether or not the user has typed yet. - Compact route picker inside the form (Limit only). Limit replaces the side panel's full quote list with a tiny inline
.ui-quote-mini-listdirectly under the amount-pair. The trade-off: Limit needs the side panel for the chart, so the routing UI gets compacted into the form. Compact card carries the same dual gate (mode + readiness) as the full list. - Persistence. Mode survives reload via
localStorage["lifi-playground-widget-mode"]. Default on first visit is Bridge; unknown values fall back to Bridge. - Source of truth:
swap.js(applyMode(),MODE_LABELS),swap.css(body[data-widget-mode]rules),playground.html(canonical markup composition). When in doubt, match playground.html.
Presentation — View rail · Single
How the swap widget exposes its flows — a 2-tier View rail (Simple / Advanced), or a title-only Single lock.
The widget's flows are exposed two ways, chosen from the playground rail's Presentation drill (body[data-presentation]): View rail (a 2-tier left rail — Simple / Advanced — that drives a segmented .seg tab bar in the form header) and Single (one widget type, title only). The same buttons drive applyMode in either presentation — only the chrome differs. Switch between them in playground.html. (Replaced the former Mode + Navigation drills in May 2026; the 4-mode icon rail and the standalone Tabs presentation with its .seg-menu overflow chevron were retired — the split-tab primitive itself lives on at .seg-menu for future overflow modes.)
View rail · .widget-stage__view-rail + .side-nav--rail
Two tiers on the panel surface (--surface-raised + --elev-3). Simple (shuffle) → Adaptive layout, tabs Bridge · Private · Gas; Advanced (candlesticks) → Dual layout, tabs Swap · Bridge · Limit.
data-presentation on <body> is the sole CSS gate — the single-lock, the view-rail show, and the Dual-grid composition rules all read it directly. The View rail is a .side-nav--rail with two tier buttons; the .seg tab bar stays in the form header.
<!-- Set from the playground rail's Presentation drill -->
<!-- "view-rail" (default) | "single" -->
<body data-presentation="view-rail" data-view-tier="simple">
<!-- The View rail — 2 tier buttons beside the form. Shown only -->
<!-- in the view-rail presentation. -->
<aside class="side-nav side-nav--rail widget-stage__view-rail" role="tablist" aria-label="Widget view">
<span class="tooltip-wrap">
<button class="btn-icon btn-borderless" role="tab" data-view-tier="simple" aria-label="Simple">…</button>
<span class="tooltip" data-tooltip-pos="right">Simple</span>
</span>
<!-- … Advanced tier button -->
</aside>
body[data-presentation] | What it shows | Layout | Tabs |
|---|---|---|---|
| single | One widget type, static title, no switcher | Any (free pick) | — (locked) |
| view-rail | 2-tier left rail (Simple / Advanced) | Owned by tier — Simple→Adaptive, Advanced→Dual | Per tier: Simple→Bridge·Private·Gas / Advanced→Swap·Bridge·Limit |
Persisted across lifi-playground-presentation, lifi-playground-single-type, lifi-playground-view-tier, and lifi-playground-layout-variant via swap.js → initRailPresentationPanel() + initViewRail(). Every CSS gate reads data-presentation directly.
A one-time migration — swap.js → migratePlaygroundConfigRev(), version-stamped by lifi-playground-config-rev — runs once to clear the saved presentation / tier / layout (plus the retired lifi-playground-flow-mode / lifi-playground-mode-nav fall-through keys), re-hydrating returning users to the canonical view-rail + Advanced default (June 2026 flip). Source of truth: CLAUDE.md → Playground default config.
| Part | Class | Role |
|---|---|---|
| View rail | .widget-stage__view-rail | A .side-nav--rail on the panel surface (--surface-raised + --elev-3 + --panel-radius). display:none until the view-rail presentation shows it. |
| Tier button | .btn-icon.btn-borderless | One per tier, role="tab" + data-view-tier (simple / advanced). Drives layout + tab set via initViewRail. |
| Tab strip | .seg in .screen-header--main | Stays in the form header; the tier drives all three slots (2 fixed .seg-item + 1 .seg-item--menu), so the fixed pair differs per tier — Advanced leads Swap·Bridge, Simple leads Bridge·Private. |
| Item wrapper | .tooltip-wrap | Hosts each icon button + its hover .tooltip for the label. |
- One axis, two presentations.
data-presentation=view-rail/single. It replaced the former Mode (lock-to-flow) + Navigation (tabs / rail / hybrid) drills in May 2026 — one drill instead of two overlapping ones. - The View rail owns the layout. Simple→Adaptive, Advanced→Dual, so the standalone Layout drill hides under view-rail. Single keeps the free Layout pick.
- Tiers swap the seg, no rebuild. The mode-click handlers are bound per button and read
data-modeat click time, sosyncTierSegrewrites all three slots'data-mode+ label per tier — the fixed pair itself changes (Advanced: Swap·Bridge·Limit; Simple: Bridge·Private·Gas), the third rides the.seg-item--menu. Markup order is display order (no CSSorderhack). Switching tiers re-homes the active mode to Bridge (the tab shared by both tiers) when it isn't in the new set. - One source of truth. Every CSS gate (single-lock, view-rail show, Dual-grid composition) reads
body[data-presentation]directly. The earlierdata-flow-mode/data-mode-navtranslation layer was removed once the consolidation was complete. - The 4-mode icon rail was retired (May 2026). Only the View rail's 2-tier switcher remains. Don't reintroduce a standalone icon rail of all four modes.
- The Tabs presentation was retired (June 2026). Its split menu-tab opened a full-screen modes overflow screen — an affordance that duplicated the View rail's tier mechanism for the only 4th mode (Gas). The axis collapsed to
view-rail/single; theinitModesScreenhandler + thedata-state="modes"screen were removed. The.seg-menusplit-tab primitive is preserved — re-mount its overflow chevron in the view-rail seg when modes genuinely exceed three (DCA / perps). - Source of truth:
swap.js → initRailPresentationPanel()+initViewRail(),playground.html(the#railPresentationGrid+#railSingleTypeGridoption cards + the.widget-stage__view-railmarkup). When in doubt, match playground.html.
- CSS —
swap.css, searchView rail(surface + show gate) andView-rail tier tabs(tier-driven slots — markup order is display order; menu-tab right-padding restore). - JS —
swap.js → initRailPresentationPanel()(presentation axis + Single type pick + translation layer) andinitViewRail()(tiers → layout + tab set + re-home). - Markup —
playground.html: the#railPresentationGrid+#railSingleTypeGridoption cards (the drill) and the.widget-stage__view-railaside.
Dual layout — form + persistent side panel
Persistent two-panel layout. The right panel shows the route list (Swap/Bridge/Gas) or the chart (Limit), with an honest idle placeholder.
Dual is the persistent two-panel layout (set from the Layout drill, or via the View rail's Advanced tier). The form + quick-settings sit in the left column; an always-visible panel fills the right. Unlike Adaptive — where the panel slides in only when a quote is ready — Dual's panel is a fixed grid column, so it (a) follows the canonical mode→panel mapping every layout uses, and (b) needs an honest idle state when there's nothing real to show yet. The three panel states are below; live two-panel switching across Swap / Bridge / Limit lives in playground.html.
Swap / Bridge / Gas · route list
Receive
The full .ui-quote-list lives here — relocated to the aside like Adaptive, not collapsed into the form's quotes drill. Each row is a .ui-quote-card; the header carries a refresh-timer when routes are live.
Limit · market-price chart
Market price
.ui-token-info + .ui-price-chart — market reference, independent of trade state, so the panel renders the chart even before an amount is typed (you read the price to set a limit). The header offers an expand affordance instead of a refresh-timer.
No amount yet · idle placeholder
Receive
Enter an amount to compare routes
Swap / Bridge / Gas with no amount entered. Because Dual's panel is persistent, it shows the canonical .empty-state — never the chart, never faked routes. In production it carries [data-routes-idle] and is gated off data-quotes-state; the live route list replaces it the moment an amount makes a quote real.
<!-- Dual is gated on the body attribute (set by the Layout drill or the -->
View rail's Advanced tier). The receive panel is a SIBLING of the form -->
.ui-card and stays mounted in every state. -->
<body data-layout="dual" data-widget-mode="swap">
<aside class="widget-stage__receive">
<article class="panel ui-card">
<header class="screen-header screen-header--main">
<h2 class="screen-header__title">Receive</h2>
</header>
<!-- Honest idle placeholder — shown when data-quotes-state isn't -->
"visible" (Swap/Bridge/Gas, no amount yet). -->
<div class="empty-state" data-routes-idle aria-live="polite">
<span class="spot-icon spot-icon-4xl" aria-hidden="true"><!-- route icon --></span>
<p class="empty-state__message">Enter an amount to compare routes</p>
</div>
<!-- Route list — shown when quotes are ready (Swap/Bridge/Gas). -->
For Limit, swap this block for .ui-token-info + .ui-price-chart. -->
<div class="ui-quote-list"><!-- ui-quote-list.js populates --></div>
</article>
</aside>
</body>
| Mode | Side panel content | Idle state (no amount) |
|---|---|---|
| Bridge | Route list (.ui-quote-list) | Routes-idle placeholder |
| Swap | Route list (.ui-quote-list) | Routes-idle placeholder |
| Gas | Route list (.ui-quote-list) | Routes-idle placeholder |
| Limit | Market chart (.ui-token-info + .ui-price-chart) | Chart's own "select a token" empty-state |
Same mapping Adaptive uses — Dual just keeps the panel persistent. The stage grid is 416px 416px ("form chart"), widening to auto 416px 416px when the View rail adds its leading column.
| Hook | Role | Notes |
|---|---|---|
| body[data-layout="dual"] | Layout gate | Set by the Layout drill or the View rail's Advanced tier; switches .widget-stage to a CSS grid. |
| .widget-stage__receive | Persistent panel | Fixed grid area — never slides. Hosts the route list or chart per mode. |
| .ui-quote-list | Route list | Relocated to the aside (like Adaptive), NOT into the form's quotes drill. One node — selection + getReviewModel() travel with it. |
| [data-routes-idle] | Idle placeholder | An .empty-state shown when the stage isn't quote-ready. Hidden in Adaptive (panel slides away) and Limit (chart owns the panel). |
| .widget-stage[data-quotes-state="visible"] | Readiness gate | Set on tokens-picked + amount > 0 (wallet-optional). Flips idle ↔ live route list. |
- Match the mode→panel mapping — route list for Swap / Bridge / Gas, chart for Limit. Same mapping Adaptive uses; don't fork it per layout.
- Honest idle, never fake data — when no amount is entered, show the
.empty-stateplaceholder, not the price chart (a seeded demo series) and not faked routes. The chart is near-meaningless for Bridge anyway — the asset price is constant across chains. - Routes relocate to the aside, not the form drill — Dual keeps the persistent panel as the routes home (like Adaptive); only Compact collapses routes into the form. The in-form
.ui-quote-inlinepreview is suppressed in Dual. - Header chrome follows the panel — refresh-timer in route context; expand + chart-token tabs in chart context (Limit). The refresh-timer is suppressed during the idle state (nothing to count down to).
- Source of truth:
design/components/swap.md → Layout variantsdefines the mapping; this card renders it.
- CSS —
swap.css, searchDual layout variant(grid + persistent panel + mode-gated chart/list +[data-routes-idle]gates). - JS —
swap.js → relocateQuoteList()(routes → aside in Dual;routesInForm()= Compact only) plus the title / expand-collapse logic inapplyMode()andapplyLayout(). - Markup —
playground.html: the.widget-stage__receiveaside and the[data-routes-idle]empty-state. - Spec —
design/components/swap.md → Layout variants — Adaptive · Compact · Dual.
Widget shell .ui-card
Outer container of the swap widget. Two-tier elevation hosts stacked sub-cards; data-state swaps inner content (form / picker / review / tx-progress).
Exchange
<article class="ui-card" data-state="form">
<header class="screen-header screen-header--main">…</header>
<div class="ui-amount-pair">
<div class="ui-amount-card">… <!-- Send --></div>
<button class="ui-swap-arrow ui-swap-arrow--lg ui-swap-arrow--vertical">…</button>
<div class="ui-amount-card">… <!-- Receive --></div>
</div>
<button class="btn-primary">Review swap</button>
</article>
| Dimension | Value |
|---|---|
| Max width | var(--w-7) · 416px |
| Padding | var(--space-24) · 24px |
| Inner gap | var(--space-16) · 16px |
| Border radius | 24px |
| Background | var(--surface-raised) |
| Shadow | 0 1px 2px / 0.04 + 0 8px 24px / 0.06 |
| State attribute | data-state="form | picker | review | tx-progress" |
| Sub-card pair gap | var(--card-gap) defaults to var(--gap-card-sm) · 8px (see .ui-amount-pair) |
- Two-tier elevation. Outer shell is
--surface-raised; inner.ui-amount-cardSend + Receive sub-cards sit on top at--surface-card. Both tokens auto-flip light/dark so the elevation reads the same in either theme. - One
.ui-cardper widget instance. The shell holds the entire flow;data-stateswaps the inner content as the user moves through stages. - Sub-cards live inside
.ui-amount-pair— the composition primitive that anchors the.ui-swap-arrow--lg.ui-swap-arrow--glassFAB on the seam between Send and Receive. The FAB tracks the joint regardless of either card's height (see the recipe). - Don't bake interactivity into the CSS. State changes happen in
swap.jsby togglingdata-state. - Source of truth: the v2 widget mockups in Figma (file
rGJrqtE7660rPUTrtS3yqR). When in doubt, match the Figma frame, not playground.li.fi v1.
Amount pair .ui-amount-pair
Composes two .ui-amount-cards with a swap-direction FAB anchored on the seam. The FAB tracks the joint regardless of card heights.
Default · matched heights
Asymmetric heights · FAB tracks the seam
Disconnected · both chips placeholder, “Select” label, meta hides
Wallet-disconnected default. Both cards in .ui-amount-card--zero (derived values fade), both chips in .avatar-tandem--placeholder with “Select” copy, balance / fiat captions hidden.
<div class="ui-amount-pair">
<div class="ui-amount-card">… <!-- Send sub-card --></div>
<button class="ui-swap-arrow ui-swap-arrow--lg ui-swap-arrow--vertical"
aria-label="Swap direction">
<svg viewBox="0 0 24 24">…</svg>
</button>
<div class="ui-amount-card">… <!-- Receive sub-card --></div>
</div>
| Property | Value | Notes |
|---|---|---|
| Display | flex · column | Cards stack vertically; FAB sits as the middle flex item. |
--card-gap | var(--gap-card-sm) · 8px | Visible inter-card whitespace. Consumes the system-wide card-gap family (see Card gap). Override on the pair to widen. |
--swap-arrow-size | var(--space-32) · 32px | FAB diameter (rest). Cascades into the .ui-swap-arrow child so the seam math and the FAB's width/height read from one source. See .ui-swap-arrow. |
FAB margin-block | calc((var(--card-gap) + var(--swap-arrow-size)) / -2) · -20px | Negative margin absorbs the FAB's REST flow size so cards meet at the seam regardless of the FAB's hover state. |
FAB align-self | center | Horizontally centred on the pair's column axis. |
FAB z-index | 1 | Above both cards so the glass surface paints cleanly over their edges. |
| Position context | position: relative | Kept only for z-index scoping — the FAB is no longer absolutely positioned. |
| Seam drift at Δh = 28px | 0.00px | Verified in the browser preview against an asymmetric pair. |
| Class | Role | Notes |
|---|---|---|
| .ui-amount-pair | Flex column wrapper | Owns the --card-gap token + cascades --swap-arrow-size down to the FAB. Also opts the FAB into --swap-arrow-cutout (4 px). Three required children in DOM order: Send, FAB, Receive. |
| .ui-amount-card ×2 | Send + Receive sub-cards | Documented at .ui-amount-card. Either card may change height (zero-balance, loading, error) without dislodging the FAB. |
| .ui-swap-arrow--lg --glass --vertical | Direction toggle FAB | 40px glass FAB. --lg + --glass are the canonical modifiers for the pair; --vertical rotates the chevron 90° for stacked layouts. |
- Three required children in DOM order. Send
.ui-amount-card, the.ui-swap-arrowFAB, Receive.ui-amount-card. Don't wrap the FAB in an intermediate container — the recipe targets.ui-amount-pair > .ui-swap-arrowdirectly. - The FAB lives in flex flow, never
position: absolute. Negativemargin-blockabsorbs its own height plus a gap on each side, so the cards meet at the seam and the FAB visually overlaps both equally. Verified 0px drift at Δh = 28px between cards. - To change the FAB size, override
--swap-arrow-sizeon the pair — not on the FAB directly. The variable cascades into the FAB child (which consumes it forwidth/height) AND the seam math (calc((--card-gap + --swap-arrow-size) / -2)). One source of truth, no drift. Full primitive spec at.ui-swap-arrow. - To widen the visible inter-card gap, override
--card-gapon the pair. The recipe holds the visible gap equal to--card-gapfor any FAB size. The pair defaults tovar(--gap-card-sm)(8 px) per the system-wide card-gap family. - Either card may change height freely. Future zero-balance, loading, and error states compose into Send or Receive without breaking the seam. Verify this stays true when you author a new state variant.
- Don't reintroduce
position: absolute+top: 50%. That centres the FAB on the midpoint of the pair's TOTAL height, which is only the seam when both cards happen to be equal. The recipe broke once that way — the source comment inswap.cssstates the math. - Disconnected / no-selection state composes three modifiers.
.ui-amount-card--zeroon both cards (derived values fade),.avatar-tandem--placeholderin the token chips, “Select” copy in the chip label, and per-card meta (balance / fiat / price impact) hidden. The Send input value is preserved at full ink when present — only derived slots fade. See the “Preserve user-typed intent through dependency loss” rule for the full two-tier contract. - Wallet is OPTIONAL for the quote preview. Once both chips are populated (real picks, not placeholder) AND the user typed an amount, the pair renders live Send fiat + Receive amount + Receive fiat regardless of wallet state, and the quote list panel displays. Only balance row, MAX chip, and the insufficient-balance alert are wallet-gated. Execution requires the footer CTA's wallet flip. Canonical rule: CLAUDE.md → Quote preview is wallet-OPTIONAL.
CSS: swap.css — search for .ui-amount-pair; the comment block above the rule states the seam-tracking math.
Live consumer: playground.html — the swap widget on the right rail.
Related primitives: .ui-card (the outer shell), .ui-amount-card (Send / Receive sub-cards), .ui-swap-arrow (the direction-toggle FAB).
Card label .ui-card-label
The widget's single-source card-title label — every card's top label reads at one treatment via the shared --ui-card-label-* tokens.
One treatment for every widget card-title label, single-sourced from --ui-card-label-*. The four below are the real consumers — identical size, weight, ink, tracking, and line-box. Change a token once and all of them follow.
Amount card · .ui-amount-card__label
Limit price card · .ui-limit-price-card lead
Destination wallet card · .ui-destination-wallet-card__label
Received card (execution flow) · .ui-card-label
<!-- Default for new cards: add .ui-card-label to the label span -->
<span class="ui-destination-wallet-card__label ui-card-label">Receiving wallet</span>
<!-- OR — slot-pattern cards point their own label slot at the token: -->
<!-- .ui-amount-card { --amount-card-label-color: var(--ui-card-label-color); } -->
<!-- both resolve to the same --ui-card-label-* source -->
| Token | Default | Notes |
|---|---|---|
| --ui-card-label-size | var(--text-sm) | 14 px |
| --ui-card-label-weight | var(--fw-semibold) | 600 |
| --ui-card-label-color | var(--text-primary) | Primary ink — the same darkness as the card's value text. NOT --text-secondary (the drift this primitive fixed). |
| --ui-card-label-tracking | var(--tracking-normal) | 0 |
| --ui-card-label-lh | calc(20 / 14) | 20 px line-box at the 14 px size — matches .seg-item. |
Two ways to apply it — both consume the same --ui-card-label-* source, so there's genuinely one truth:
| Method | For |
|---|---|
Add .ui-card-label to the label span | New cards (zero-CSS default). Exemplar: .ui-destination-wallet-card__label. |
| Point the card's label slot at the token | Slot-pattern cards with an existing __label rule. .ui-amount-card (--amount-card-label-*) & .ui-limit-price-card (--limit-price-card-lead-*) default their slots to var(--ui-card-label-*), preserving per-instance override. |
- One source, no hand-lockstep. The treatment used to be redeclared per card and kept in sync by hand (the limit card's comment literally said "update in the same edit, and vice versa") — and it drifted: the destination card shipped at
--text-secondary(a lighter grey). Now changing a--ui-card-label-*token once updates every card; card-to-card drift is structurally impossible. - Apply it to every new widget card's title label. Add
.ui-card-labelto the label span; never redeclare size / weight / color / tracking / line-height inline. - NOT the field-label role. The smaller field caption —
.ui-amount-field__label/.ui-token-select__label(12 px /--fw-medium/--text-secondary) — is a deliberately quieter style. Don't fold it into--ui-card-label-*. - Per-instance override via the token (e.g.
style="--ui-card-label-color: …"on the card) when a one-off genuinely needs to deviate — rare.
- Tokens + class —
swap.css→ searchWIDGET CARD-TITLE LABEL. - Canonical contract —
CLAUDE.md→ "Widget card-title labels — one canonical style". - Spec —
design/components/swap.md→ "Card-title label".
Amount card .ui-amount-card
Send / Receive sub-card inside the widget shell — header + amount-with-token-chip + footer. Default surface: --surface-card + --elev-1, one elevation tier above the .panel shell. Composes .chip-avatar, .chip, .avatar.
Send variant · user input on the amount · hover for quickpicks
Receive variant · read-only amount
Zero state · .ui-amount-card--zero · no wallet, no typed amount
Insufficient balance · data-state="error" · slide-in + icon swap
<div class="ui-amount-card">
<div class="ui-amount-card__header">
<span class="ui-amount-card__label">Send</span>
<span class="ui-amount-card__trailing">
<span class="chip chip-neutral">25%</span> <!-- 50, 75, MAX -->
</span>
</div>
<div class="ui-amount-card__body">
<input class="ui-amount-card__amount" value="100">
<button class="chip-avatar chip-avatar-neutral" data-role="from">…</button>
</div>
<div class="ui-amount-card__footer">
<span class="ui-amount-card__fiat">$100.00</span>
<span class="ui-amount-card__footer-meta">
<span class="ui-amount-card__balance" data-amount-slot="balance">
<!-- Stack: default + error twins share one grid cell.
data-state="error" on the parent slides between them
without changing the slot's height OR width. -->
<span class="ui-amount-card__balance-stack">
<span class="ui-amount-card__balance-amount">2,645.15</span>
<span class="ui-amount-card__balance-error" aria-hidden="true">Insufficient balance</span>
</span>
<span class="tooltip-wrap">
<button class="btn-icon btn-borderless btn-muted btn-xs ui-amount-card__balance-trigger">
<svg class="ui-amount-card__balance-icon ui-amount-card__balance-icon--wallet">…</svg>
<svg class="ui-amount-card__balance-icon ui-amount-card__balance-icon--error">…</svg>
</button>
<span class="tooltip tooltip--multi">Balance: 2,645.15 USDC ($2,645.15)</span>
</span>
</span>
</span>
</div>
</div>
Modifier matrix
| Modifier | Padding | Radius | Amount size | Label size | Use |
|---|---|---|---|---|---|
| default | 16 / 16 | 16px | 36px | var(--text-sm) · 14px | Form-state widget (Send / Receive, 416 px shell). The only tier — the --sm / --lg size ladder was retired June 2026. |
| --zero | inherit | inherit | inherit | inherit | State modifier. Re-pins --amount-card-amount-color and --amount-card-footer-color to --text-faint, dims the input placeholder to match, and hides the __footer-meta slot. Applied by ui-amount-card.js on the Receive card whenever the wallet is disconnected; applied on the Send card only when its input is empty (typed values render at full ink — preserved intent through a disconnect / reconnect round-trip). Toggle, not a size. |
Custom-property API
| Custom property | Default (rendered) | What it controls |
|---|---|---|
| --amount-card-bg | var(--surface-card) | Canonical card surface — theme-aware (24% in dark, pivots to --surface-page in light). Sits one tier above the .panel's --surface-raised fill. |
| --amount-card-radius | 16px | Corner rounding — reads from the system-wide --card-radius. |
| --amount-card-padding-y | var(--space-16) | Vertical padding (top + bottom). |
| --amount-card-padding-x | var(--space-16) | Horizontal padding (left + right). |
| --amount-card-gap | var(--space-12) | Gap between header / body / footer slots. |
| --amount-card-shadow | var(--elev-1) | Resting-card elevation. Pairs with the --surface-card default to lift one tier above the .panel shell (which sits at --surface-raised + --elev-3). --ghost resets to none. |
| --amount-card-label-size | var(--ui-card-label-size) | Header label type-size — defaults to the shared --ui-card-label-* single source (--text-sm · 14px). |
| --amount-card-label-weight | var(--ui-card-label-weight) | --fw-semibold · 600. |
| --amount-card-label-color | var(--ui-card-label-color) | --text-primary. All three label slots default to the shared --ui-card-label-* tokens, so this label can't drift from "Receiving wallet" / the limit + review labels. |
| --amount-card-amount-size | 36px | Body amount type-size. Ladder: --text-h2 / 36 / --text-h1. |
| --amount-card-amount-weight | 700 | Amount weight. |
| --amount-card-amount-tracking | -0.02em | Amount letter-spacing. |
| --amount-card-amount-lh | 1.1 | Amount line-height. |
| --amount-card-amount-color | var(--text-primary) | Amount colour. Override for branded summary cards. |
| --amount-card-footer-size | var(--text-caption) | Footer type-size. |
| --amount-card-footer-color | var(--text-secondary) | Footer colour. |
| Slot | Class | What goes here |
|---|---|---|
| Card | .ui-amount-card | Outer container; --surface-card background with subtle shadow. |
| Header | .ui-amount-card__header | Two-cell row: label (left) + trailing slot (right). |
| Label | .ui-amount-card__label | Bold 14px primary-text identifier (“Send”, “Receive”). |
| Trailing | .ui-amount-card__trailing | Inline flex slot. Used by Send for the quickpick chip row (25/50/75/MAX). Receive omits this slot in the canonical pattern — bridge / best-return / route badges live on the dedicated route-detail card, not on the amount card. |
| Body | .ui-amount-card__body | Two-cell row: 36px tabular-num amount (left) + .chip-avatar (right). |
| Amount | .ui-amount-card__amount | An <input> for Send (editable) or a <span> for Receive (read-only). |
| Footer | .ui-amount-card__footer | Two-cell row: fiat value (left) + supplementary meta (right). |
| Fiat | .ui-amount-card__fiat | USD value, tabular-nums, secondary-text. |
| Meta value | .ui-amount-card__balance | Inline-flex meta-value slot used on both sides — the class name reflects its first consumer but the slot is generic. Dual usage, differentiated by data-amount-slot: on Send (data-amount-slot="balance") it renders the wallet balance (e.g. 1,000) plus a wallet-icon trigger surfacing full-precision balance + USD value; on Receive (data-amount-slot="price-impact") it renders the signed-percent price impact (e.g. -7.27% for a loss, +0.05% for an unlikely gain) plus an info trigger surfacing the canonical Price Impact: 7.27% line and a one-sentence explainer. The info trigger is .tooltip-wrap + .btn-icon.btn-borderless.btn-muted.btn-xs.ui-amount-card__balance-trigger with a .tooltip.tooltip--multi at data-tooltip-pos="left". |
| Stack | .ui-amount-card__balance-stack | Inner 1×1 grid that hosts both default + error text twins inside the Send balance slot (Receive's price-impact slot doesn't use it — it has one text run only). grid-template-columns: max-content + justify-items: end right-aligns both twins to the same cell edge so the slot's width is constant in BOTH states (always sized to the wider twin = the error message). The slot's height is constant too — both twins share row 1. data-state="error" on the parent slides the twins. |
| Default twin | .ui-amount-card__balance-amount | The numeric balance run on Send (2,645.15) OR the signed-percent label on Receive (-7.27%). Inside .balance-stack, gets an explicit --text-secondary color so the price-impact slot's whole-text danger ink (still load-bearing on the Receive side) doesn't bleed into the Send amount twin during the cross-fade. |
| Error twin | .ui-amount-card__balance-error | The static "Insufficient balance" message that slides in from the right when the parent slot flips to data-state="error". Self-paints in --danger. Lives in the DOM at all times in opacity: 0; transform: translateX(60%) so the CSS transition can play. aria-hidden flips with state so assistive tech only reads the visible twin. |
| Trigger | .ui-amount-card__balance-trigger | The info-affordance button. position: relative so the error glyph can absolute-position over the info glyph — the button's intrinsic box size is unchanged from the single-svg version (no layout shift). The button's aria-label flips with the slot state ("Balance details" → "Insufficient balance — see details"). |
| Wallet glyph | .ui-amount-card__balance-icon--wallet | Default Lucide wallet svg; fades out on error. |
| Error glyph | .ui-amount-card__balance-icon--error | Lucide circle-alert — the system “needs your attention” mark. Cross-fades in over the resting wallet glyph on error; paints directly in --danger. |
| Meta | .ui-amount-card__footer-meta | Inline-flex slot that hosts the meta-value (.ui-amount-card__balance). Present on both Send (balance) and Receive (price impact). Hidden in one rule by .ui-amount-card--zero. |
- Send and Receive are the same primitive. Differences are compositional — not modifier classes. Send accepts an
<input>in__amount+ quickpick chips in__trailing+ the wallet balance in__footer-meta. Receive is the stripped form — a<span>in__amount+ token chip-avatar + fiat in__footer, nothing else. Bridge / best-return / gas / time / expand-route detail live on the dedicated route-detail card adjacent to the amount card, not on it. Don't author--send/--receivemodifiers; the composition IS the difference. - Always pairs inside
.ui-cardvia.ui-amount-pair. The wrapper is what makes the swap-direction FAB possible (relative-positioned parent). A single Amount card on its own surface is OK for the disconnected zero-state, but the default widget pattern is the Send / FAB / Receive trio. - Zero state —
.ui-amount-card--zerowhen the wallet is disconnected. Visual recipe: amount and fiat render"0"/"$0"in--text-faint(≈ 40 % alpha — same ink as the.mutedprimitive), weight / size / tracking / line-height unchanged per the state changes shift ink, not weight rule (the slot's identity IS the weight, color is the state signal). The__footer-metaslot is hidden in one rule — no/ 1,000balance under the Send chip, no-7.27%price impact under the Receive chip. The widget's primary CTA already carries the connect signal; duplicating it inside the cards adds noise without adding information. The token chips stay at full ink — they're pickable affordances, not state-driven data. - Two-tier trigger — preserve the user's typed intent through a disconnect / reconnect round-trip.
renderEmptyState()applies--zeroto the Receive card always (a derived value can't be meaningful without a quote), and to the Send card only when its input is empty. When the user has typed an amount and then disconnects, the Send card keeps the value at full ink — it reads "this is the amount I want, just need to connect" rather than blowing away their intent. On reconnect,renderPair()removes--zerofrom both cards and the connected branch recomputes fiat + balance + receive against the preserved Send value. The Send card's footer-meta still disappears in the disconnected-with-typed sub-state becauserenderEmptyState()empties the balance text content (a flex slot with no content has zero width — no separate CSS rule needed). Composes with--ghost—--zerois a state, not a size. - Canonical UI card recipe —
--surface-card+--elev-1, 1 px transparent border at rest, softened accent rim on active. This is the recipe every UI card in the system should default to. The.panelshell sits at--surface-raised+--elev-3; cards living inside it lift one elevation tier above via--surface-card+--elev-1(dark: 16% panel → 24% card; light: 97% panel → page-pivot card lifted by shadow alone). Hover: 3 % accent tint on the card base + bump to--elev-2. Active /.is-active: 4 % accent fill (uniform across themes) +--elev-2+ flip the1px solid transparentrest border tocolor-mix(in oklch, var(--accent-primary) 64%, transparent)— the border lives INSIDE the box so it can't be clipped by a parent'soverflow: hidden(the rail usesoverflow-x: hiddenfor its slide animation, which would clip a box-shadow ring). No halo glow, no layout shift. Rim and fill were softened May 2026 (was full-saturation rim + 5 % fill) so the selected card stays clearly accented without dominating long lists. Reach for this recipe whenever you author a new UI card:.theme-card,.uc-card,.card--accent,.form-card, and the Paper-material family (.card,.stat-card,.action-card,.notification,.content-tile) all conform after the May 2026 ui-card unification. - Compose existing universals in the Send slots. Quickpick rows in
__trailingare.chip.chip-neutral× 4 (25 / 50 / 75 / MAX). Balance in__footer-metais.ui-amount-card__balance. Don't author quickpick-specific classes. (Route badges, gas / time chips, expand chevron all live on the dedicated route-detail card, not here.) - Price-impact severity ladder —
data-stateon.ui-amount-card__balancedrives the ink shift. The slot's signed-percent label (-7.27%) takes its colour fromdata-statestamped on the outer span byrenderPriceImpactSlot(). Thresholds — pinned in JS and mirrored by.ui-quote-list's detail-table so both surfaces flag risk at the same boundaries:|impact| ≤ 1%→ default — neutral footer ink.1% < |impact| ≤ 3%→data-state="warn"—var(--warn)ink.|impact| > 3%→data-state="error"—var(--danger)ink.
impactPctso the explainer matches what the user is reading on the label:impact > 0(label-X.XX%) gets a value-lost framing;impact < 0(label+X.XX%) gets a value-gained framing; no quote yet uses a neutral cost framing. The samedata-state="error"hook drives the Send balance slot's insufficient-state slide — see the next bullet. Weight / size / tracking stay constant across all three price-impact tiers per the state changes shift ink, not weight rule — colour is the state signal, never typography. - Insufficient balance is a slide-in, not a re-render — the Send balance slot keeps a stable height AND a stable width. When the user's typed amount exceeds the wallet balance, the slot doesn't get its
innerHTMLreplaced (the old behaviour, which collapsed the slot to a bare text node and made the height + width jump). Instead, the slot hosts two TWINS in the DOM at all times inside.ui-amount-card__balance-stack(a 1×1 grid sized to the wider twin viagrid-template-columns: max-content, both children right-aligned viajustify-items: end):.ui-amount-card__balance-amount— the default balance amount (2,645.15), painted in--text-secondary..ui-amount-card__balance-error— the static "Insufficient balance" message, painted in--danger, parked off-stage atopacity: 0; transform: translateX(60%).
data-state="error"on the outer slot slides the amount twin out to the left (translateX(-30%) + opacity 0) and the error twin in from the right (translateX(0) + opacity 1) over 280 msease— matches the canonical hover-transition contract (transformis the lift cue,opacitythe visibility cue). Because both twins share one grid cell, the slot's width is the same before and after — no horizontal jump. The trigger button stays in place; its two SVGs (.ui-amount-card__balance-icon--wallet— the resting Lucidewalletglyph — and.ui-amount-card__balance-icon--error— the Lucidecircle-alert“needs your attention” glyph) cross-fade in 200 msopacityonly. The tooltip flips from "Balance: 2,645.15 USDC ($2,645.15)" to shortfall framing ("Short 154.85 USDC · Balance: 2,645.15 USDC") so the operative number is whatever the user needs next.aria-invalid="true"still goes on the sibling input; the button'saria-labelflips to "Insufficient balance — see details"; thearia-hiddenattribute moves between the two twins so screen readers only see the visible one.prefers-reduced-motion: reducedrops the translates and keeps an opacity-only cross-fade (120 ms). The whole interaction is driven by a single attribute toggle inui-amount-card.js → renderPair()— the renderer no longer overwrites the slot DOM mid-transition. - Quickpicks use
.chip-shortcutswith the--reveal-on-hovermodifier. Markup: replace the static.ui-amount-card__trailing+ bare chips with a.chip-shortcuts.chip-shortcuts--reveal-on-hovercluster (still inside__header). The whole card surface triggers the staggered reveal — not just the header strip — via the broadened selector inswap.css;:focus-withinkeeps keyboard tab-in working. Gate the cluster on wallet-connected AND balance > 0 via the nativehiddenattribute (the chips are a percentage shortcut — with no balance to take a percentage of they'd be dead buttons). The Receive card has no quickpicks; the chips are a Send-side affordance only. - Token chip, not From/To button. The legacy
.ui-token-selectthree-cell row is retired in this layout — the token picker opens from a.chip-avatarbutton on the right of__body. - Configurability lives in the
--amount-card-*custom properties, not in extra modifier classes. Reach for a per-instance override (inlinestyleor a context selector) to tune background, padding, amount size, etc.; reach for the canonical modifier (--ghost) for the flush-embedded use case. Tokens before classes; classes before forks. - Tokens, not hex. Background defaults to
--surface-card; text colours flow through--text-primary/--text-secondary. The Theme Composer drives the widget surface with zero adapter code. - Loading state paints skeletons in the derived slots only; user-typed Send amount is preserved. On
persona-changeandwallet-changeevents (real-world balance-fetch moments),ui-amount-card.js → enterLoadingState()paints.skeleton.--textatoms in Send fiat + Send balance + Receive amount + Receive fiat slots, setsdata-state="loading"+aria-busy="true"on each card, and schedulesrenderPair()to land at+600 ms. The Send<input>value, the picked token chips, and the static label / FAB chrome stay untouched — per the Preserve user-typed intent through dependency loss rule, only derived values fade through loading.balance-changeevents skip the loading phase (no fetch — the user just hit "set balance" or "reset"); the new number lands instantly. Skipped when disconnected (no wallet to fetch for; the zero state is definitive).
Destination wallet card .ui-destination-wallet-card
Chosen send-to-wallet destination on the form — wallet avatar + ENS / address + clear ×. Replaces the footer wallet trigger.
Live, the card is display:none until .ui-card[data-destination-set] (a confirmed destination) — or, in Private mode, body[data-widget-mode="private"][data-playground-wallet-connected] (the receiving wallet is mandatory there). The demos below force display:flex so each state renders. The zero-state mirrors .ui-amount-card__body — an inline address input (left) + a trailing action (right) — with a “Required” warn chip beside the label.
Zero state · empty — “Select” opens the picker
Zero state · address entered — clear × replaces “Select”
ENS destination · resolved name over the short address
Pasted address · no ENS — short address leads, “External wallet” below
<!-- In the form stack, above the action footer. Hidden until -->
<!-- .ui-card[data-destination-set] is set by initSendWallet. -->
<div class="ui-destination-wallet-card" data-destination-card>
<!-- Label row. The "Required" chip shows only under [data-required]
(required flows like Private Swap); omit it in optional flows. -->
<div class="ui-destination-wallet-card__header">
<span class="ui-destination-wallet-card__label">Receiving wallet</span>
<span class="chip chip-warn ui-destination-wallet-card__required">
<svg class="chip-icon" viewBox="0 0 24 24" aria-hidden="true">…</svg>
Required
</span>
</div>
<!-- Zero state (Private + connected + no address). [data-required] on the
card shows this entry row and hides __body. Mirrors .ui-amount-card__body:
inline address input (left) + trailing action (right). Empty → "Select"
pill (opens the picker); once the input has content the pill swaps for the
clear × via :has(:not(:placeholder-shown)). Enter / paste resolves the
address and routes through the confirm sheet. -->
<div class="ui-destination-wallet-card__entry">
<input type="text" class="ui-destination-wallet-card__input" data-destination-input
placeholder="Enter address" aria-label="Receiving wallet address">
<button type="button" class="ui-destination-wallet-card__enter chip-avatar chip-avatar-neutral"
data-action="open-send-wallet">
<span class="avatar-tandem avatar-tandem--placeholder" aria-hidden="true">…</span>
Select
</button>
<!-- Swaps in once the input has content (placeholder hidden). -->
<button type="button" class="ui-destination-wallet-card__input-clear btn-neutral btn-icon btn-sm btn-circle"
data-destination-input-clear aria-label="Clear address">…</button>
</div>
<!-- Set state (any destination confirmed) — identity row + clear ×. -->
<div class="ui-destination-wallet-card__body">
<!-- Identity = re-open the picker. Root is a <div>-free button -->
<button type="button" class="ui-destination-wallet-card__identity"
data-action="open-send-wallet" aria-label="Edit destination wallet">
<span class="avatar-tandem" aria-hidden="true">
<img class="avatar avatar--circle" src="…/wallets/metamask.svg" alt="">
<img class="avatar-badge avatar-badge--br" src="…/chains/ethereum.svg" alt="">
</span>
<span class="ui-destination-wallet-card__text">
<span class="ui-destination-wallet-card__name">vilenr.eth</span>
<span class="ui-destination-wallet-card__addr">0x2ee6…b49C</span>
</span>
</button>
<button type="button" class="ui-destination-wallet-card__clear btn-neutral btn-icon btn-sm btn-circle"
data-destination-clear aria-label="Clear destination wallet">…</button>
</div>
</div>
| Property | Token / value | Notes |
|---|---|---|
| Surface | --dest-card-bg: var(--surface-card) | Local-slot recipe mirroring .ui-amount-card — one tier above the .panel shell; overridable per-instance. |
| Radius | --dest-card-radius: var(--card-radius) | 16 px default; tracks the playground corner control. |
| Elevation | --dest-card-shadow: var(--elev-1) | box-shadow — the canonical method (this card is not masked). The masked .ui-amount-pair above it uses the value-matched --elev-1-filter twin, so both render the same shadow. |
| Padding | --dest-card-padding: var(--space-16) | Matches .ui-amount-card. |
| Gap (label → body) | --dest-card-gap: var(--space-12) | Matches --amount-card-gap so the family rhythm is consistent. |
| Label type | .ui-card-label | Composes the shared .ui-card-label class — the widget's single-source card-title label (--text-sm / --fw-semibold / --text-primary / --tracking-normal / 20 px line-box) — so "Receiving wallet" reads identically to "Send" / "Receive" / the limit + review labels. The exemplar: new widget cards apply this class to their label span. |
| Name type | --text-body / --fw-semibold | --text-primary; truncates with ellipsis. |
| Address type | --text-sm | --text-muted; truncates with ellipsis. |
| Avatar | .avatar-tandem | Standard .avatar-tandem — 40 px wallet-provider logo (.avatar--circle; MetaMask by default until provider selection ships) + the destination chain in .avatar-badge--br. |
| Zero-state entry | .ui-destination-wallet-card__entry | Private mode only. Flex row mirroring .ui-amount-card__body (space-between, 40 px min-height): inline address input (left) + trailing action (right). Shown under [data-required]; the set-state __body is hidden then. |
| Zero-state input | .ui-destination-wallet-card__input | Bare <input> (the card is the chrome), --text-body / --text-primary, placeholder “Enter address” in --field-placeholder. Enter / paste resolves the address or domain and routes through the confirm sheet (swap.js → initSendWallet). |
| Zero-state action | .chip-avatar ↔ .ui-destination-wallet-card__input-clear | Empty input shows the token-picker “Select” pill (placeholder .avatar-tandem, opens the send-wallet flow); once the input has content it swaps for the clear × (.btn-icon.btn-sm.btn-circle), via .ui-destination-wallet-card__entry:has(.ui-destination-wallet-card__input:not(:placeholder-shown)) — no JS state flag. |
| Required marker | .chip.chip-warn + .chip-icon | Mandatory-destination flows (Private Swap). Top-right of the __header row, warn-toned with the triangle-alert glyph. Revealed by [data-required] (so it appears in the required zero-state and clears once an address is set); omit the element entirely in optional flows. |
| Element | Role |
|---|---|
.ui-destination-wallet-card | Card root (a <div> — hosts two real <button> children, so it can't itself be a button; see Rules). |
__header | Label row — flex flex-start + 8 px gap: __label with the optional __required marker directly beside it. |
__label | “Receiving wallet” eyebrow over the identity row. |
__required | .chip.chip-warn + .chip-icon (triangle-alert) reading “Required”. Beside the label, for mandatory-destination flows (Private Swap). Shown only under [data-required]; omitted from the markup when the destination is optional. |
__entry | Zero-state row (Private mode) — flex space-between mirroring .ui-amount-card__body: __input (fills) + the trailing action. Shown only under [data-required]; __body is hidden then. |
__input | Bare <input> — type / paste a 0x address or ENS domain. Resolves on Enter / paste through the confirm sheet. Placeholder “Enter address”. |
__enter | <button> — the “Select” pill. A .chip-avatar.chip-avatar-neutral (placeholder tandem + “Select”) opening the picker (data-action="open-send-wallet"). Shown while the input is empty; hidden once it has content. |
__input-clear | <button> — clear × (.btn-icon.btn-sm.btn-circle) that empties the typed/pasted entry. Replaces __enter whenever the input has content (:has(:not(:placeholder-shown))). Distinct from __clear, which unsets a confirmed address. |
__body | Flex row — identity (fills) + clear (square). The set-state view; hidden under [data-required]. |
__identity | <button> — re-opens the picker (data-action="open-send-wallet"). Neutral hover, full UA reset + font-family. |
.avatar-tandem | Standard avatar tandem — the wallet-provider logo (MetaMask default) with the destination chain in the bottom-right badge (.avatar-badge--br). |
__text · __name · __addr | ENS / label over the short address (or short address over “External wallet” when there's no ENS). |
__clear | <button> — clears the destination (data-destination-clear); the footer wallet trigger reappears. |
- Visibility gates on
.ui-card[data-destination-set], which ALSO hides the footer wallet trigger. The card and the[data-send-wallet-trigger]icon button are mutually exclusive — the card IS the affordance once a destination is set, so showing both would be redundant. Both rules key off the one attribute. Exception — Private mode: the card is also shown bybody[data-widget-mode="private"][data-playground-wallet-connected]— always-on once connected, even with no address set, because a receiving wallet is mandatory in Private. - Excluded entirely in Limit mode. A limit order fills to the connected wallet by definition — routing the output to a different address doesn't apply — so Limit has no receiving-wallet concept at all.
body[data-widget-mode="limit"]hides BOTH the footer trigger and the card (even if a destination was set in another mode before switching), removing the flow: the send-wallet screen is only reachable through those two entry points. This is the inverse of Private (mandatory) — Limit is the one mode where the flow is suppressed rather than offered or required. Swap / Bridge / Gas keep it optional. See the per-mode Receiving wallet row in the Widget modes matrix. - Private mode adds a required zero-state — an inline address input + “Select”. Shown empty in Private (connected, no address yet), the card wears
[data-required]and CSS swaps the set-state__body(identity + clear ×) for.ui-destination-wallet-card__entry— a row that mirrors.ui-amount-card__body: an inline__input(left, placeholder “Enter address”) + a trailing action (right). The user can type or paste an address / ENS domain directly — Enter or paste resolves it and routes through the confirm sheet (swap.js → initSendWallet) — OR click “Select” (the.chip-avatar.chip-avatar-neutralpill, reusing the token-picker affordance) to open the full send-wallet picker.swap.js → syncPrivateGate()toggles[data-required]and keeps the Review CTA disabled until an address is set. In every other mode the card stays optional and the zero-state never appears. - The trailing action swaps Select ↔ clear × on content — auto-detected, no JS flag. While the input is empty the “Select” pill shows; once it has typed/pasted content the pill swaps for a clear × (the same
.btn-iconaffordance the set-state shows for a chosen address), driven purely by.ui-destination-wallet-card__entry:has(.ui-destination-wallet-card__input:not(:placeholder-shown))(per CLAUDE.md → auto-detect content-driven state via:has()). The × empties the input (restoring “Select”) — distinct from__clear, which unsets a confirmed address. - The “Required” marker is a warn chip beside the label — tonal + iconographic, not colour alone. When the destination is mandatory (Private Swap), the
__headercarries a.chip.chip-warn+.chip-icon(triangle-alert) reading “Required”, sitting directly beside the label (flex-start, 8 px gap) so it reads as part of the label rather than floating at the far edge. The warn surface AND the triangle glyph both carry the meaning, so it survives colour-blindness (same reasoning as the mandatory alert spot-icon rule). It's revealed by the same[data-required]flag as the entry row, so it appears in the required zero-state and clears the moment an address is set. For optional-destination flows, omit the chip element entirely. - Built on the canonical UI card recipe — box-shadow elevation, like every other card. The card mirrors
.ui-amount-card's local-slot shape (--dest-card-bg / radius / padding / gap / shadowdefaulting to system tokens) so it reads as one family; gap matches--amount-card-gap(12 px) and elevation isbox-shadow: var(--elev-1)— the canonical method (this card is NOT masked, so it uses box-shadow like the other 60-odd cards). The masked.ui-amount-pairabove it must use the--elev-1-filtertwin (box-shadow is clipped by its FAB-cutout mask); that token is value-matched to--elev-1, so the box-shadow card and the filter pair render the same shadow. See Elevation. - The destination persists through wallet disconnect. Per the Preserve user-typed intent through dependency loss rule, a chosen recipient is the user's intent, not derived data — clearing it is the user's job (the
×), not a side effect of disconnecting. - Card root is a
<div>, not a<button>. It hosts two real buttons (identity + clear); a<button>root would trigger the HTML parser's button-in-button auto-close. The two buttons are siblings, never nested. - Identity carries
font-family: var(--font-sans). A<button>resets to the UA font; without the explicit declaration the name/address would render in Arial. - The avatar is the standard
.avatar-tandem— wallet-provider logo + chain badge, not a persona portrait. The main avatar shows which wallet the address lives in (MetaMask by default until provider selection ships); the corner badge (.avatar-badge--br) marks the chain where funds arrive. A send-to-wallet destination is an external address, so it reads as “a wallet”, never a persona.
- Markup —
playground.html→ the form-stack block (grepdata-destination-card) + the[data-state-content="send-wallet"]picker + the two.ui-wallet-confirmmodals. - CSS —
swap.css→ theSEND-TO-WALLETsection (grep.ui-destination-wallet-card). - Behavior —
swap.js→initSendWallet()+initSendWalletModals()(ENS resolver, address book, confirm / bookmark flow, destination apply / clear).
Quick settings .ui-quick-settings
Pre-CTA summary cluster of deep-link rows. Mirrors live values from the settings menu so users review intent before acting.
Each row is a .setting-row--sm .setting-row--drill with a data-quick-link pointing at a sub-screen of the canonical .ui-settings flow — click navigates the user INTO that setting's full screen, where the value can be changed. The right-column value is mirrored live from the underlying setting (data-settings-value), so the cluster reads as a summary the user can both review AND act on without opening the full menu first. The cluster is mode-gated, and Swap and Bridge carry DIFFERENT compositions — two sibling blocks keyed by data-quick-settings-mode, only the one matching the active mode shows. Swap runs 3 rows (route type / exchanges / slippage); Bridge runs 4 (route priority / exchanges / bridges / slippage). Limit is gated out entirely. The per-tab demos below force visibility via inline style on each wrapper (production gates on body[data-layout="dual"][data-widget-mode="swap"] and …[data-widget-mode="bridge"] respectively).
Compositions per tab
Swap tab · Route type · Exchanges · Max. slippage
Bridge tab · Route priority · Exchanges · Bridges · Max. slippage
Bridge runs its OWN 4-row block (data-quick-settings-mode="bridge") — Route priority (not Route type) plus a Bridges row, the two axes a cross-chain route exposes that a same-chain swap doesn't. Same gate, separate markup; only the block matching the active mode shows.
Limit tab · no cluster
In Limit mode the cluster is gated out — the .ui-orders-panel and the .ui-limit-price-card occupy the pre-CTA space, and Limit's price + expiry axes don't share the route‑type / exchanges / slippage vocabulary. The rows stay in the DOM (display: none); the mode selector flips visibility.
Icon visibility · orthogonal toggle
Icons-on opt-in · data-icons="on"
Default is icons-OFF (the resting state shown per tab above). Each row keeps its .setting-row__icon in the DOM so the toggle is pure CSS — the icon column collapses by default and reclaims its 20 px slot when data-icons="on" (or body[data-quick-settings-icons="on"]) is set. The Workshop "Show icons" card that drove the body-level gate was cleared June 2026 (icons-off is the settled default); both gates stay live CSS capabilities — per-instance consumers reach for the scoped attribute.
<!-- Sits inside .ui-form-stack, between the form body and the action footer.
Each row deep-links to a setting via data-quick-link; the JS handler
opens that setting's sub-screen of the .ui-settings inner state machine. -->
<!-- Swap block: route-type, exchanges, slippage (3 rows) -->
<div class="ui-quick-settings" data-quick-settings-mode="swap">
<button type="button" class="setting-row setting-row--sm setting-row--drill"
data-quick-link="route-type">
<span class="setting-row__icon"><svg>…</svg></span>
<span class="setting-row__label">Route type</span>
<span class="setting-row__value setting-row__value--muted"
data-settings-value="route-type">All</span>
</button>
<!-- + exchanges, slippage -->
</div>
<!-- Bridge block: route-priority, exchanges, bridges, slippage (4 rows).
Sibling of the Swap block; the CSS gate shows only the active mode's. -->
<div class="ui-quick-settings" data-quick-settings-mode="bridge">…</div>
<!-- Icons-on opt-in: per-instance OR global. Either gate flips the same
CSS rule; pick by scope. -->
<div class="ui-quick-settings" data-icons="on">…</div> <!-- per-instance -->
<body data-quick-settings-icons="on">…</body> <!-- page-wide -->
| Property | Value | Notes |
|---|---|---|
| display | flex (column) | Children stack; .ui-form-stack's own gap doesn't apply because .ui-quick-settings is the container. |
| gap | var(--space-8) | Sm-row inter-row rhythm. Pairs the canonical .setting-row--sm 40 px height tier. |
| margin-top | var(--space-16) | Separates the cluster from whatever sits above in .ui-form-stack (typically the inline quote preview or limit alerts) without using a divider. |
| Gate (visibility) | body[data-layout="dual"][data-widget-mode="swap"] …[mode="swap"] body[data-layout="dual"][data-widget-mode="bridge"] …[mode="bridge"] | Hidden outside the spacious Dual layout. Each mode's block shows only in its own mode. Limit + Gas are gated out entirely — their axes don't share either vocabulary. |
| [data-quick-settings-mode] | swap | bridge | Per-mode block selector. Swap's block carries 3 rows (route type / exchanges / slippage); Bridge's carries 4 (route priority / exchanges / bridges / slippage). Two sibling blocks in the DOM; the CSS shows only the active mode's. |
| Icons gate (page-wide) | body[data-quick-settings-icons="on"] | Page-wide opt-in. No live toggle since the Workshop config cards were cleared (June 2026) — set the attribute directly to flip icons on. |
| Icons gate (per-instance) | .ui-quick-settings[data-icons="on"] | Instance-level opt-in. Future widget configs that want icons by default reach for the scoped attribute without touching body. Same CSS rule fires on either gate. |
| Grid (default) | 1fr auto | Icon column collapses; label slot reclaims the reserved 20 px. Icons stay in the DOM (display: none), so toggling is pure CSS — no markup mutation. |
| Grid (icons-on) | var(--space-20) 1fr auto | Restores the canonical .setting-row--sm 3-col grid. |
| Class / attribute | Role | Notes |
|---|---|---|
| .ui-quick-settings | Wrapper | Flex column inside .ui-form-stack. Owns the inter-row gap + top margin; the rows own their own surface chrome. |
| .setting-row--sm | Row tier | Sm tier pairs the 40 px height with body-sm typography — tight enough to read 4 rows + the CTA inside the spacious Dual layout's lower half without scrolling. |
| .setting-row--drill | Affordance | Adds the trailing chevron + drill semantics — click navigates INTO the named setting's sub-screen, not toggles an inline control. |
| [data-quick-link] | Drill target | Names the sub-screen of .ui-settings the row opens. Same vocabulary as the full settings menu's drill rows, so the deep-link is a 1:1 navigational shortcut. |
| .setting-row__icon | Leading glyph | Hidden by default in this surface. Kept in the DOM so the toggle is pure CSS. Each consumer picks the icon that names the axis (route type uses icons/split.svg, slippage, etc.). |
| .setting-row__label | Axis name | The setting's name. Stays sm and semibold per the row's tier. |
| .setting-row__value (--muted) | Current value | Mirrored live from the corresponding setting in .ui-settings. The muted variant keeps the cluster reading as "summary," not "active control." |
| [data-icons="on"] | Instance opt-in | Optional. When present on the wrapper, re-shows the icon column for this instance only. Composes with the body-level gate — either flips the same CSS rule. |
- Reach for this composition when a flow's pre-action summary benefits from being both reviewable AND act-on-able. The cluster names the axes the user cares about most (route type, exchanges, slippage — whichever set fits the flow) and lets them step INTO any axis to change it without losing the form state behind. Don't reach for it when the summary is just descriptive (use a definition list); reach for it when each row is a navigable drill.
- Route type is a choice setting, not a toggle. Its sub-screen offers All (every route), Simulated only, and Private — a radio set wired through
CHOICE_VALUESinswap.js → initQuickSettings, so picking one checkmarks it and mirrors the label to everydata-settings-value="route-type"surface (the quick row AND the full-menu main-list row). "Simulated only" is a feature in progress — it's selectable (the radio checkmarks) but not yet bound to routing, exactly as the other route-type choices are mock-selectable in the playground. Distinct from Route priority (Best Return / Fastest / Cheapest) — which is Bridge's first quick row, where Swap shows Route type instead. The two modes carry separatedata-quick-settings-modeblocks. - Anchor above the action CTA, never below. The cluster's job is "the last thing the user reviews before clicking the final button," so it sits as the bottom-most content block above
.action-barinside.ui-form-stack. Above-the-fold placement (under the form header) loses the pre-action context. - Cap at 4 rows — the scan budget. Five-plus rows lose the at-a-glance read and start competing with the form body for visual weight. Swap runs 3 (route type, exchanges, slippage); Bridge runs the full 4 (route priority, exchanges, bridges, slippage). If a flow needs more than 4, the summary becomes a separate drill (a full settings menu), not a quick cluster.
- Each row's value mirrors the live setting, never a stale snapshot. Use
data-settings-valuebound to the same source the full settings menu reads from. A value that drifts here erodes the trust the cluster's whole purpose depends on. - Icons OFF on quick rows; icon + label on the full Settings menu — a deliberate tier split. Quick rows are the compact
.setting-row--smtier: no leading icon (the labels alone are unambiguous in this short list; glyphs added noise without disambiguating value). The full Settings menu (.ui-settings) uses the larger.setting-row--lgtier where icon + label aids recognition in a longer list — icons stay ON there. Rule of thumb:--smsummary clusters drop the icon;--lgfull menus keep it. The quick-row icon stays in the DOM so the toggle is pure CSS — flipbody[data-quick-settings-icons="on"](page-wide) OR.ui-quick-settings[data-icons="on"](per-instance). - Gate by widget mode, not by layout alone — and give each mode its own block. "Different mode = different cluster" is literal here: Swap and Bridge each ship a sibling
.ui-quick-settings[data-quick-settings-mode]block, gatedbody[data-layout="dual"][data-widget-mode="…"], and only the active mode's shows. Limit + Gas get no block (their price/expiry, native-gas axes don't share the vocabulary). When you reuse the paradigm for a future widget config, author its own mode block rather than overloading another mode's. - The cluster is a navigation primitive, not a selection control. Rows carry no
.is-activestate. Per the row-recipe rule (CLAUDE.md§ List-row state recipe), navigation rows skip the canonical card's transparent border slot and useborder: 0— that's what.setting-rowships with. Don't add selection semantics to this composition; if a value needs picking inline, the row's drill destination is where that happens. - Reusing the paradigm? Keep the contract. A future widget config that wants its own quick-summary cluster (a Gas pre-CTA gas-source summary, a Limit pre-CTA price + expiry summary) follows the same shape:
.ui-quick-settingswrapper,.setting-row--sm --drillchildren,data-quick-linkper row pointing at a registered sub-screen of.ui-settings. Don't fork the wrapper name per consumer — the composition is the primitive.
CSS: swap.css § Quick settings — Dual + Bridge/Swap only — search .ui-quick-settings.
Markup: playground.html — search class="ui-quick-settings"; the rows (route type, exchanges, slippage) are authored inline inside .ui-form-stack. The matching route-type sub-screen + main-list row live in the .ui-settings block.
JS: swap.js — initQuickSettings(card) wires each [data-quick-link] click to the matching .ui-settings sub-screen, and registers choice settings in CHOICE_VALUES (route-type = All / Simulated only / Private) so picks mirror to every data-settings-value surface. The Workshop "Show icons" toggle IIFE was retired June 2026 when the Workshop config cards were cleared — the icons gate is pure CSS.
Catalog companion: .ui-settings for the inner state machine the deep-links navigate INTO.
Rule (canonical): CLAUDE.md § Quick settings composition. Spec rendering: design/components/swap.md → Quick settings.
Settings flow .ui-settings
Scoped inner state machine. Lets one outer .ui-card state host a main list + N sub-screens via data-settings-screen.
Both demos host the same .ui-settings wrapper at 368 px (the swap widget's native width). The wrapper's data-settings-screen attribute is the only difference between the two cells — flip it and the visible [data-settings-content] swaps without re-mounting any DOM.
Main screen · data-settings-screen="main"
Settings
Sub-screen · data-settings-screen="appearance"
Appearance
<!-- Outer state machine adds ONE new value: "settings" -->
<article class="ui-card" data-state="settings">
<div data-state-content="settings">
<div class="ui-settings" data-settings-screen="main">
<!-- Main list -->
<div data-settings-content="main">
<header class="screen-header">
<button data-action="settings-back">…</button>
<h2 class="screen-header__title">Settings</h2>
</header>
<div class="setting-stack">
<button class="setting-row setting-row--drill" data-settings-drill="appearance">…</button>
<!-- N drill rows -->
</div>
</div>
<!-- One sibling per sub-screen -->
<div data-settings-content="appearance">
<header class="screen-header">
<button data-action="settings-sub-back">…</button>
<h2 class="screen-header__title">Appearance</h2>
</header>
<div class="setting-stack">…</div>
</div>
<!-- … more sub-screens -->
</div>
</div>
</article>
| Attribute / role | Value | Notes |
|---|---|---|
| .ui-card[data-state] | "settings" | Outer state-machine value. The card's existing state vocabulary (form / picker / chain-picker / review / tx-progress) gains one new value — everything else stays untouched. |
| .ui-settings[data-settings-screen] | "main" · "<sub-key>" | Inner state-machine attribute. Each sub-screen registers its own key; the wrapper flips between them via JS. |
| [data-settings-content] | "main" · "<sub-key>" | Sibling content blocks under .ui-settings. CSS shows the one whose key matches the wrapper's data-settings-screen. |
| [data-action="settings-back"] | — | Main-screen back button. Restores the OUTER previous state via card.dataset.settingsPrevState. |
| [data-action="settings-sub-back"] | — | Sub-screen back button. Sets data-settings-screen="main". |
| [data-settings-drill] | "<sub-key>" | Main-screen drill row. Sets data-settings-screen to the named sub-screen. |
| Class / wrapper | Role | Notes |
|---|---|---|
| .ui-card[data-state="settings"] | Outer-state shell | Existing widget shell. Adding "settings" reuses the same shell + transition + receive-panel-hide rules as the other states. |
| [data-state-content="settings"] | State-content slot | Sibling of [data-state-content="form"] / "picker" / etc. Becomes visible when the outer state matches. |
| .ui-settings | Inner state-machine wrapper | Flex column, flex: 1 1 auto, min-height: 0. Hosts the inner sub-screen siblings; provides the scroll envelope for the active screen. |
| [data-settings-content="main"] | Main-screen content | Contains the section's .screen-header + .setting-stack. The stack is the scroller (bleed margins put the scrollbar in the card gutter). |
| [data-settings-content="<sub-key>"] | Sub-screen content | One per drill destination. Same shape as the main screen: .screen-header + a body (.setting-stack, a list, a form — whatever the sub-flow needs). |
| .screen-header__back | Back button | Wired by JS: on the MAIN screen, restores the outer prev-state; on a SUB screen, returns to "main". |
- Reach for this when a single outer state hosts a navigable sub-flow. Settings is the canonical use; future candidates include a multi-step review screen, a wallet-connect wizard, an order-history drill-down, or any sub-domain that benefits from a main ↔ sub navigation. Don't reach for it for a single static screen — just add an outer state.
- One scoped attribute per inner state machine. Use
data-<flow>-screen+data-<flow>-contentfor each flow (data-settings-screen/data-settings-content,data-review-screen/data-review-content, etc.). The scoping keeps inner state machines independent — the next flow doesn't fight settings for the same attribute. - Main-screen back restores the outer prev-state, not "form". Stamp
card.dataset.<flow>PrevStateat open time; read it on back; delete it on use. This lets users open settings from form OR from a deep widget state and return to where they came from, not to a hardcoded default. - Each
[data-settings-content]sibling is a flex column. Its own.screen-headerstays intrinsic at the top; the body owns the scroll. Mirrors the.ui-form-stackclip-and-host pattern from the form state — same architecture, scoped inward one level. - Visibility lives in CSS, not JS.
.ui-settings > [data-settings-content] { display: none }+ a per-key rule for the active screen. JS only mutates the attribute; the cascade does the rest. Adding a new sub-screen = add one selector to the rule block + author one sibling div, no JS edit needed beyond the drill/back wires. - Don't add a new outer state per sub-screen. The outer state-machine list (form / picker / settings / review / …) is the widget's top-level concept space; bloating it with
settings-appearance/settings-language/settings-gasconflates "widget mode" with "sub-flow position." Keep the outer list short; use the inner machine for what sits inside one outer state. - The wrapper provides the scroll envelope.
.ui-settingsisflex: 1 1 auto+min-height: 0so the inner.setting-stackcan scroll inside long sub-screens (Bridges / Exchanges) without growing the card. Don't override this on the wrapper.
CSS: swap.css § .ui-settings inner state machine — search for .ui-settings > [data-settings-content].
Markup: playground.html — search for data-state-content="settings".
JS: swap.js — initSettings(card) (outer transition + prev-state restore) and initSettingsSubScreens(card) (drill + sub-back wires).
Rule: CLAUDE.md § Git workflow — commit autonomy shipped this primitive in e4c9d2e (May 2026).
Token picker — full screen .ui-token-picker
Full picker screen composing .screen-header, .card-avatar-grid, .search, and stacked .token-list-module blocks inside .ui-card[data-state="picker"].
Picker screen at the canonical 416 px .ui-card width, in the picker state. Chain shortcuts sit above the search; the body stacks one or more .token-list-module blocks — here, Popular. Click any chain tile to filter the list; click the active tile to clear; click +60 to drill into the full .card-avatar--more chain picker.
Default · no chain filter active
Send token
Popular
-
ETHEthereum
-
USDCEthereum
-
USDTEthereum
-
DAIEthereum
-
WBTCEthereum
-
ARBArbitrum
-
OPOP Mainnet
Cold-state preview: All-networks tile is active (no chain filter), the full Popular module shows tokens across every chain, the body scrolls inside the panel's fixed height.
Arbitrum filter active · list narrows to one chain
Send token
Popular
-
ARBArbitrum
-
ETHArbitrum
-
USDCArbitrum
-
USDTArbitrum
-
DAIArbitrum
Filtered state: Arbitrum tile carries .is-active with the 1 px accent rim, the All-networks tile drops back to rest, the body re-renders to Arbitrum-only tokens.
<!-- Full token-picker screen -->
<article class="panel ui-card" data-state="picker" data-active-picker="from">
<div data-state-content="picker">
<header class="screen-header">
<button class="screen-header__back btn-icon btn-borderless btn-circle" aria-label="Back">…</button>
<h2 class="screen-header__title">Send token</h2>
<div class="screen-header__trailing"></div>
</header>
<div class="ui-token-picker" data-picker="from">
<header class="ui-token-picker__head">
<!-- Chain shortcuts row — uses #card-avatar -->
<div class="card-avatar-grid">
<button class="card-avatar card-avatar--cluster is-active">…</button>
<button class="card-avatar" data-chain-key="ethereum">…</button>
<!-- first N chains in canonical chains.json order -->
<button class="card-avatar card-avatar--more">+60</button>
</div>
<!-- Canonical search component — see #search -->
<div class="search">
<span class="search-icon">…</span>
<input type="search" class="search-input">
</div>
</header>
<div class="ui-token-picker__body">
<!-- Stack 1+ token-list-module blocks — see #token-list-module -->
<section class="token-list-module" data-list-variant="portfolio" hidden>…</section>
<section class="token-list-module" data-list-variant="popular">…</section>
</div>
</div>
</div>
</article>
Five sub-primitives compose the screen. Each carries its own catalog card — this card documents the wiring, not the per-primitive contract.
| Region | Class | Catalog | Role |
|---|---|---|---|
| Shell | .ui-card[data-state="picker"] | #ui-card | Widget canvas. Picker is one of five states (form / picker / chain-picker / quotes / review / activity / tx-details). 416 px width via --w-7. |
| Header | .screen-header | #screen-header | Back arrow + title + trailing slot. Title swaps via data-title-from / data-title-to based on which chip opened the picker. |
| Picker host | .ui-token-picker | This card | Flex column: pinned head + scrolling body. Owns no scroll itself — defers to the body so chain shortcuts and search stay sticky. |
| Chain shortcuts | .card-avatar-grid | #card-avatar | 2-row grid: All-networks tile + first 8 chains + +N overflow. Active tile carries .is-active. Hidden on Wide variant (sidebar takes over). |
| Search | .search | #search | Canonical search component. Filters across every visible .token-list-module on input. |
| Body | .ui-token-picker__body | This card | Owns the vertical scroll. Hosts 1–3 stacked modules: Featured (curated promo, opt-in), Portfolio (wallet-conditional), Popular (always shown). |
| Token modules | .token-list-module | #token-list-module | One per list section. Each carries its own head + .ui-token-list. Stack them inside the body for the canonical Portfolio + Popular split. |
- The picker is the default landing for every chip click. Tapping a
[data-role="from"]or[data-role="to"]chip flips the card todata-state="picker"directly — the user lands on this screen with shortcuts visible. The previous chain-first drill (chip → chain picker → token picker) was retired May 2026. The single exception is Bridge/Gas to-side, where the destination is chain-only: those chip clicks route todata-state="chain-picker"as a terminal commit. - Click any shortcut to filter; click the active one to clear. Filter state lives on the stage as
data-chain-filter; toggling repaints both the shortcut grid (active rim) and every visible.token-list-module(rows narrowed). The All-networks tile (.card-avatar--cluster) is a dedicated "clear filter" affordance so users don't have to click an active chain twice. Filter survives back-out — clicking the picker's back arrow returns to the form with the filter intact, so reopening the picker resumes where the user left off. +Noverflow opens the full chain picker as a re-entry drill. Click.card-avatar--more→data-state="chain-picker"withdata-chain-picker-mode="drill". The chain row commits the filter and returns to the picker (drill flag is cleared on commit). Backing out of the drill without picking returns to the picker with the existing filter preserved.- The shortcut grid is variant-gated to Compact / Dual. The Adaptive layout variant runs a side-by-side
.split-panechain-filter rail instead — the grid stays in the DOM but its computed display flips tonone. Single-panel variants get the grid because the panel can't host the sidebar. - Stack token modules vertically inside the body. One module per logical section (Featured / Portfolio / Popular). Each module defers its scroll to the picker — one continuous scroll across every module, not N nested wells. Portfolio renders only when a wallet is connected; the wrapper carries
hiddenotherwise. Featured is opt-in (currently disabled markup-preserved for restore). - Don't render the chain-picker inline. The chain-picker is a sibling state (
data-state="chain-picker"), not a sub-panel. Composing them in one screen would defeat the single-source-of-truth on which sub-screen the user is currently in. - Limit-mode constraints. When the card is in Limit mode AND the To-side picker is the one open, the chain rail collapses to ONLY the From-side chain (the constraint renders as visible state). Sell-side is unaffected. Wired in
picker-render.js → getLockedChainKey().
CSS — swap.css → .ui-token-picker (flex column · pinned head · scrolling body · L/R padding compensation for row-hover bleed).
State machine — swap.js → initCard() wires the chip click → setCardState(card, 'picker') transition. The [data-chain-shortcuts] click delegation lives there too; initChainPicker() handles the drill commit + cancel paths.
List rendering — picker-render.js → renderChainShortcuts(stage) rebuilds the grid against chains.json; renderAll() repaints the token-list modules against the active filter; setChainFilter(stage, key) writes data-chain-filter and re-renders.
Live consumer — playground.html → [data-state-content="picker"] block. The whole screen swaps in via setCardState(card, 'picker'); the form state ([data-state-content="form"]) sits as a sibling for back-button return.
Split pane .split-pane
Auxiliary card that slides in beside a primary container. Hosts the swap picker's chain filter and future route detail panels.
<aside class="split-pane is-open">
<header class="split-pane__header">
<h3 class="split-pane__title">Networks</h3>
</header>
<div class="split-pane__body">…</div>
</aside>
- Adaptive-layout only. Sits as a sibling next to a primary container. Compact-layout treatment (drawer/bottom-sheet) is deferred until phase 8+.
- Hidden by default. Toggle with
.is-open, or use a parent state class. The:has()selector inside the host context can also reveal it (e.g.,.widget-stage:has(.ui-card[data-state="picker"])). - 320 px default width. Override via inline style or a modifier class for context-specific widths.
- Two scopes. Tagged
ui marketing— primarily product, but reusable on a marketing page that needs a sibling card pattern.
Chain selection panel .split-pane--chain-filter
The token picker's network filter in two compositions: a compact mini sidebar (Adaptive) and a balanced regular panel (Dual).
One node (.split-pane--chain-filter), two compositions, switched by layout via picker-render.js → applyChainPanelComposition(). Both filter the token list identically — click a network to narrow, click All networks to clear. Only the scale (search tier · row tier · avatar size) and the header differ. (Rows are normally JS-rendered; these demos are static at each composition's scale.)
Mini — Adaptive sidebar · .split-pane--chain-filter
320 px (--w-4) · .search--sm (32 px) · default-tier rows w/ .avatar--sm (32 px) · no header
Regular — Dual receive column · .split-pane--chain-filter--regular
416 px (--w-7, matches the picker) · .search (40 px) · .list-item--lg rows w/ default .avatar (40 px) · 64 px row · directional "From network" / "To network" header (40 px)
The mini composition is the base node (no header, .search--sm, default-tier rows). The regular composition adds the --regular modifier + a title-only header and uses the default .search tier + .list-item--lg rows:
<!-- Regular composition (Dual Swap/Bridge receive column) -->
<aside class="panel split-pane split-pane--chain-filter split-pane--chain-filter--regular">
<!-- Header shown only in --regular (CSS-gated); balances the picker -->
<header class="screen-header">
<h2 class="screen-header__title" data-title-from="From network" data-title-to="To network">From network</h2>
</header>
<div class="search">…</div> <!-- mini uses .search--sm -->
<section class="chain-list-module">
<div class="ui-chain-list list" role="listbox" data-chain-list>
<!-- renderChainList() emits .list-item--lg rows in --regular, -->
<!-- default-tier + .avatar--sm rows in mini -->
</div>
</section>
</aside>
In the playground the node is authored once (mini base markup); applyChainPanelComposition() toggles the --regular modifier + the search tier off body[data-layout], and renderChainList() reads the modifier to emit the matching row scale — and calls syncChainPanelTitle() to mirror the header to the active picker side ("From network" / "To network", matching the token picker's "Send token" / "Receive token"). No per-layout markup duplication.
| Axis | Mini .split-pane--chain-filter | Regular .split-pane--chain-filter--regular |
|---|---|---|
| Layout | Adaptive — side sidebar beside the form | Dual Swap/Bridge — the Receive column |
| Width | 320 px (--w-4) | 416 px (--w-7) — matches the picker |
| Search | .search--sm · 32 px | .search · 40 px |
| Rows | .list-item-interactive · 48 px | .list-item--lg · 64 px |
| Avatar | .avatar--sm · 32 px | .avatar (default) · 40 px |
| All-networks cluster | 24 px | 40 px |
| Header | none | .screen-header "From network" / "To network" · 40 px |
The regular tiers are chosen to match the token picker beside it exactly — same 416 px width, same 40 px header row, same 64 px row height, same 40 px avatars, same default search tier — so the two panels read as one balanced pair.
| Part | Class | Role |
|---|---|---|
| Panel | .panel.split-pane.split-pane--chain-filter | .panel surface + flex column; the --regular modifier upsizes the composition. |
| Header (regular only) | .screen-header > .screen-header__title | Title-only (no __back/__trailing) → single centered column; directional ("From network" / "To network" via syncChainPanelTitle()), balancing the picker's "Send token" / "Receive token". CSS-gated to --regular. |
| Search | .search / .search--sm | Tier swapped by applyChainPanelComposition() — default in regular, --sm in mini. |
| Chain list | .chain-list-module > .ui-chain-list[data-chain-list] | Scroll host; rows rendered by renderChainList() at the modifier's scale. |
| Chain row | .list-item--interactive (+ --lg in regular) | Carries data-chain-key; is-selected marks the active filter. |
| All-networks row | .list-item [data-chain-all] + .list-item-all__multi | Clears the filter; the 2×2 avatar cluster sizes to the row's avatar (24 px mini / 40 px regular). |
- Two compositions, one node, switched by layout.
applyChainPanelComposition()(inpicker-render.js) readsbody[data-layout]—dual→ regular, everything else → mini — and toggles the--regularmodifier + the search tier. It runs on init and from abody[data-layout]observer, so layout switches re-scale the panel. - Regular is balanced 1:1 with the token picker. Same 416 px width, same 40 px header row, same 64 px row height, same 40 px avatars, same default search tier — and a directional title ("From network" / "To network", mirroring the picker's "Send token" / "Receive token" via
syncChainPanelTitle()). Dual's persistent column has the room the Adaptive sidebar doesn't, so the chain list reads at the same scale as the tokens beside it. - Mini is the Adaptive sidebar, unchanged. Compact 320 px rail beside the form:
.search--sm, default-tier rows with.avatar--sm, no header. - No row-render fork.
renderChainList()emits the row markup at the right scale by reading the--regularmodifier (lg rows + default avatar vs. default-tier +.avatar--sm). One render path, two scales — no parallel skeleton. - Filtering is identical across both. Click a network to narrow the token list, click an active one or All networks to clear. Locked-chain (Limit to-side) and wallet-ecosystem scope behave the same in either composition.
- When each appears. Mini → Adaptive (Simple tier). Regular → Dual Swap/Bridge (Advanced tier), in the Receive column during token selection. Compact uses the in-picker chain-shortcut grid instead (no room for a panel); Dual+Limit hides the panel (single-chain).
- CSS —
styles.css(.split-panebase) +swap.css(search"Chain-filter panel — the two documented compositions"for the mini/regular deltas;"Dual + Swap/Bridge — chain picker takes the Receive column"for the reposition). - JS —
picker-render.js → applyChainPanelComposition()(the composition switch) +renderChainList()(variant-aware row scale) +syncChainPanelTitle()(directional header → active picker side) +initChainDelegation()(filter clicks). - Markup —
playground.html→ the.split-pane--chain-filternode (with the directional From/To-network header). - Spec —
design/components/swap.md → Layout variants(the mini-vs-regular table + the Dual token-picking note).
Quotes
Limit price card .ui-limit-price-card
Limit-mode price anchor — "When BASE is worth" label + invert, value input, percent-preset chip row with an off-market alert past 10 %.
Default denomination — data-denom="base-per-quote"
Zero state · .ui-limit-price-card--zero · no token picked
No token picked yet (the playground cold-load). Mirrors .ui-amount-card--zero: price dims to --text-faint at constant weight, the label swaps to the static “Limit price”, and the invert chip + preset chips hide (no market rate to reference). The card stays for context.
<!-- Limit-mode price slot. Mode-gated via .ui-card[data-mode="limit"];
hidden in Swap / Bridge. Label symbols are read-only mirrors of
the Sell / Buy tokens picked via the amount-cards below. The
invert chip combines QUOTE-symbol display + denom flip action. -->
<div class="ui-limit-price-card" data-denom="quote-per-base">
<div class="ui-limit-price-card__lead">
<!-- Two label spans: --dynamic carries the live "When BASE is worth"
sentence; --zero is the static "Limit price" placeholder, shown
by .ui-limit-price-card--zero when no token is picked yet. -->
<span class="ui-limit-price-card__label ui-limit-price-card__label--dynamic">When <span data-limit-ref="lead-symbol">USDC</span> is worth</span>
<span class="ui-limit-price-card__label ui-limit-price-card__label--zero">Limit price</span>
<button class="ui-limit-price-card__invert chip chip-neutral">
<span data-limit-ref="main-symbol">ETH</span>
<svg class="chip-icon">…</svg>
</button>
</div>
<div class="ui-limit-price-card__main">
<input type="text" class="ui-limit-price-card__amount" inputmode="decimal" value="0.000449">
</div>
<div class="ui-limit-price-card__chips" role="group">
<!-- Four chips, all .chip-neutral baseline. paintActiveChip()
toggles .is-active on the one matching the displayed %. -->
<button type="button" class="chip chip-neutral is-active" data-limit-preset="market">Market</button>
<!-- +1%, +5% -->
<!-- Custom — composes .chip-edit; scoped overrides in swap.css
lock the input width + suppress the focus-within deeper-
tint so the chip stays an equal peer of its siblings. -->
<span class="chip chip-edit chip-neutral" data-chip-edit data-limit-proximity>
<button type="button" class="chip-edit__label" data-chip-edit-label>Custom</button>
<span class="chip-edit__field" hidden data-chip-edit-field>
<input class="chip-edit__input" value="+0.0" data-chip-edit-input data-limit-proximity-input>
<span class="chip-edit__suffix">%</span>
</span>
</span>
</div>
</div>
Card-family sibling of .ui-amount-card. Every surface + rhythm + typography slot default matches the equivalent --amount-card-* token, so the limit price card and the .ui-amount-pair below render as one card family — same surface, same shadow, same radius, same internal gap, same label and amount typography. Per-instance overrides still set the slot via inline style. When a property changes on either primitive, update the other in the same edit so they stay in lockstep.
| Slot | Default | Notes |
|---|---|---|
--limit-price-card-bg | var(--surface-card) | Auto-flips light/dark via the surface token family. |
--limit-price-card-radius | 16px | Matches .ui-amount-card. |
--limit-price-card-padding-y | var(--space-16) | Vertical padding inside the card. |
--limit-price-card-padding-x | var(--space-20) | Horizontal padding inside the card. |
--limit-price-card-gap | var(--space-12) · 12px | Vertical gap between lead / main / chips / proximity rows. Matches --amount-card-gap so internal rhythm is identical to .ui-amount-card. |
--limit-price-card-shadow | var(--elev-1) | Resting-card tier. |
--limit-price-card-lead-size | var(--text-sm) · 14px | Label typography — matches .ui-amount-card__label. |
--limit-price-card-lead-weight | var(--fw-semibold) · 600 | |
--limit-price-card-lead-color | var(--text-primary) | Primary ink so the label reads at the same weight as "Sell" / "Buy" below. |
--limit-price-card-amount-size | 36px | Matches .ui-amount-card__amount. |
--limit-price-card-amount-weight | 700 | |
--limit-price-card-amount-tracking | -0.02em | Display-tier negative tracking. |
--limit-price-card-amount-lh | 1.1 | Unitless ratio — tight for the 36px tier. |
| Sub-part | Class | Role |
|---|---|---|
| Root | .ui-limit-price-card | Card surface; carries data-denom (quote-per-base or base-per-quote). |
| Lead row | .ui-limit-price-card__lead | Label ("When BASE is worth") on the left + invert chip on the right. Baseline-aligned (align-items: baseline) so the chip's text sits on the same baseline as the lead label. |
| Label | .ui-limit-price-card__label | Hosts the "When BASE is worth" copy. Typography matches .ui-amount-card__label (14 / 600 / primary). Two sibling spans carry the modifier suffix: --dynamic (the live sentence) and --zero (the static "Limit price" placeholder). The --zero state swaps which one renders — pure CSS, no JS rebuilds the copy. |
| Lead symbol | [data-limit-ref="lead-symbol"] | Inner span inside the --dynamic label — paintLabel() writes textContent to the lead token's symbol on token + denom changes. |
| Invert chip | .ui-limit-price-card__invert | Flips data-denom and inverts the displayed value via 1/v. Composes the default .chip.chip-neutral tier (24 px tall, 12 px text). Anchored at the lead row's right edge via margin-left: auto. Combines QUOTE-symbol display (the chip's text content) with the invert action (chip-icon trailing) in one element. |
| Main symbol | [data-limit-ref="main-symbol"] | Inner span inside the invert chip — paintLabel() writes the QUOTE token's symbol (or BASE when data-denom="base-per-quote") on token + denom changes. |
| Chip icon | .chip-icon | Trailing 12 × 12 SVG inside the chip (invert-arrows glyph). Signals the action affordance; tone is inherited via currentColor. |
| Main row | .ui-limit-price-card__main | Value input alone. The invert action moved back into the lead row in May 2026 v3 (as a labeled chip), so the main row now hosts only the price input at full width. |
| Amount input | .ui-limit-price-card__amount | 36px / 700 / tabular-nums — matches .ui-amount-card__amount. |
| Chip row | .ui-limit-price-card__chips | Four equal .chip-neutral peers — Market / +1% / +5% / Custom — always visible. paintActiveChip() toggles .is-active on whichever chip matches the current displayed percent; the active chip gets the canonical .chip-accent1 accent fill (10–12 % bg, accent ink, no outline). |
| Custom chip | [data-limit-proximity] | Composes .chip-edit — clicking the "Custom" label swaps in a +N % input pre-filled with the current displayed percent; typing back-propagates to the price via applyProximityInput(); blur snaps the input back to the live computed percent. Scoped overrides in swap.css lock the input width to 5 ch (so the chip doesn't grow on click) and suppress the chip-edit focus-within deeper-tint bg (so the chip stays an equal peer of its preset siblings visually) — see the v1 → v5 migration history in the source comment for the design path here. |
- Card-family sibling of
.ui-amount-card. Surface (--surface-card), shadow (--elev-1), radius (16 px), padding (16 / 20), internal gap (12), label typography (14 / 600 / primary), and amount typography (36 / 700 / -0.02em) ALL match the amount-card's slot defaults. The amount weight is one cluster shared across three value-display-tier primitives (.ui-amount-card,.ui-limit-price-card,.ui-token-info) — each exposes a--*-amount-weight/--token-info-price-weightslot for per-instance override; cluster-wide changes flip the three slot defaults together. When a property changes on one primitive, update the other two in the same edit. A modifier-of-amount-card approach was deliberately rejected (layout shapes differ: lead / amount / presets / proximity vs. header / body / footer); the relationship is sibling, not variant. - Visible only in Limit mode. CSS gate via
.ui-card[data-mode="limit"] .ui-limit-price-card. The cascade keeps the markup live in the DOM (no JS toggle for visibility) so token-sync and proximity updates can run silently while hidden. - Limit mode is single-chain — Buy follows Sell. The limit-order engine doesn't yet support cross-chain settlement, so both Sell and Buy tokens must live on the same chain. The constraint is invisible until the user opens the Buy picker, at which point the chain-filter sidebar hides entirely (via
swap.css:.widget-stage:has(.ui-card[data-mode="limit"][data-active-picker="to"]) .split-pane--chain-filter { display: none; }) and the Portfolio + Popular token lists scope to Sell's chain — the constraint stays visible via the chain badges on every row. On entry to Limit mode (and on every Sell-chain change inside Limit),swap.js → snapToFollowFromInLimit()snaps Buy's chain to Sell's and try-preserves the Buy symbol viaLifiPickerData.findToken(); if the symbol doesn't exist on the new chain, falls back to the chain's alphabetically-first token (matches the Popular picker's sort). Asymmetric: Buy never lifts Sell. Source of truth:design/components/swap.md → Single-chain constraint (Limit mode only)— the constraint lifts when the engine adds cross-chain support. - Label symbols are read-only mirrors of the amount-pair tokens, not pickers. The base / quote tokens come from
[data-role="from"]/[data-role="to"]on the amount-cards below. Token sync fires when swap.js's MutationObservers detectdata-symbol/data-chainchanges — no per-card observer. - Price input re-syncs to market on every token change. When the user picks a new token in either amount-card, the observer above invokes
LifiLimitPriceCard.handleTokenChange()— equivalent torenderAll()(re-paints labels + chip) plus a re-snap of the price input to the new pair's market rate viaapplyPreset(card, 'market'). Without the re-snap, the previous pair's rate (e.g., 2253.4807 for ETH/USDC) carries over into the new pair (e.g., ETH/USDT) where it has no meaning, andrenderProximity()silently activates the "Custom" chip with a stale offset. The handler keeps the Market-chip-active invariant across token changes — matches the "open the limit card → you're at market" first-paint state frominit(). Focus guard: skipped when either the price input or the Custom-chip's inline input is the active element, so in-progress typing isn't clobbered. Trade-off accepted: a user-set preset offset (e.g., +5%) is reset on token change — the absolute number has no meaning when the market itself just changed. - Invert chip lives in the lead row, baseline-aligned with the label (May 2026 v3). History: v1 (Apr 2026) was a
btn-xsborderless icon (24 px) in the lead row; v1.5 stepped up to a default (40 px)btn-neutralcircle in the main row, replacing the read-only chip-avatar that mirrored the quote token; v2 stepped back down tobtn-sm(32 px)btn-neutralcircle withbtn-staticand corner-anchored absolutely; v3 (May 2026) moved back in-flow as a labeled.chip.chip-neutralin the lead row, combining the QUOTE-symbol display with the invert action in one element. The chip's default tier (24 px, 12 px text) matches the lead row'smin-height: var(--space-24);align-items: baselineon the lead row aligns the chip's text baseline with the 14 px label's text baseline. The QUOTE symbol returns as the chip's visible text content (lost in v1.5 when the read-only chip-avatar retired) — this time as an interactive element, not a passive marker. - Label typography matches
.ui-amount-card__label. 14 px / 600 / primary — the lead row reads at the same visual weight as "Sell" / "Buy" on the cards below, so the limit price card and the amount pair below it share one label family. Migrated May 2026 from the previous "When 1 [chip] is worth" sentence + lead chip-avatar; the chip retired and the symbol is rendered inline inside the label text. - Percent presets are ABSOLUTE relative to market — +5% sets the value to
marketRate * 1.05, notcurrentValue * 1.05. Idempotent: clicking +5% twice still gives +5% above market. The market reference flips withdata-denomso the same percent applies to whichever ratio the user is viewing. - Preset chips + invert + Custom-% input ALL move the TradingView chart's "Limit" line. The chart's
watchLimitPrice()intradingview-chart.jslistens for'input'events on the price field, debounces 150 ms, then re-paints the dashed line. Typing fires'input'natively, but programmatic writes to<input>.valuedo NOT — soapplyPreset(),invert(), andapplyProximityInput()route their writes through a smallwritePriceValue()helper that synthesizes the event after each write. Without that synthesis, clicking +5% would update the input field but leave the chart line at its previous value, making it look like the limit can't be raised or lowered. - Four equal chips, always visible. Market / +1% / +5% / Custom share the
.chip-neutralbaseline at rest.paintActiveChip()toggles.is-activeon the one matching the displayed percent (canonical.chip-accent1accent fill — bg + ink, no outline). Matching uses loose thresholds —|x| < 0.5 → Market,0.5 ≤ x ≤ 1.5 → +1%,4 ≤ x ≤ 6 → +5%, anything else (incl. negative deltas outside the Market band) → Custom. The bands absorb the ~0.05 % round-trip error fromtoFixed()string formatting. Pre-bake.is-activeon the chip matching the initial price value (typically Market) so the FOUC state matches the eventual JS-rendered state. - Custom chip composes
.chip-editwith scoped width-lock + bg-suppression overrides, plus a label-sync hook. Clicking the "Custom" label swaps in an inline+N %input;applyProximityInput()back-propagates the typed percent to the price-input; Enter / blur deactivates the field.syncCustomChipLabel()then rewrites the resting label text to the committed value (e.g.,+7.5%) so the chip's text reflects state instead of reverting to the static "Custom" word — re-clicking re-opens the input pre-filled with that value. When Custom is NOT the active chip (user is at Market / +1% / +5% / no value), the label reverts to "Custom". Two scoped overrides inswap.csskeep the chip visually identical to its preset siblings in edit mode: (a)field-sizing: fixed; width: 5 chon the input locks the chip width — typing doesn't grow it; (b) the chip-edit focus-within deeper-tint bg is suppressed back to the chip's tone (chip-neutralat rest, accent fill when.is-active). The off-market alert below the card carries the loud warning at|distance| ≥ 10 %. - Market rate comes from
LifiWalletSim.getPriceUsd(symbol), notLifiQuoteSim.getQuote(). AtfromAmount = 1gas dominates the quote output (a $1 trade incurs ~$8 in gas), zeroing the nettoAmount. Spot is what the proximity check actually wants — execution costs are surfaced elsewhere. - Off-market alert is a separate primitive, not part of the price card. When the proximity hits danger tier, ui-limit-price-card.js toggles
[hidden]on a sibling.ui-limit-off-marketwrapper that hosts a.alert--warn. The chip is the in-context numeric readout / control; the alert is the ambient prose escalation — the chip no longer carries the "(may not fill)" appendage now that the input shape lives there. - Zero state —
.ui-limit-price-card--zerowhen no token is picked. Sibling of.ui-amount-card--zero, applied byui-limit-price-card.js → renderZeroState()whenever either referenced token (Sell / Buy, mirrored from the amount cards below) is still a placeholder — the playground cold-load / no-selection state. Visual recipe, mirroring the amount card: the price value renders the faint0placeholder in--text-faintwith weight / size / tracking / line-height unchanged (the state changes shift ink, not weight rule); the label swaps from the dynamic "When BASE is worth" sentence to the static "Limit price" (full--text-primaryink, exactly as "Sell" / "Buy" stay full-ink on the amount cards); and the invert chip + preset chips hide — both reference a market rate that needs tokens, so they'd be dishonest affordances (same reason the amount card hides its balance row + MAX shortcuts at zero). The card stays in the DOM to give context of what it'll hold. Critically,init()does NOT seed a market price without tokens — the pre-fix bug painted a fabricated "When ETH is worth 2227" fromMARKET_DEFAULTbefore the user picked anything (synthetic data shown as authoritative). The moment both tokens are picked, the observer'shandleTokenChange()lifts--zero, repaints the labels, and re-snaps the input to the real market rate.
Implementation:
swap.css—.ui-limit-price-cardrules (search the section comment "limit-mode price slot").ui-limit-price-card.js— controller. Public API:init,render,renderAll,invert,applyPreset,renderProximity,getMarketRateonwindow.LifiLimitPriceCard.swap.js—initExchangeModeTabs()callsrenderAll()on mode change; both MutationObservers firerenderAll()when the amount-pair's tokens change.playground.html— canonical markup live inside the.ui-cardshell, between the screen-header and the.ui-amount-pair.design/components/swap.md— written spec for the primitive's surface recipe + denomination semantics.
Related primitives: .ui-card (widget shell), .ui-amount-pair (Send / Receive composition), .ui-amount-card (sub-card primitive that shares the surface recipe), .chip-avatar (read-only mirrors of the base / quote tokens), .chip (percent-preset chips + proximity readout).
Quote card .ui-quote-card
One offer in the routes list. Body collapses to amount + provider; chevron expands the fee breakdown.
Route comparison · tagged + untagged
Route
- Network cost
- $7.22
- Provider fee
- $0.04
- Price impact
- -7.27%
- Max. slippage
- 0.30%
- Min. received
- 0.041028 ETH
Private mode · shield chip, single route
Route
- Network cost
- $2.87
- Bridge fee
- $2.55
- Price impact
- -0.49%
- Max. slippage
- 0.30%
- Min. received
- 1,117.87 USDC
Private mode surfaces one Houdini route and renders it as a compact inline card in the form (after the receiving wallet); tagDisplay()’s private branch swaps “Best return” for the shield “Private” chip. Shown expanded — the inline card keeps its chevron and expands in place to reveal the route breakdown (unlike the Compact inline teaser, which strips the chevron and drills to the full list).
<article class="ui-quote-card is-active is-expanded" data-route-id="route-1inch-…">
<header class="ui-quote-card__header">
<span class="chip chip-accent1">Best return</span>
</header>
<div class="ui-quote-card__body">
<!-- Canonical .avatar-tandem primitive (see #avatar-tandem). Every card
in the list shows the SAME destination token + chain tandem; the
differentiator lives in amount + meta + provider, not the avatar. -->
<span class="avatar-tandem">
<img class="avatar avatar--circle" src="avatars/tokens/eth.svg" alt="" />
<img class="avatar-badge avatar-badge--br" src="avatars/chains/ethereum.svg" alt="" />
</span>
<div class="ui-quote-card__amount">
<span class="ui-quote-card__amount-value">0.041151</span>
<span class="ui-quote-card__meta">…</span>
</div>
<button class="btn-icon btn-neutral btn-circle btn-xs" data-action="quote-toggle">…</button>
</div>
<div class="ui-quote-card__detail">
<div class="ui-quote-detail-table">…</div>
</div>
<footer class="ui-quote-card__footer">…</footer>
</article>
State recipe
Same chrome ladder as .theme-card and .ui-amount-card; tokens are the contract.
| State | Background | Border | Elevation |
|---|---|---|---|
| Rest | var(--surface-card) | 1px solid transparent | var(--elev-1) |
| Hover | color-mix(--accent-primary 3%, --surface-card) | unchanged | var(--elev-2) |
.is-active / :active | color-mix(--accent-primary 4%, --surface-card) | 1px solid color-mix(--accent-primary 64%, transparent) | var(--elev-2) |
Dimensions
| Property | Token / Value | Resolved |
|---|---|---|
| Padding (Y / X) | var(--space-16) var(--space-20) | 16 / 20 px |
| Border radius | var(--space-16) | 16 px |
| Card-to-card gap | var(--card-gap) defaults to var(--gap-card-md) | 12 px |
| Avatar slot | .avatar-tandem (default --avatar-size: 40px) | 40 × 40 px (token); 16 × 16 px (chain badge, 40 %) |
| Amount value | var(--text-h3) · var(--fw-bold) · var(--tracking-snug) · tabular-nums | 24 px / 700 |
| Tag pill | 11px · var(--fw-semibold) | 11 px / 600 |
| Transition | background / border-color / box-shadow 0.18s ease | — |
| Region | Class | Purpose |
|---|---|---|
| Container | .ui-quote-card | Outer card shell. .is-active moves the accent ring; .is-expanded reveals __detail. |
| Header | .ui-quote-card__header | Tag pill row. Rendered only when the route carries a semantic tag (RECOMMENDED / FASTEST / CHEAPEST / SAFEST); untagged routes drop the header entirely so the provider name in __meta-provider isn’t duplicated. |
| Tag pill | .chip + .chip-accent1 / .chip-neutral | Canonical chip primitive — .chip-accent1 for the “Best return” recommendation tag; .chip-neutral for “Fastest” / “Cheapest” / “Safest”. In Private mode the chip reads shield (icons/shield.svg, in the .chip-icon slot) + “Private” on .chip-accent1 — mode-driven, not a route tag, so it wins over any RECOMMENDED tag the forced route carries. The card’s former .ui-quote-card__tag shorthand was retired May 2026 — the chip family already covers the recipe verbatim. Provider-name fallback was retired in the same pass; providers surface in __meta-provider only. |
| Body | .ui-quote-card__body | Flex row: avatar · amount + meta · chevron toggle. |
| Avatar slot | .avatar-tandem | Canonical tandem primitive at the default 40 × 40 (chain badge 16 × 16). Token <img class="avatar avatar--circle"> from toTok.logoURI (avatars/tokens/{symbol}.svg); chain badge <img class="avatar-badge avatar-badge--br"> resolved via chainIdToAssetKey(toTok.chainId) (avatars/chains/{slug}.svg). Every card in the list shares the same tandem — quotes read as "different rates for the same destination," not "different destinations." |
| Amount value | .ui-quote-card__amount-value | Headline token amount; tabular-nums for cross-card alignment. |
| Meta line | .ui-quote-card__meta | Fiat · price impact · provider. Separators via __meta-dot; tiny provider logo via __meta-provider-icon (background-image set on render). |
| Expand toggle | .btn-icon.btn-neutral.btn-circle.btn-xs + data-action="quote-toggle" | Canonical 24 × 24 filled icon button (12 px chevron, neutral theme-adaptive tint via .btn-neutral) — composes the standard icon-only primitive; no per-component class. Buttons are static by default (May 2026 inversion — the lift is the opt-in .btn-lift), so the toggle stays put on hover with no extra class; the bg wash alone communicates the affordance. Click toggles .is-expanded; aria-expanded + aria-label reflect state. Chevron rotates 180° when aria-expanded="true" via a scoped rule in swap.css that uses the standalone rotate: property (not transform: rotate()) so it stays independent of the button's own transform. In a list the toggle is delegated on .ui-quote-list; the Compact inline teaser drops the chevron (drills instead) while the Private inline keeps it (no drill) — expanded in place by ui-quote-list.js → attachInlineToggleDelegate() on the .ui-quote-inline wrapper. |
| Detail slot | .ui-quote-card__detail | Hidden until .is-expanded. Hosts .ui-quote-detail-table today; variant-flexible for future route-step / route-track views. Expand animates via grid-template-rows: 0fr → 1fr with chrome (margin-top, padding-top, border-top-color) transitioning in lockstep — no JS height measurement. Per-row stagger fades up children matching .detail-list__row or .ui-quote-card__detail-row in :nth-child sequence (capped at 12; tuning tokens --detail-open-duration / --detail-row-duration / --detail-stagger-step / --detail-stagger-base). See Rules pane for the future-variant contract. |
| Footer | .ui-quote-card__footer | Always visible. Rate · gas · time across three cells. |
- Canonical UI card recipe. Same ladder as
.theme-cardand.ui-amount-card. Rest =--surface-card+ 1 px transparent border +--elev-1. Hover = 3% accent-tinted bg +--elev-2..is-active= 4% accent-tinted bg + 1 px rim atcolor-mix(--accent-primary 64%, transparent)+--elev-2. The transparent border at rest reserves the slot so selection costs zero layout shift. Rim and fill were softened May 2026 — was full-saturation rim + 5% fill; new recipe calms the selected card in long lists without losing the accent signal. - Every card is selectable;
.is-activeis the single selection class. Click anywhere on the card body to move.is-activeto that card;ui-quote-list.js's delegated handler enforces single-selection across the list. The recommended route lands with.is-activeby default on initial render. - Chevron and body are distinct affordances. Chevron click toggles
.is-expandedper-card — multiple cards may be expanded simultaneously. Body click moves selection. The chevron handler callse.stopPropagation()so expanding doesn't also move the ring. - Default state is collapsed; single-quote lists are the one exception. Multi-quote lists land with every card collapsed so the user can scan rates and pick a card without the recommended card's detail dominating the panel; expanding is then a deliberate per-card act. When the route returns exactly one quote, that lone card lands with
.is-expandedapplied — there's no pick to make and the detail row is the only remaining useful surface. The behaviour mirrors the LI.FI widget in production. Implemented inui-quote-list.js → applyInitialExpansion(); the auto-expand path is gated oncards.length === 1. - Tag → header label mapping. The renderer translates SDK tag values:
RECOMMENDED→ “Best return” (no--neutral);FASTEST→ “Fastest”;CHEAPEST→ “Cheapest”;SAFEST→ “Safest”; no tag → header omitted (provider lives in__meta-provider). All non-default tones use--neutral. - Private mode overrides the tag with a shield “Private” chip. When
body[data-widget-mode="private"],tagDisplay()returns{ text: "Private", best: true, kind: "private" }before reading the route’s SDK tags — so the single Houdini route reads shield + “Private” on.chip-accent1instead of “Best return”. The chip names the route’s nature (privacy), not a best-of-N ranking.slotHeaderTag()emits the.chip-iconshield (icons/shield.svg) only forkind === "private". In Private the card also renders as a compact inline preview in the form (the receive panel is suppressed — see Widget modes), so this chip is the route’s only label surface. Unlike the Compact inline teaser (chevron stripped, drills to the list), the Private inline keeps its chevron and expands in place to reveal the route breakdown — it's the only path to the details, since there's no list to drill into. See Inline quote preview. - “Best return” tag is semantic; persists across selection moves. The header tag labels the system's recommendation. The accent ring tracks the user's pick. Selecting a non-best card does not strip the “Best return” tag from the recommended one — the two signals are decoupled.
- Detail slot is variant-flexible. Hosts
.ui-quote-detail-tablefor fee breakdowns today. Future variants (route summary, route steps, route track) drop into the same slot without changing the card chrome. - Expand/collapse is content-agnostic — three contracts for new detail variants. (1) Wrap the variant content in exactly ONE direct child of
.ui-quote-card__detail. Thegrid-template-rows: 0fr → 1frtrick sizes one row track — multiple direct children would each land in an implicit auto row, and implicit rows don't transition. (2) Don't authormargin-top/padding-top/border-topon 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.detail-list__row(preferred — reuses the existing primitive) OR.ui-quote-card__detail-row(catch-all for custom row markup). Rows are counted by:nth-childagainst their direct parent — keep stagger candidates as siblings of each other inside one row container. The cascade explicitly handles 12 siblings; rows past 12 share the 11th delay so the reveal stays bounded. - Reveal cadence is token-driven.
--detail-open-duration(container height + chrome, default 280 ms),--detail-row-duration(per-row fade-up, 220 ms),--detail-stagger-step(delay between row starts, 30 ms),--detail-stagger-base(initial offset before row 1, 60 ms). Override at:root,.ui-quote-card, or.ui-quote-card__detailto re-cadence without re-authoring the:nth-childcascade.prefers-reduced-motion: reducezeroes every transition and animation — the toggle becomes instant. - Collapse uses implicit clipping, not a reverse stagger. Removing
.is-expandedanimates the container shut;overflow: hiddenon the slot trims rows as the track collapses. No per-row exit animation — cheaper and visually sufficient. If a future variant needs a deliberate reverse stagger (e.g., a "minimize to summary" effect), author it as a sibling animation keyed off a different state class, not by reversing this one. - Footer always visible. Rate · gas (USD) · ETA. Reads as a single line in the collapsed state; sits below the detail table when expanded.
- Data flow: simulator → renderer → card.
LifiQuoteSim.getRoutes()returns aRoutesResponsemirroring the@lifi/sdkRouteshape.ui-quote-list.jsrebuilds.ui-quote-list's innerHTML on every input change (debounced 200 ms). Token avatar resolves fromtoTok.logoURI(set toavatars/tokens/{symbol}.svginbuildTokenShape()); chain badge resolves fromchainIdToKey(toTok.chainId)via the chain catalog (avatars/chains/{key}.svg). Tool name + provider logo for the meta line come from the captureddata/tools.jsonfixture; eventual swap to the real API is a one-line body change insidegetRoutes(). - Avatar is destination, not provider. Provider identity lives in the meta line (
__meta-providerwith the tiny provider logo + name). The primary avatar emphasises what the user is getting (same destination token + chain across every quote), so the visual hierarchy reads "I'm choosing a rate" rather than "I'm choosing an exchange." This was changed May 2026; pre-change the avatar renderedtoolDetails.logoURI. - Loading state — three stamp triggers, one preserve trigger.
ui-quote-list.js → renderList(listEl, opts)stamps3skeleton-shape cards (.ui-quote-card.is-skeletonchrome +.skeletonatoms in the canonical slot positions) when EITHER the list is empty (first render, or after the empty-state div) ORopts.forceSkeleton === true(deliberate user refresh — the canonical caller is the.refresh-timerclick, which dispatchesrefresh-timer:expiredwithdetail.source: "click"and the playground bridge forwardsforceSkeleton). Passive refresh paths (typing debounce, sim event, timer auto-expiry) preserve the previous render — flashing skeletons on every 400–800 ms sim cycle reads as jitter, not progress.aria-busy="true"on the list announces the state; each skeleton atom isaria-hidden="true"so screen readers don't repeat per-atom. Loading exits clear bothdata-stateandaria-busy. Full contract: CLAUDE.md → Skeleton + shimmer (loading-state contract #4). - Typography pinned on the wrappers, not inherited.
__metaand__footerboth pinfont-size: var(--text-caption)(12 px) andline-height: var(--lh-caption)(16 px) so every child text run inherits a system-tokenized type pair. Without the line-height pin,__meta-provider-name,__delta, and__footer-ratewould inheritline-height: normal(browser default ≈ 1.2) instead of the canonical 16 px line-box.__amount-symbolpins its ownline-height: 1.2to ride the same baseline rhythm as the parent__amount-value. Catalog ≡ playground for every text run regardless of consumer ambient — drops the inheritance contract for any future overlay or popover that hosts this primitive. - Price impact — one formatter, three sites, neutral ink. Every surface that renders price impact routes through
window.LifiQuoteList.formatPriceImpact(impactPct, userAmount?)— the body meta line (under the amount), the route detail row (inside the expanded.detail-list), and the Receive card's footer slot. The formatter returns{ text }with the canonical sign convention: positive raw impact (value lost) →-X.XX%; negative raw (gain, rare) →+X.XX%; zero or no quote →0.00%. All three sites render at neutral ink today — no.detail-list__desc--warning/--negativeon Sites 1/2, nodata-state="warn"|"error"on Site 3. Severity coloring is deferred as a future opt-in feature; the CSS hooks for it stay dormant inswap.css+styles.css, ready to re-engage when consumers extend the formatter return shape to{ text, severity }. Full contract + thresholds + future-feature path:design/components/swap.md → Price impact. Therate-deltabody-meta variant (opt-in alternative meta-line presentation) is exempt — its severity coloring is part of the variant's identity, not a price-impact contract. - Wallet is OPTIONAL — quote panel renders pre-connect. The list paints routes as soon as .ui-amount-pair has both tokens picked + a typed amount, regardless of wallet state. Route-card click still selects (moves
.is-active, updates the Receive card to mirror), so users can compare routes before connecting. Execution is gated by the footer CTA, not the list. Canonical rule: CLAUDE.md → Quote preview is wallet-OPTIONAL.
The card spans CSS, the renderer, and the simulator that drives its content.
- CSS —
swap.css, search.ui-quote-card. Anatomy comment + canonical recipe live at the top of the block. - Renderer —
ui-quote-list.js. Reads picker state from.ui-amount-pair, callsLifiQuoteSim.getRoutes(), rebuilds the list innerHTML on every input change. Owns the delegatedquote-toggle+ selection click handlers. - Simulator —
quote-sim.js. ParametricRoutesResponsegenerator mirroring@lifi/sdkRoute/LiFiStep/Stepfield-for-field. Returns 4–6 routes per call withRECOMMENDED/FASTEST/CHEAPESTtags assigned across the set. - Tool catalog —
data/tools.json. Captured from/v1/tools; provides every tool'skey,name,logoURI, andsupportedChains. - Live mount —
playground.html, search.ui-quote-list. Lives inside the.widget-stage__receiveaside.
Mini quote card .ui-quote-mini-card
Limit-mode route picker. Slim sibling of .ui-quote-card — provider icon + name + gas cost only, no amount / time / conversion / impact.
Three routes · first card selected
<!-- Limit-mode route picker. Roving-tabindex radiogroup; one card
carries .is-active + aria-checked + tabindex=0 at a time. Roots
are <div role="radio"> (NOT <button>) so each row can host the
metric pill's icon-<button> child without tripping the HTML
button-in-button parser footgun. Keyboard handling lives in
swap.js → initQuoteMiniList(). -->
<div class="ui-quote-mini-list" data-limit-only role="radiogroup">
<div class="ui-quote-mini-card is-active" role="radio"
tabindex="0" aria-checked="true" data-route-id="route-1inch">
<span class="ui-quote-mini-card__provider">
<span class="ui-quote-mini-card__provider-icon" style="background-image:url(...)"></span>
<span class="ui-quote-mini-card__provider-name">1inch</span>
</span>
<!-- Metric pill — shared primitive (see #ui-quote-metric). Mini
cards include --fee only; --eta is omitted in Limit mode (all
quotes share the same execution time). -->
<span class="ui-quote-metric ui-quote-metric--fee">
<span class="tooltip-wrap">
<button class="btn-icon btn-borderless btn-muted btn-xs">…fuel svg…</button>
<span class="tooltip tooltip--multi">Estimated network fee…</span>
</span>
<strong>$7.22</strong>
</span>
</div>
<!-- 1-2 more cards, each with tabindex="-1" aria-checked="false" -->
</div>
| Sub-part | Class | Role |
|---|---|---|
| List | .ui-quote-mini-list | Vertical stack of cards. --card-gap: var(--gap-card-sm) (8 px). Single-selection group — role="radiogroup". |
| Card | .ui-quote-mini-card | Single horizontal row, 48 px min-height. Consumes the canonical UI card recipe — --surface-card + --elev-1 at rest, 1 px transparent border slot, 4 % accent fill + 64 % accent border + --elev-2 on .is-active. Root is <div role="radio" tabindex="0|-1" aria-checked> (NOT <button>) so it can host the metric pill's icon-<button> child without tripping the HTML button-in-button parser footgun. Keyboard + roving-tabindex handled by swap.js → initQuoteMiniList(). |
| Provider slot | .ui-quote-mini-card__provider | Icon + name pair. flex: 1 so the metric pill trails at the row's right edge. |
| Provider icon | .ui-quote-mini-card__provider-icon | 20 × 20 circular avatar. Background-image painted inline from the LI.FI types repo's exchange-icon CDN URL (same pattern as .ui-quote-card__meta-provider-icon). |
| Provider name | .ui-quote-mini-card__provider-name | Semibold label, single line, ellipsis on overflow. |
| Metric pill | .ui-quote-metric.ui-quote-metric--fee | Trailing gas-cost cell — shared primitive (see #ui-quote-metric). Mini card includes only --fee; --eta is intentionally omitted because all Limit-mode quotes share the same execution time. |
- Limit-mode-only. In Swap / Bridge modes the full
.ui-quote-cardlist (Receive panel) is the route selector — users care about amount / conversion / time delta because providers differ on those axes. In Limit mode the order fills at the user's set price, so amounts / conversion / time collapse to identical across routes; provider + gas are the only meaningful differentiators. The mini variant strips the rest and lives in the main form above the Expires-in row; the Receive panel hides its quote list (gated bybody[data-widget-mode="limit"]) and the space hosts.ui-token-info+.ui-price-chartinstead. - Sibling primitive, not a modifier. The mini card has different anatomy (single horizontal row vs. multi-row stack with avatar + expandable detail) and different role (Limit-mode-only). A
.ui-quote-card--minimodifier would have to suppress ~80% of the parent's slots; cleaner as its own primitive. - Single-selection via delegated click + keyboard nav. One
.is-activeat a time across the list, mirrored byaria-checked+ rovingtabindex(the group is one tab stop; arrows move + activate between members). Selection handler inswap.js → initQuoteMiniList()handles click + Enter/Space + Arrow / Home / End per the WAI-ARIA radiogroup pattern. The handler ignores clicks landing inside.ui-quote-metricso the tooltip's icon-button affordance fires independently of row selection. - Root is
<div role="radio">, not<button>. Each row hosts the metric pill's icon-<button>child (gas-cost tooltip trigger). Per the CLAUDE.md "Card root element —<button>vs.<div role="button">vs.<article>" rule, a card root that hosts a real<button>child must NOT itself be a<button>— the HTML parser auto-closes the outer button the moment it sees the inner. Tradeoff: keyboard activation isn't free (custom Enter/Space + Arrow handlers in swap.js) but accessibility lands cleaner via proper radio-group semantics (the parent.ui-quote-mini-listisrole="radiogroup", so radio members are the semantically correct child role). - Compose 2–3 cards in the form. Vertical stack at
--gap-card-sm(8 px) so 3 cards take ~136 px — comparable to one full quote card. Past 3 the list reads as overflow; surface a "more routes" affordance instead. - Visibility — dual gate. Shown only when both (a) the widget is in Limit mode (the
data-limit-onlyattribute composed with the.ui-card[data-mode="limit"]gate) and (b) the swap stage has reached quote-ready state (.widget-stage[data-quotes-state="visible"], set byui-amount-card.js → setQuotesReady()when wallet is connected, both tokens are picked, send amount > 0, and the quote sim returned a result). Empty / partial input collapses the list entirely — no "loading skeleton" tier, because the list's purpose IS comparison and there's nothing to compare without amounts. Trade-result content vs. market-reference content: the mini-list is trade-result (routes for a specific trade), so the readiness gate applies. The Receive panel in Limit mode hosts.ui-token-info+.ui-price-chart— market-reference content — and is ALWAYS visible in Limit mode regardless of readiness. The two surfaces follow different gates by design. - Provider-name typography pinned on the primitive, not inherited.
__provider-namepinsfont-size: var(--text-sm)(14 px) andline-height: var(--lh-sm)(20 px) so the label reads at the same 20 px line-box across every consumer — catalog, playground, and any future overlay that hosts the primitive. Without the pin the size technically inherits 14 px from the.ui-quote-mini-cardwrapper (so it currently looks right) but the line-height inheritsnormal(~17 px) instead of the system-tokenized 20 px pair. Per CLAUDE.md → "When promoting in-product code to the catalog — match the live rendering, never rely on defaults," the contract is on the primitive itself.
Quote metric pill .ui-quote-metric
Shared "icon + value" route-metric primitive. Two tone modifiers (--fee · --eta); consumed by both .ui-quote-card footer cells and .ui-quote-mini-card rows.
.ui-quote-metric--fee · gas-cost cell
.ui-quote-metric--eta · execution-time cell
<!-- Tone modifier picks icon + tooltip body. Customization is by
slot omission: a consumer that wants only one metric just doesn't
include the other <span>. Don't author --no-fee / --no-eta
modifiers — fee/eta are orthogonal optional regions, not chrome
variants. (See CLAUDE.md → Composable slots vs variant modifiers.) -->
<span class="ui-quote-metric ui-quote-metric--fee">
<span class="tooltip-wrap">
<button class="btn-icon btn-borderless btn-muted btn-xs"
aria-describedby="tt-fee-{id}" aria-label="Network fee details">
<svg viewBox="0 0 24 24">…fuel-pump path…</svg>
</button>
<span id="tt-fee-{id}" class="tooltip tooltip--multi" role="tooltip">
Estimated network fee, paid in the source chain’s native token.
</span>
</span>
<strong>$7.22</strong>
</span>
| Sub-part | Class | Role |
|---|---|---|
| Wrapper | .ui-quote-metric | inline-flex, align-items: center, gap: var(--space-4), font-size: var(--text-sm) (14 px), line-height: var(--lh-sm), tabular-nums, flex-shrink: 0. The --space-4 gap adds a small breath between icon and value, layered on top of the .btn-xs internal padding (6 px each side around a 12 px glyph in a 24 px button). Font-size is pinned on the primitive (NOT inherited) so the pill reads identically in every context — catalog preview, big card footer, mini card row. |
| Tone modifier | .ui-quote-metric--fee | Fuel-pump icon + "Network fee" tooltip body. Gas-cost cell. |
| Tone modifier | .ui-quote-metric--eta | Clock icon + "Estimated time" tooltip body. Execution-duration cell. |
| Tooltip wrapper | .tooltip-wrap | Anchors the popover-API tooltip; sibling of the icon button + value. |
| Icon button | .btn-icon.btn-borderless.btn-muted.btn-xs | Leading 24 px icon-button. .btn-muted tone (the icon doesn't compete with the value's primary ink). Hovering or focusing the button reveals the tooltip; nothing happens on click. |
| Tooltip | .tooltip.tooltip--multi | Multi-line tooltip body (see #tooltip). Uses popover API + JS positioning so it escapes any ancestor overflow: hidden. |
| Value | <strong> | Semibold primary ink, tabular-nums via the wrapper. Formatted as fiat for --fee ($0.28), as duration for --eta (6m / 11s). |
- Customization is slot omission, not modifier flags. A consumer that wants only the fee (e.g.,
.ui-quote-mini-cardin Limit mode — all routes share the same execution time, so showing it is noise) simply doesn't include the--etaspan. PerCLAUDE.md → Composable slots vs variant modifiers: fee/eta are orthogonal optional regions, not different chrome for the same content. Don't author--no-fee/--no-etamodifiers; don't add adata-show-etaattribute. - One canonical primitive across two consumer surfaces.
.ui-quote-card's footer cells (Receive panel, Swap / Bridge) and.ui-quote-mini-cardrows (Limit-mode picker) both render.ui-quote-metricinstances — same icon-button, same tooltip body, same typography. Promoted May 2026 from per-card.ui-quote-card__footer-cell+.ui-quote-mini-card__costclasses. Atomic retirement, no aliases. - Single-source rendering through
ui-quote-list.js → slotFooterCell(). The big card's footer cells are JS-rendered, so any future tweak to the metric pill (typography, icon swap, new modifier) flows through one slot helper. The mini card's markup is hand-authored static HTML today — when (if) the mini-list moves to JS rendering, it should reuseslotFooterCell()to keep both consumers single-source. - Don't add a third metric without surfacing first. The two tones cover the universal route-comparison axes (cost + time). A future
--slippageor--mev-protectionmodifier is plausible but would expand the catalog surface and the renderer; propose before authoring so we evaluate whether the new metric is genuinely cross-route-comparable or belongs in the per-card detail panel instead. - Font-size pinned on the wrapper, not inherited. The primitive's value renders at
var(--text-sm)(14 px) regardless of consumer context — without the pin, the value<strong>would inherit the parent's ambient font-size and the pill would render at different sizes per surface (catalog ~16 px vs. each consumer's own ambient). The pin keeps the metric pill at a stable 14 px across surfaces; in the mini card the value now reads at the same 14 px as the provider-name label beside it, instead of overriding downward. - Icon button is decoration + tooltip trigger, not an action. Hovering or focusing the button reveals the tooltip; clicking it does nothing. The consumer card's click delegation should ignore clicks landing inside
.ui-quote-metric(seeswap.js → initQuoteMiniList()for the canonical pattern) so the tooltip affordance doesn't double-fire as a row selection. - Mini-card root must be
<div role="radio">, NOT<button>. The metric pill's icon-button is a real<button>child. A<button>-rooted card row would trip the HTML button-in-button parser footgun (perCLAUDE.md → Card root element) — the outer button auto-closes the moment the inner button opens, and the inner button ends up as a sibling rather than a child. Same constraint applies to any future consumer hosting.ui-quote-metric.
Inline quote preview .ui-quote-inline
In-form route surface — shows only the selected .ui-quote-card. In Compact it's a teaser that drills to the full list; in Private it's the only quote surface, so it keeps its chevron and expands in place.
Compact · teaser + drill (chevron stripped)
Compact: the card is the live .ui-quote-card.is-active with its expand chevron stripped (read-only teaser). Clicking anywhere drills to the full routes list.
Private · keeps the chevron, expands in place
Route
- Network cost
- $2.87
- Bridge fee
- $2.55
- Price impact
- -0.49%
- Max. slippage
- 0.30%
- Min. received
- 1,117.87 USDC
Private: single Houdini route, no drill. The chevron stays and toggles .is-expanded in place (wired by attachInlineToggleDelegate() on the .ui-quote-inline wrapper). No "Show all quotes" button.
<!-- In-form inline route preview. [data-inline-card] is filled by
ui-quote-list.js → renderInline() with the active route's card.
Compact: chevron stripped, "show more" → quotes drill (initCompactQuotes).
Private: chevron KEPT, expands in place; no drill, no show-more. -->
<div class="ui-quote-inline" data-quote-inline>
<div class="ui-quote-inline__card" data-inline-card>
<!-- .ui-quote-card.is-active rendered here (Compact: chevron removed;
Private: chevron kept + expands in place) -->
</div>
<!-- .btn-with-icon appends the trailing → arrow; .btn-block spans full width -->
<button type="button" class="ui-quote-inline__more btn-secondary btn-with-icon btn-block"
data-action="show-quotes">Show all quotes</button>
</div>
| Sub-part | Class | Role |
|---|---|---|
| Wrapper | .ui-quote-inline | Flex column, gap: var(--space-8). Base display: none — revealed only in Narrow + non-Limit + quote-ready + form state (see Rules). The whole wrapper is the drill tap-target. |
| Card host | .ui-quote-inline__card | Holds the active .ui-quote-card projected by ui-quote-list.js → renderInline(). In Compact the expand chevron is removed (the card drills to the full list instead). In Private the chevron is kept and expands the card in place — renderInline() branches on body[data-widget-mode="private"], and attachInlineToggleDelegate() (bound to the .ui-quote-inline wrapper, which survives the innerHTML rewrites) handles the toggle since the list's own delegate can't reach a card outside .ui-quote-list. |
| Show-more | .ui-quote-inline__more | A .btn-secondary.btn-with-icon.btn-block — .btn-with-icon appends the trailing → arrow via ::after; .btn-block spans the column edge-to-edge (no bespoke width override). Label is static "Show all quotes"; renderInline() hides the button when only one route exists. |
- Two reveal contexts: Compact and Private. The inline preview shows in single-panel Compact (
body[data-layout="compact"], the answer to route comparison without a side panel) AND in Private mode (body[data-widget-mode="private"], any layout — the receive panel is suppressed because there's only one Houdini route, see Widget modes). In Adaptive / Dual non-Private the full.ui-quote-cardlist lives in the Receive aside instead. Both reveals are gated:not([data-widget-mode="limit"])— Limit keeps its two-panel chart. - Projection of the selected route. The card is rendered from whichever route carries
.is-activein the canonical.ui-quote-list.renderInline()fires fromnotifySubscribers(), so it tracks both data refreshes and selection changes in one path; in Private it preserves the user's.is-expandedstate across those re-renders. In Compact selection only ever changes in the drill, so the inline can't desync; in Private there's a single route, so there's nothing to select. - One list, relocated — not two. The full list is a single DOM node moved between the Receive aside (Wide) and the form's
quotesdrill state (Narrow) on variant switch (swap.js → initRailLayoutPanel). Moving the node carries its delegated handlers + selection with it, sogetReviewModel()and selection stay correct without a second list to keep in sync. - Show-more drills, back returns. Clicking the wrapper sets the form card to
data-state="quotes"(full list,Routesheader); the back arrow returns toform. Selecting a route in the drill moves.is-active, re-renders the inline, and refines the Receive amount — the standard quote-selection path. - Chevron: stripped in Compact, kept in Private. In Compact the projected card's
[data-action="quote-toggle"]is removed inrenderInline()(not CSS-hidden) — the whole card drills to the full list, so a chevron would be a false affordance. In Private there's no drill (single route), so the chevron is kept and expands the card in place;attachInlineToggleDelegate()on the.ui-quote-inlinewrapper handles the toggle (andstopPropagations so the click never reaches the Compact drill handler). The "Show all quotes" button hides whenever there's a single route, so Private never shows it. - Capped-height scroll is expected. In Auto height the form grows and the whole preview + show-more sit above the action footer. In a capped height (Compact / Standard) the form stack scrolls, consistent with how the form already absorbs Limit-mode overflow — the show-more sits at the bottom of that scroll region.
- Markup —
playground.html, searchdata-quote-inline(form state) +data-quotes-drill-host(thequotesdrill). - CSS —
swap.css, searchCompact layout variantfor the single-column rules +.ui-quote-inlinegating. - Render —
ui-quote-list.js → renderInline()(projects the active route; called fromnotifySubscribers). - Navigation + relocation —
swap.js → initCompactQuotes()(drill / back) andinitRailLayoutPanel → relocateQuoteList()(list move on variant switch).
Receive panel .ui-receive-panel
The receive-column frame: a Receive header above the route list (.ui-quote-list) in Swap / Bridge / Gas, or the market chart in Limit.
Swap / Bridge / Gas · the frame wraps the route list
Receive
Route
- Network cost
- $7.22
- Provider fee
- $0.04
- Price impact
- -7.27%
- Max. slippage
- 0.30%
- Min. received
- 0.041028 ETH
The frame is the constant; its body is a mode-gated slot. Here, the Swap / Bridge / Gas state — a screen-header--main titled “Receive” (with the .refresh-timer in its trailing slot) above the .ui-quote-list, each row a selectable .ui-quote-card. In Limit the same frame instead holds .ui-token-info + .ui-price-chart; in Dual with no amount entered, a routes-idle .empty-state. .ui-receive-panel itself is a pure composition marker — it carries no CSS of its own; the surface, elevation, and radius all come from .panel.ui-card.
<!-- The receive-column frame. A pure composition marker over .panel.ui-card
(no own CSS rule). It lives inside the .widget-stage__receive aside,
which owns the column width per layout (Adaptive slides it in). The
header's trailing slot holds three CSS-gated controls; the body is a
single slot with three mode-gated contents. -->
<aside class="widget-stage__receive" aria-label="Available routes">
<article class="panel ui-card ui-receive-panel">
<header class="screen-header screen-header--main">
<h2 class="screen-header__title">Receive</h2>
<div class="screen-header__trailing">
<!-- refresh-timer — Swap / Bridge / Gas (countdown → refresh) -->
<button class="btn-icon btn-borderless btn-circle refresh-timer" data-interval="60000">…</button>
<!-- chart-token-tabs — Limit OR Dual, once both tokens are picked -->
<div class="seg seg-sm seg--borderless chart-token-tabs" data-chart-token-tabs>…</div>
<!-- expand-panel — Limit only (paired maximize) -->
<button class="btn-icon btn-neutral btn-sm btn-circle" data-action="expand-panel">…</button>
</div>
</header>
<!-- BODY (mode-gated slot, exactly one shows) -->
<!-- 1 · Limit — market reference (always renders in Limit) -->
<div data-limit-receive>
<div class="ui-token-info">…</div>
<div class="ui-price-chart">…</div>
</div>
<!-- 2 · Dual, no amount yet — honest idle state -->
<div class="empty-state" data-routes-idle>…</div>
<!-- 3 · Swap / Bridge / Gas (+ Dual w/ amount) — route comparison.
ui-quote-list.js → renderList() fills this; each row is a .ui-quote-card. -->
<div class="ui-quote-list"></div>
</article>
</aside>
| Property | Value | Source |
|---|---|---|
| Own CSS | None — pure composition marker | The class has no rule of its own; it only tags the frame so docs + JS can reference it. Surface character comes entirely from .panel.ui-card. |
| Surface | var(--surface-card) · var(--elev-1) | Inherits from .ui-card |
| Border radius | var(--card-radius) · 16 px | Inherits from .ui-card |
| Padding | var(--panel-padding) · 24 px | Inherits from .panel |
| Host + width | .widget-stage__receive aside · var(--w-7) 416 px (Swap / Bridge / Gas) · var(--w-16) 704 px (Limit + Advanced) | The panel fills its host aside; the aside owns the column width per layout (the Advanced + Limit tier widens it for the rich Orders table beneath). See Presentation. |
| Header | .screen-header.screen-header--main — title “Receive” | No back affordance (top-level frame). Mirrors the Orders panel header shape. |
| Trailing slot (CSS-gated) | .refresh-timer (Swap / Bridge / Gas) · .chart-token-tabs (Limit OR Dual) · expand-panel (Limit) | One .screen-header__trailing; widget mode decides which control shows. Same body-gated pattern as Widget modes. |
| Body slot (mode-gated) | .ui-quote-list (Swap / Bridge / Gas) · [data-limit-receive] → .ui-token-info + .ui-price-chart (Limit) · .empty-state[data-routes-idle] (Dual, no amount) | The frame is constant; exactly one body content renders per widget mode. |
| Quote-list renderer | ui-quote-list.js → renderList() into .ui-quote-list | Rebuilds the list innerHTML on every input change; route preview is wallet-optional (see CLAUDE.md → Quote preview is wallet-OPTIONAL). |
| Element | Class | Role |
|---|---|---|
| Panel root | .ui-receive-panel | Composes .panel.ui-card — surface, elevation, radius, padding inherited. A top-level receive-column frame (panel), not an inner card. Pure marker: no own CSS. |
| Host aside | .widget-stage__receive | The column the panel fills; owns width per layout and the slide-in transition in Adaptive. The panel is its sole child (plus the Limit-only Orders panel sibling). |
| Header | .screen-header.screen-header--main | Title (“Receive”) + a .screen-header__trailing. No back affordance. |
| Trailing — refresh | .refresh-timer | Swap / Bridge / Gas. Countdown → click-to-refresh; see .refresh-timer. |
| Trailing — chart tabs | .chart-token-tabs | Limit OR Dual, once both tokens are picked — switches the chart’s token (from / to). |
| Trailing — expand | [data-action="expand-panel"] | Limit only — the paired maximize that widens the column (mirrors the Orders panel control). |
| Body — route list | .ui-quote-list | Swap / Bridge / Gas (and Dual with an amount). The route-comparison list; each row a selectable .ui-quote-card. Carries the #ui-quote-list anchor; ui-quote-list.js is the render host. |
| Body — market ref | [data-limit-receive] | Limit. Holds .ui-token-info + .ui-price-chart — market reference, which renders whether or not an amount is typed. |
| Body — idle | .empty-state[data-routes-idle] | Dual, no amount. The persistent Dual panel shows an honest idle .empty-state (“Enter an amount to compare routes”) rather than the chart or faked routes. |
- Pure composition marker — no own CSS.
.ui-receive-panelexists only to name the frame for docs + JS; every visual property comes from.panel.ui-card. If you reach for a.ui-receive-panel { … }rule, that’s a smell — the styling belongs on.panel/.ui-card(chrome) or on the body primitive (content), not on the marker. - It’s a panel, not a card. A top-level column frame holding content takes the
-panelsuffix; an inner surface that sits inside a frame takes-card..ui-receive-paneland its sibling.ui-orders-panelwere named together in the June 2026 panel-vs-card pass. SeeCLAUDE.md → Panel vs card. - The body is a mode-gated slot; the frame is constant. One header, one body region, three contents — route list (Swap / Bridge / Gas), market chart (Limit), routes-idle empty-state (Dual, no amount). Don’t fork the frame per mode; gate the body content off the widget mode.
- The trailing slot is CSS-gated by mode too.
.refresh-timer(Swap / Bridge / Gas),.chart-token-tabs(Limit OR Dual),expand-panel(Limit) all live in one.screen-header__trailing; only the active mode’s control shows. The catalog Preview shows just the refresh-timer (the Swap / Bridge state) — the live switching lives inplayground.html(same body-gated pattern as Widget modes). - The route list is wallet-optional. Picks + an amount unlock the quote panel; a wallet is required only for execution. Don’t gate the list render on connection — see
CLAUDE.md → Quote preview is wallet-OPTIONAL. - The frame reads complete only with its body. A bare “Receive” header is half a component — the Preview deliberately shows the populated quote-list state so the panel reads as a finished surface (the standalone-completeness test). Document body contents via their own cards (quote card, token info, price chart), not by duplicating them here.
- Markup —
playground.html, searchui-receive-panel(the live instance inside the.widget-stage__receiveaside, with the.ui-quote-listrender host + the Limit[data-limit-receive]body). - CSS — none of its own. The frame is
.panel+.ui-card(styles.css/swap.css); column width + the slide-in live on.widget-stage__receive(swap.css— searchwidget-stage__receive); the body-content gating is keyed offbody[data-widget-mode](swap.css). - Renderer —
ui-quote-list.js(window.LifiQuoteList) →renderList()populates.ui-quote-list; selection +getReviewModel()travel with the one node. - Body primitives —
.ui-quote-card(route rows),.ui-token-info+.ui-price-chart(Limit market reference),.empty-state(routes-idle). - Naming — the panel-vs-card decision is documented in
CLAUDE.md → Panel vs card; the sibling frame is.ui-orders-panel.
Orders
Orders panel .ui-orders-panel
Limit-mode receive-column panel (a top-level frame, not an inner card) below Market Price. A 9-column .data-table--orders of the persona's limit orders — chain, pair name, amounts, limit, market, fill %, expiry, kebab. Scrolls horizontally via the .table-scroll primitive; the chain column pins.
Rich 9-column table · chain pins + scrolls when narrow
Orders
| Chain | Pair | Sell | Buy | Limit | Market | Filled | Expires | |
|---|---|---|---|---|---|---|---|---|
ETHUSDC |
0.800.80 ETH · $1,244 |
1,264.00 |
1,580 |
1,555 |
0% |
6 days |
||
USDCETH |
1,500 |
0.9804 |
1,530 |
1,555 |
37% |
3 days |
||
BTCUSDC |
0.05 |
2,970.00 |
59,400 |
59,319 |
100% |
|||
LINKUSDC |
800 |
6,800.00 |
8.50 |
7.21 |
0% |
Expired |
||
UNIETH |
1,200 |
2.04 |
0.00170 |
0.00154 |
0% |
Cancelled |
All nine columns at once; the table is wider than the 640 px card so the body scrolls horizontally (drag or shift-scroll to reveal the kebab). The identity is a pinned Chain column (the chain each order runs on — we support many) + a scrolling pair-name column: the fixed-width chain mark stays put while the variable-width SELL → BUY name scrolls with the values. The Expires column doubles as the lifecycle signal: active orders show the countdown, expired orders read “Expired” in muted grey, cancelled orders read “Cancelled” in --danger; filled orders stay blank because the 100% success ring already telegraphs it. (The retired dedicated Status column lived here as a separate .chip tier until June 2026 — the column-merge is the alternate cancelled-state integration.) The kebab here is a static placeholder — the live card opens a real .dropdown menu (see Markup).
<!-- 9-column rich table (pinned Chain mark + scrolling pair name, so the
horizontal scroll pins only the fixed-width chain mark). The article
carries all columns; the narrow
receive column scrolls the body horizontally (overflow-x: auto in
swap.css). data-disconnected (set by the orders wiring IIFE when
disconnected) and data-empty (set by orders-list.js when zero orders)
both HIDE the entire card. Rows render from window.LifiOrders via
orders-list.js — tbody[data-orders-list] is the render host; one row
(Open state) shown here. Every cell wraps its content in .data-cell, a
block-level flex box the <td> centres geometrically. -->
<article class="panel ui-card table-panel ui-orders-panel" data-limit-orders>
<header class="screen-header screen-header--main">
<h2 class="screen-header__title">Orders</h2>
</header>
<!-- the reusable .table-scroll primitive: viewport + body + < > nav + pinned col -->
<div class="table-scroll table-scroll--pinned"><div class="table-scroll__body">
<table class="data-table data-table--sm data-table--borderless data-table--rich data-table--orders">
<thead><tr>
<th>Chain</th> <!-- pinned chain mark --> <th>Pair</th> <th class="ta-r">Sell</th>
<th class="ta-r">Buy</th> <th class="ta-r">Limit</th>
<th class="ta-r">Market</th> <th>Filled</th>
<th>Expires</th>
<th aria-label="Row actions"></th>
</tr></thead>
<!-- Render host — orders-list.js fills this; order===null = skeleton path -->
<tbody data-orders-list>
<tr data-order-id="…">
<!-- Chain (pinned) | pair name (scrolls). Chain mark = a single avatar,
the hover/focus tooltip target carrying the chain name. -->
<td><div class="data-cell"><span class="tooltip-wrap"><img class="avatar avatar--sm avatar--circle" src="…ethereum.svg" alt="Ethereum" tabindex="0" aria-describedby="tt…"><!-- + .tooltip --></span></div></td>
<td><div class="data-cell"><span class="data-cell-text data-cell-text--bold ui-order-pair-label">ETH<svg class="ui-order-pair-arrow">…arrow-right…</svg> USDC</span></div></td>
<!-- Sell / Buy: number only — the value text IS the tooltip target
(.tooltip-wrap > .data-cell-text; symbol + fiat in the tooltip,
no info-icon button). Buy mirrors it, here without a tooltip. -->
<td class="ta-r"><div class="data-cell data-cell--end"><span class="tooltip-wrap"><span class="data-cell-text" tabindex="0" aria-describedby="tt…">0.80</span><!-- + .tooltip --></span></div></td>
<td class="ta-r"><div class="data-cell data-cell--end"><span class="data-cell-text">1,264.00</span></div></td>
<!-- Limit (emphasized) · Market (muted) -->
<td class="ta-r"><div class="data-cell data-cell--end"><span class="data-cell-text data-cell-text--bold">1,580</span></div></td>
<td class="ta-r"><div class="data-cell data-cell--end"><span class="data-cell-text data-cell-text--muted">1,555</span></div></td>
<!-- Filled: the fill % as plain .data-cell-text (no ring) -->
<td><div class="data-cell"><span class="data-cell-text">0%</span></div></td>
<!-- Expires: countdown (active only) · Status: chip + lifecycle tooltip -->
<td><div class="data-cell"><span class="data-cell-text data-cell-text--secondary">6 days</span></div></td>
<td><div class="data-cell"><span class="tooltip-wrap"><span class="chip chip-neutral" tabindex="0">Open</span><!-- + .tooltip --></span></div></td>
<!-- Kebab — a real .dropdown. The icon-only button is the trigger
(data-dropdown-trigger, a behavior-only hook — NOT a
.dropdown__trigger--* chrome variant); the menu opens via
the Popover API so it escapes .ui-card's overflow clip.
order id mirrored on the panel. Actions are status-gated + live. -->
<td class="ta-r"><div class="data-cell data-cell--end">
<div class="dropdown">
<button class="btn-icon btn-neutral btn-circle btn-xs" data-dropdown-trigger aria-haspopup="menu" aria-label="Order actions"><svg>…ellipsis…</svg></button>
<div class="dropdown__panel dropdown__panel--align-end" role="menu" popover="manual" data-order-id="…">
<ul class="menu">
<li><button class="menu-item" data-order-action="modify">…Modify limit…</button></li>
<li><button class="menu-item" data-order-action="repeat">…Repeat order…</button></li>
<!-- Destructive action is ALWAYS last -->
<li><button class="menu-item menu-item--danger" data-order-action="cancel">…Cancel order…</button></li>
</ul>
</div>
</div>
</div></td>
</tr>
<!-- N more rows, rendered by orders-list.js -->
</tbody>
</table>
</div></div>
<!-- No in-card empty-state markup. The wiring IIFE in playground.html
sets data-disconnected when the wallet is disconnected; orders-list.js
sets data-empty when the persona has zero orders. swap.css → "Body-
state hide" turns both into display:none on the article itself. -->
</article>
| Property | Value | Source |
|---|---|---|
| Surface | var(--surface-card) · var(--elev-1) | Inherits from .ui-card |
| Border radius | var(--card-radius) · 16 px | Inherits from .ui-card |
| Padding | var(--panel-padding) · 24 px | Inherits from .panel |
| Body edge bleed | margin-inline: calc(-1 * var(--panel-padding) + var(--space-12)) | Pulls table flush to --sm cell padding so “Pair” header aligns with the screen-header title |
| Columns | 9 — Chain · Pair-name · Sell · Buy · Limit · Market · Filled · Expires · kebab | All nine always rendered; the body scrolls horizontally (no width-gated column hiding). The identity is a pinned Chain mark + a scrolling pair-name column. |
| Horizontal scroll | the reusable .table-scroll primitive (.table-scroll--pinned) · table .data-table--orders at width: max-content; min-width: 100% | The 9-col table is wider than the narrow receive column (416 / 736 px), so it scrolls sideways inside .table-scroll — edge fades + < > nav + the pinned chain column. Table sized to content so columns keep their nowrap widths rather than compressing. |
| Table tier | .data-table--sm head · rows pinned to var(--space-56) (56 px) | Compact --sm head (--text-caption); each <td> is height-locked to the 40 px-avatar row tier (56 px) with an integer line-height so rows land on the 4-grid (no half-pixel avatar/text drift). See CLAUDE.md → Data-table row alignment. |
| Row components | .data-cell · .data-cell-text (+ --bold / --muted / --secondary / --danger) · .ui-order-pair-label | Per-cell content classes (promoted to swap.css June 2026 from the retired wide-panel page-local CSS). .data-cell is the geometric-centre flex box; .data-cell-text is the single text primitive every cell value uses, with weight + ink as its only variants; .ui-order-pair-label adds just the arrow gap + nowrap. The Filled cell is the fill % as plain .data-cell-text too (no ring) — every cell is a label. |
| Row value typography | --data-cell-text-size | One shared chassis — every cell value (amounts · market · expires · pair-name · the fill %) is the same .data-cell-text primitive, reading at this single token (--text-body 16px). Chassis = size + line-height:1 + tabular-nums; per-column ink + the two emphasis weights (pair, Limit at 600) are variant axes on top. Same single-source pattern as --ui-card-label-*. No cell carries a separate line-box, so the whole row centres on one line. |
| Table chrome | .data-table--borderless | Drops row borders; thead divider is a box-shadow inset (zero layout height) so the header stays an exact 32 px on the 4-grid |
| Sticky header surface | thead th { position: sticky; background: var(--surface-raised) } | The header pins while rows scroll under it, so it needs an OPAQUE backdrop matching the card’s own surface. The card is .panel → --surface-raised (NOT --surface-card); pinning the header to --surface-card painted a white band over the raised-grey card in light mode. Matching --surface-raised makes the header read as part of the card surface in both themes — opaque for scroll, invisible at rest. |
| Split — Stacked / fill (default) | body[data-limit-panel-split="fill"] | The cold-load default (fill is stamped statically on the playground <body>). The widget (form + view-rail) pins via position: sticky; Market Price is a capped band (min-height: 440px · max-height: min(var(--widget-max-h), 560px)); Orders grows at natural height (flex: 0 0 auto) and the page scrolls, so a short viewport keeps the form in view. |
| Split — ratio (opt-in) | chart flex: 2 1 0 · Orders flex: 1 1 0 | 2/3 chart · 1/3 Orders in the capped receive column; Orders scrolls internally past its slice (sticky thead). The opt-in alternate — no [data-limit-panel-split] attribute (the CSS base). |
| Paired maximize | .widget-stage[data-expanded] .widget-stage__receive { flex-basis: … } | An expand-panel control sits in BOTH the Market Price and Orders headers; clicking either toggles data-expanded and widens the receive column to fill the canvas. Both cards grow together — horizontal only, neither collapses, the 2/3 · 1/3 split is preserved. |
| Visibility gate | body[data-widget-mode="limit"] | Outside Limit mode the article is display: none — markup-resident, mode-hidden. The catalog forces the demo visible via inline style so it renders here (same body-gating pattern as Widget modes). |
| Body-state hide | [data-disconnected="true"] · [data-empty="true"] → display: none | Either flag hides the entire card. data-disconnected is set by the orders wiring IIFE in playground.html when the wallet is disconnected; data-empty is set by orders-list.js when getOrders() returns empty. The card only renders in the connected AND has-orders case. Paired-surface exception to the canonical wallet-scoped rule — see CLAUDE.md → Wallet-scoped surfaces gate their empty-state on connection. |
| Receive panel width (Limit + Advanced) | var(--w-16) · 704 px | Widened from var(--w-7) 416 px to give the rich table more visible width before the horizontal scroll kicks in (Presentation · design/components/swap.md → Advanced widget tier) |
| Renderer | orders-list.js → renderRow(order) | Single-source slot dispatch (one helper per column); order === null is the skeleton path. Reads window.LifiOrders; re-paints on persona-change. |
| Data source | window.LifiOrders (translator) | Pivots window.LifiTransactions (type === 'limit', persona-keyed) into the row shape. The tx.limit payload was extended (market / distancePct / filled / state) to feed every column; orders-data.js derives the relative expires text. See CLAUDE.md → Unified transactions data layer. |
| Element | Class | Role |
|---|---|---|
| Panel root | .ui-orders-panel | Composes .panel.ui-card — surface, elevation, radius, and flex layout inherited. A top-level receive-column frame (panel), not an inner card. Carries the body-state visibility gate. |
| Header | .screen-header.screen-header--main | Title (“Orders”) + a .screen-header__trailing hosting the paired expand-panel control. No back affordance. Mirrors the Market Price card’s heading + expand control. |
| Scroll viewport | .table-scroll | The reusable horizontal-scroll primitive (styles.css; .table-scroll). Non-scrolling positioned host; carries the edge-fade gradients + < > nav. The orders panel adds the edge-to-edge margin-inline + --surface-raised surface. |
| Table scroller | .table-scroll__body | The overflow-x: auto scroller wrapping .data-table.data-table--orders; the avatar column (col 1) pins via .table-scroll--pinned while the rest scrolls. |
| Table | .data-table.data-table--sm.data-table--borderless | --sm head + 56 px rows (<td> height-locked to var(--space-56)). width: max-content; min-width: 100% so columns keep their widths and the table overflows for the horizontal scroll. |
| Cell wrapper | .data-cell / .data-cell--end | Block-level flex box inside every <td>. The <td> centres it geometrically (integer math); --end right-justifies the numeric columns. Keeps avatar / text / chip on one centre line without a vertical-align nudge. |
| Chain cell (col 1) | .avatar.avatar--sm.avatar--circle | A single .avatar (32 px --sm tier) of the chain the order runs on — limit orders are same-chain, and we surface the chain (not the token logos the Pair column already names) because we support many. The fixed-width pinned identity column. The mark is wrapped in .tooltip-wrap (tabindex="0" + aria-describedby) so the logo-only cell reveals the chain name on hover/focus. |
| Pair — name cell (col 2) | .data-cell-text.data-cell-text--bold.ui-order-pair-label | The SELL → BUY name as the shared .data-cell-text primitive at --bold (the identifier weight); .ui-order-pair-label adds only the arrow gap + nowrap. The separator is a muted arrow-right Lucide icon (.ui-order-pair-arrow, --text-muted) so the symbols on each side keep primary ink and stand out. Scrolls with the value columns — slides under the pinned chain mark on scroll. |
| Sell / Buy cells | .tooltip-wrap › .data-cell-text | Number only; the value text itself is the .tooltip-wrap target (tabindex="0" + aria-describedby) — hover/focus reveals the symbol + fiat tooltip. No info-icon button (retired June 2026 to recover horizontal space). Right-aligned, tabular. Base .data-cell-text (primary ink, regular weight). |
| Limit / Market cells | .data-cell-text--bold / .data-cell-text--muted | Limit = the key trigger price, emphasized (--bold = semibold). Market = current market price, --muted ink. Both tabular, right-aligned. |
| Filled cell | .data-cell-text | The partial-fill percentage (0% / 37% / 100%) as plain row text — the same .data-cell-text label every other cell uses, so it shares their line-box and centres on the row's one centre line. (The .progress-ring meter was retired here June 2026 — its separate line-box was the odd one out for vertical alignment.) |
| Expires cell | .data-cell-text--secondary · --muted · --danger | Doubles as the lifecycle signal, all on the shared .data-cell-text chassis — only the ink modifier changes: active (open / partial) renders the relative countdown (“6 days”) at --secondary; expired renders “Expired” at --muted; cancelled renders “Cancelled” at --danger; filled stays blank because the 100% --success ring already telegraphs it. Same typography across every state — only ink changes (per CLAUDE.md → State changes shift ink, not weight). The renderer strips the data’s leading “in” on the countdown (the Expires header implies it). (June 2026 — this column-merge is the alternate cancelled-state integration that retired the dedicated Status column.) |
| Kebab cell | .dropdown › [data-dropdown-trigger] | The kebab is a real .dropdown: a regular (chromed) .btn-icon.btn-neutral.btn-circle.btn-xs trigger carrying the horizontal ellipsis icon, marked data-dropdown-trigger, opening a .menu via the Popover API so it escapes .ui-card’s overflow: hidden. The menu is status-gated: active orders (open / partial) offer Modify limit + Cancel order (danger, last); terminal orders (filled / expired / cancelled) offer Repeat order. Order id mirrored on the panel. All three actions are live — Modify + Repeat open the prefilled modal (#ui-modify-order), Cancel routes through a destructive confirm sheet; all mutate the source then re-render. |
| Hide flags | [data-disconnected="true"] / [data-empty="true"] | Either flag hides the entire card via swap.css → "Body-state hide". data-disconnected set by the orders wiring IIFE in playground.html (reads body[data-playground-wallet-connected]); data-empty set by orders-list.js when getOrders() returns empty. Card renders only in the connected AND has-orders case. Paired-surface exception to the canonical wallet-scoped rule — the form panel beside it already carries a primary “Connect wallet” CTA, so an in-card prompt would duplicate it. See CLAUDE.md → Wallet-scoped surfaces. |
| Render host | tbody[data-orders-list] | The <tbody> orders-list.js fills. renderList() queries it on load + on persona-change; renderRow(order) with order === null stamps the skeleton path. |
| Card marker | [data-limit-orders] | Marker on the article (the orders-card instance). Markup-resident in every widget mode; CSS hides the card outside Limit. |
Horizontal scroll via the .table-scroll primitive. The full table is wider than the narrow receive column, so it scrolls horizontally inside .table-scroll (.table-scroll--pinned for the avatar column); .table-scroll__body sets overflow-x: auto and the .data-table--orders table is width: max-content; min-width: 100% so columns keep their natural widths rather than compressing. Collapsed, the panel hides the analytical Market column; the expand control reveals all nine — see Orders row.
Vertical distribution — ratio vs. fill. Orders is the second flex child of the Limit-mode receive column, stacked under the Market Price chart. Two models split the column height, switched by body[data-limit-panel-split] (the Workshop rail’s Market panel selector was cleared June 2026 once Stacked settled as the choice):
- Stacked (default,
body[data-limit-panel-split="fill"]). The widget (form + view-rail) pins viaposition: sticky; Market Price sits in a capped band (min-height: 440px,max-height: min(var(--widget-max-h), 560px)— generally aligned with the widget but a touch shorter); Orders grows below at natural height (flex: 0 0 auto) and the page scrolls. On a short viewport the widget stays put on the left while the Market Price + Orders panels scroll. The cold-load default (fillis stamped statically on the playground<body>). - Ratio (opt-in, no attribute). Chart
flex: 2 1 0, Ordersflex: 1 1 0— a 2/3 · 1/3 split inside the capped column. Orders scrolls internally past its slice with a stickythead, so the order count is uncapped without growing the widget.
The paired expand-panel maximize composes on top of either model: a control in both the Market Price and Orders headers sets data-expanded on the stage, which widens the receive column horizontally to fill the canvas. Both cards grow together — the maximize doesn’t touch the vertical split or collapse either card. Clicking either control (or minimize) toggles the whole column.
- Rows are pinned to the 56 px tier; the head stays
--sm. The rich row carries a 32 px.avatarchain mark, so each<td>is height-locked tovar(--space-56)with an integer line-height — the row lands on the 4-grid and the avatar / text / chip stay on one centre line (the.data-cellgeometric-flex fix; seeCLAUDE.md → Data-table row alignment). The--smhead tier is retained for the column labels. Don’t reach formin-heightorvertical-align: middlealone — both leave the rendered row off-grid. - Status column retired June 2026. The dedicated chip-in-a-column treatment is gone; the lifecycle field lives on as
tx.limit.state(still decoupled from the universaltx.status). The cancelled-state integration is moving to a different surface — TBD; the dormantSTATUS_MAP+statusTooltiphelpers inorders-list.jsare preserved for whichever shape it takes. - The kebab opens a real dropdown; the actions are status-gated + live. The trigger is a regular (chromed)
btn-icon.btn-neutral.btn-circle.btn-xscarrying the horizontalellipsisicon, markeddata-dropdown-trigger(the behavior-only hook, NOT a.dropdown__trigger--*chrome variant — seeCLAUDE.md → Icon-only / standard-component triggers). It opens a.menuvia the Popover API so the panel escapes.ui-card’soverflow: hidden. The menu is gated by lifecycle: active orders (open / partial) offer Modify limit + Cancel order; terminal orders (filled / expired / cancelled) offer Repeat order. The destructive Cancel order is always the last item. Modify + Repeat open the prefilled stepped modal (#ui-modify-order) — Modify runs the cancel-and-replace flow (cancelLimit→createLimit), Repeat creates a fresh order viacreateLimit; Cancel routes through a destructive confirm sheet (#cancelOrderModal) →cancelLimit. All three mutate the source thenLifiOrdersPanel.render(). - Limit-mode only. The card is markup-resident in every widget mode (Bridge / Swap / Limit / Gas) but CSS hides the article outside Limit via
body:not([data-widget-mode="limit"]) .ui-orders-panel { display: none }. Swap and Bridge never need a placed-order surface (they settle in one tx); Gas is destination-fixed by definition. Same shape as.ui-limit-price-card’s mode-gating. - Nine columns, horizontal scroll — baseline. The June 2026 migration folded the disposable full-width
.ui-orders-widebaseline into this card and dropped the prior width-gated 5-vs-7-column hiding. All nine columns always render; the narrow receive column scrolls the body horizontally (overflow-x: auto+ tablewidth: max-content). The Expires column doubles as the lifecycle signal (active = countdown, expired = muted, cancelled = danger, filled = blank). This is a WIP baseline — a future pass may re-introduce responsive column priority, but don’t re-add:nth-childcolumn hiding without surfacing it; the scroll is the agreed interim. - Two market-panel split models — Stacked is the default. Stacked (
body[data-limit-panel-split="fill"], the cold-load default) pins the widget (form + view-rail) on the left while Market Price (a capped 440–560 px band) and Orders (natural height) stack on the right and the page scrolls — a short viewport keeps the form in view as the panels travel. Ratio (the opt-in alternate, no attribute) holds both cards in the capped receive column at 2/3 chart · 1/3 Orders, with Orders scrolling internally under a sticky header — reach for it when the order count should stay glanceable beside a still-readable chart without page scroll. Both keep Orders in view; neither buries it. The pairedexpand-panelmaximize composes on top of either, widening both cards horizontally without changing the vertical model. - Card-hide is the empty-state recipe — June 2026 exception to the canonical “Wallet-scoped surfaces” rule. The article hides ENTIRELY when there’s nothing honest to show:
data-disconnected="true"(wallet disconnected) anddata-empty="true"(connected but zero orders) both triggerdisplay: none. Only the connected AND has-orders case renders. No in-card connect-prompt, no first-time zero-state. The 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 prompt would duplicate the affordance with no new information, and a generic zero-state would add visual weight to a panel that has nothing to tell. The card adapts to the data it has; when there’s no data, it disappears cleanly. The standalone Activity feed (FAB sub-panel) remains the reference for the canonical connect-prompt pattern — it has no paired always-visible card and no other primary Connect CTA in the same surface, so the canonical rule still applies there. PerCLAUDE.md → Wallet-scoped surfaces gate their empty-state on connection. - Connection is a playground concern, not the renderer’s.
data-disconnectedis set by the orders wiring IIFE inplayground.html(readingbody[data-playground-wallet-connected]);orders-list.jsstays a pureLifiOrders → tbodyrenderer that only ever setsdata-empty. Both flags sit on the<article class="ui-orders-panel">itself, so the hide rule stays a one-attribute read in CSS. (The Preview snapshots above show only the visible state — hidden is the absence of the snapshot, by design.) - Rows are live-rendered per persona via a single-source renderer.
orders-list.js → renderRow(order)dispatches each column through slot helpers;order === nullis the skeleton path, so the real and loading shapes can’t drift (perCLAUDE.md → Skeleton + shimmer). Data comes fromwindow.LifiOrders, a translator over the unifiedwindow.LifiTransactionsmock (type === 'limit', persona-keyed); apersona-changeevent re-paints the list. Don’t hand-author static rows in the markup — thetbody[data-orders-list]is the render host.
- Markup —
playground.html, searchui-orders-panel(the live[data-limit-orders]instance under.widget-stage__receive, with thetbody[data-orders-list]render host). - CSS —
swap.css: searchOrders panel(visibility gate, negative-margin edge bleed) +styles.css.table-scroll(the scroll affordance),Orders body scrolls(the vertical-scroll body + the stickytheadpinned to--surface-raisedso the header matches the card surface, no white band),Row geometry(the 56 px row +.data-cellgeometric centring + the.ui-order-*content classes, promoted June 2026 from the wide-panel page-local CSS),Body-state hide(thedata-disconnected/data-empty→display: nonecontract),Market-panel split(ratio + fill models),Advanced widget tier(the receive widening), andPaired-panel expand affordance(the maximize that widens both cards horizontally). - Renderer —
orders-list.js(window.LifiOrdersList). Single-sourcerenderRow(order)—order === nullis the skeleton path; ten column slot helpers dispatch onopts.isSkel.renderList()stampsdata-emptyon the card when the list is empty. - Data —
orders-data.js(window.LifiOrders) filterswindow.LifiTransactions(lifi-transactions-data.js) totype === 'limit'for the active persona and pivots the extendedtx.limitpayload (triggerPrice/market/distancePct/filled/state/expiresAt) into the row shape, deriving the relativeexpirescountdown. The schema extension lives inlifi-transactions-data.js(search thetx.limit payloadheader). SeeCLAUDE.md → Unified transactions data layer. - Kebab actions —
playground.html, a delegated[data-order-action]click handler:modify→LifiModifyOrder.open(id),repeat→LifiModifyOrder.open(id, { mode: 'repeat' }),cancel→ the#cancelOrderModalconfirm sheet →LifiTransactions.cancelLimit. Order id is read off the.dropdown__panel(the top-layer popover is hoisted out of the<tr>). Controller + mutations:modify-order.js(two modes, stepped) +lifi-transactions-data.js(cancelLimit/createLimit). - Migration — the disposable full-width baseline (
.ui-orders-wide+orders-table-w4.js) was retired June 2026; its design folded into this card and wired to real data. History indesign/components/orders-table.md. - Spec —
design/components/orders-table.md(this table) anddesign/components/swap.md— searchMarket-panel split(the two distribution models) andAdvanced widget tier(the receive widening).
Orders row
One order row — a single fixed column composition rendered through the shared renderRow from shared fixtures, the same renderer the playground uses.
The .ui-orders-panel table row is a single fixed composition — the 9 columns below, in COLUMN_ORDER, at --text-body (16px) type, the orders-table base look; the fill column is a plain % label. It's rendered live through window.LifiOrdersList.renderRow(order) from shared fixtures — the same renderer the playground uses, so the catalog can't drift from the product. (The row used to be a ROW_RECIPES registry with a default / compact A/B picker; both the picker and the registry were retired June 2026 once it dropped to one recipe — CLAUDE.md → Named-recipe registry.)
9 columns · --text-body labels
You write a plain table shell; renderList re-heads the thead[data-orders-head] and renders the tbody from COLUMN_ORDER. The renderer owns all the column markup.
<!-- The table host. The renderer fills head + body. -->
<table class="data-table data-table--sm data-table--borderless data-table--rich data-table--orders">
<thead data-orders-head></thead>
<tbody data-orders-list></tbody>
</table>
The row composition is one ordered array of column ids in orders-list.js; reorder or add columns here (a new id also needs a COLUMNS entry). The dormant pair-stacked alternative is swapped in here to use it:
var COLUMN_ORDER = ['chain', 'pair-name', 'sell', 'buy', 'limit', 'market', 'filled', 'expires', 'actions'];
Row
| Property | Value |
|---|---|
| Columns | 9 (COLUMN_ORDER) |
| Value type | --text-body (--data-cell-text-size) |
| Fill column | fill % as plain .data-cell-text (no meter) |
| Row height | var(--space-56) |
Column vocabulary COLUMNS
| Column id | Header | Align |
|---|---|---|
chain | Chain | left |
pair-name | Pair | left |
pair-stacked | Pair (dormant alt) | left |
sell · buy | Sell · Buy | right |
limit · market | Limit · Market | right |
filled | Filled (fill % label) | left |
expires | Expires | left |
actions | (blank — Row actions) | center |
The row is one fixed composition on two concerns:
- Structural —
COLUMN_ORDER. An ordered list of column ids: which cells render, in what order, and which pair representation (a pinnedchainmark + apair-namecolumn, vs. the dormant singlepair-stackedcell). The renderer emits this markup; each id maps to a{ head, align, slot }entry in theCOLUMNSmap. - Presentational — the orders-table base look.
--text-body(16px) value type (the single--data-cell-text-sizetoken on.data-table--orders, which every value span inherits, including the fill%). No per-row modifier class — this size IS the base.
Both flow through one renderer — renderRow(order) iterates COLUMN_ORDER via the COLUMNS slot map; renderHead() builds the matching <thead>; renderList re-heads + re-renders together. order === null is the skeleton path, so the skeleton column count tracks COLUMN_ORDER automatically (single-source per CLAUDE.md → Skeleton + shimmer).
Row height stays var(--space-56) on the 4-grid; the avatar tier is unchanged.
- Reorder / add a column in
COLUMN_ORDER. One array inorders-list.jsdrives both head and body. A new column also needs oneCOLUMNSentry mapping its id to{ head, align, slot }— reuse the existing slot helpers. - Identity = pinned
chain+pair-name. The first column pins the order's chain mark on horizontal scroll; the pair name (SELL → BUY) scrolls with the values. The dormantpair-stackedcell is an alternative single-cell pair representation (two text lines, no chain mark) — swap it in forpair-nameto use it. - Filled is a label, not a meter. The
filledcolumn renders the fill%as plain.data-cell-text— every cell is a label, so the row shares one line-box. (The ring + the dormant linear-bar alternative were both retired June 2026; the universal.progressbar primitive is unchanged.) - Skeleton is automatic. Because the skeleton path shares
renderRow, the loading state tracksCOLUMN_ORDERwith no extra work. Don't hand-author a parallel skeleton. - If a second composition is ever needed, bring back a registry. The row was a
ROW_RECIPESregistry until it dropped to one entry (June 2026); a registry earns its keep at 2+ compositions — see CLAUDE.md → Named-recipe registry.
- Renderer —
orders-list.js→COLUMN_ORDER+COLUMNS,renderRow(order),renderHead(),renderList. Public API:window.LifiOrdersList.renderRow / renderHead / renderList. - Styles —
swap.css→ searchOrder rowfor the dense base type (.data-table--orders→--data-cell-text-size) + the pair-separator arrow, and.ui-order-pair-stacked(the dormant stacked pair). - Live consumer —
playground.html→ the Limit-mode.ui-orders-panelrenders the order row viarenderList. - Catalog hydration —
design-system/ds-orders-rows.jsrenders this demo from static fixtures through the shared renderer. - Spec —
design/components/orders-table.md→ The order row.
Modify order modal .ui-modify-card
Stepped cancel-and-replace flow from the Orders kebab — edit → cancel → place; repeat skips the cancel. Pair locked.
The demos are static snapshots of the three modify-mode steps — the live stepped modal opens from the Orders-card kebab (“Modify limit” / “Repeat order”) in playground.html, prefilled from the clicked order. Repeat runs the same machinery as a 2-step flow (no cancel step). Shown for a partial order (45 % filled).
Step 1 · Edit terms
Modify limit order
Step 2 · Cancel order — destructive confirm
Cancel current order
Updating a limit order replaces it: the current order is cancelled first, then the new one is placed. Confirm the cancellation to continue.
- Order
- ETH → USDC
- Limit price
- 1 ETH = 4,000 USDC
- Already filled
- 45% — stays settled
- Unfilled amount
- 1.1 ETH
- Would expire in
- 12 days
This can't be undone. The current order stops filling immediately; any portion already filled stays settled.
Step 3 · Place order — receipt of the edited terms
Place new order
Current order cancelled. Place the new order to complete the update.
Review the new order. It becomes active as soon as it's placed.
- Order
- ETH → USDC
- Limit price
- 1 ETH = 4,000 USDC
- Sell
- 1.1 ETH
- Buy (est.)
- 4,400 USDC
- Expires in
- 30 days
Steps 2 + 3 confirm real transactions — the cancel signature lands before the place step, so there is no Back past step 2 in modify mode. Repeat mode shows a 2-disc stepper (Edit terms · Place order) and keeps Back on the place step.
<!-- Opened by modify-order.js → window.LifiModifyOrder.open(orderId, { mode }).
Per-step chrome (stepper · title · footer) is JS-painted into the static
hooks; the step panels toggle via [hidden]. The inner .ui-card
[data-standalone] scopes findReferenceTokens to the locked pair AND opts
out of the compose-widget bootstrap. -->
<div class="modal-overlay" id="modifyOrderModal" hidden>
<div class="modal panel" role="dialog" aria-modal="true">
<div class="ui-modify-steps" data-modify-stepper><!-- .steps--circles-inline — JS-rendered per step --></div>
<header class="screen-header screen-header--main">…per-step title…</header>
<div class="modal-body">
<!-- STEP · edit — the prefilled, constrained Limit form -->
<section class="ui-modify-panel" data-modify-panel="edit">
<div class="ui-card ui-modify-card" data-standalone data-modify-scope>
<div class="ui-limit-price-card" data-denom="quote-per-base" data-limit-standalone>…</div>
<div class="ui-modify-pair">
<!-- Sell — editable amount + locked .chip-avatar--locked[data-role="from"]
+ a fiat-only .ui-amount-card__footer (Sell × unit price) -->
<!-- Buy — derived amount + locked .chip-avatar--locked[data-role="to"]
+ a fiat-only .ui-amount-card__footer (Buy × unit price) -->
</div>
</div>
<div class="setting-row setting-row--sm">…Expires in dropdown…</div>
</section>
<!-- STEP · cancel — confirm cancelling the CURRENT order (modify mode only) -->
<section class="ui-modify-panel" data-modify-panel="cancel" hidden>
<p class="modal-desc">…</p>
<dl class="detail-list detail-list--divided" data-modify-cancel-summary></dl>
<div class="alert alert--warn alert--sm alert--no-dismiss">…can't be undone…</div>
</section>
<!-- STEP · place — confirm the NEW order (receipt of the edited terms) -->
<section class="ui-modify-panel" data-modify-panel="place" hidden>
<div class="alert alert--success alert--sm alert--no-dismiss" data-modify-cancelled-note hidden>…</div>
<p class="modal-desc">…</p>
<dl class="detail-list detail-list--divided" data-modify-place-summary></dl>
</section>
</div>
<div class="action-bar" data-modify-footer><!-- footer — JS-rendered per step --></div>
</div>
</div>
Full markup in playground.html (search modifyOrderModal). The locked chips are non-button <span>s carrying data-role/data-symbol/data-chain — they feed the price card's rate calc but never open the picker. Footer per step: Cancel · Continue (edit) / Back · Cancel order (cancel, destructive) / Close or Back · Place order (place); an in-flight signature swaps the primary for a disabled .spinner button.
| Token / value | Resolves to | Role |
|---|---|---|
| .modal max-width | var(--w-9) · 480px | Modal panel width (shared .modal default). |
| .modal-body gutter | padding 0 var(--space-8) + matching negative margin | Net-zero horizontal shadow gutter — the body is overflow:auto, which clips the card stack's --elev-1 shadow at the box edge; the padding gives it room while the negative margin keeps content flush with the modal chrome. Same precedent as .list--cards. |
| .ui-modify-steps margin-top | var(--space-8) · 8px | Stepper glue above the modal title (the portal-wizard composition); the .steps--circles-inline primitive is universal in styles.css. |
| .ui-modify-panel gap | var(--cluster-sm) · 8px | Step-panel stack rhythm (desc · receipt · alert); panels toggle via [hidden], so the rule re-asserts [hidden] { display: none }. |
| .ui-modify-card gap | var(--space-12) · 12px | Stack rhythm between price card, pair, and alert. |
| .ui-modify-pair gap | var(--gap-card-sm) · 8px | Locked Sell / Buy card-to-card gap (card-gap ladder). |
| .ui-modify-card overflow | visible | Overrides the base .ui-card's hidden (viewport-cap scroll, irrelevant in a modal) so the inner cards cast shadows. |
| data-market-override | order's tx.limit.market | Pins the price card's proximity market to the order's universe (the wallet-sim's prices differ), so presets / the off-market alert read correctly. |
| Part | Class | Notes |
|---|---|---|
| Stepper | .ui-modify-steps › .steps--circles-inline | JS-rendered per step into [data-modify-stepper], above the title — the portal-wizard composition of the universal .steps primitive. 3 discs in modify (Edit terms · Cancel order · Place order), 2 in repeat; completed discs flip to a check. |
| Step panels | .ui-modify-panel[data-modify-panel] | One <section> per step in the shared .modal-body, toggled via [hidden] by renderStep(); the shared .modal fluid-resize tweens the height between steps. |
| Confirm receipts | .detail-list--divided | Two JS-painted summaries on the .detail-list primitive: [data-modify-cancel-summary] (the CURRENT order's live terms, re-read at render) + [data-modify-place-summary] (the edited terms). Rates render volatile-as-base (1 ETH = 4,000 USDC) — the activity-translator convention. |
| Footer | .action-bar[data-modify-footer] | JS-rendered per step — two equal-width buttons; the primaries are plain labels (no trailing arrow). An in-flight signature swaps the primary for a disabled .spinner button (“Cancelling order…” / “Placing order…”). |
| Scope wrapper | .ui-modify-card | A bare .ui-card (no .panel, no surface) — exists only to scope findReferenceTokens to the locked pair and carry the [data-standalone] opt-out. |
| Limit price | .ui-limit-price-card | The canonical primitive, reused as a second instance. Wired by modify-order.js via LifiLimitPriceCard.wireCard() — not the module's global init(). |
| Locked pair | .ui-modify-pair | Two .ui-amount-cards stacked (no FAB seam-arrow — modifying can't flip direction). Sell amount editable; Buy derived (Sell × price). |
| Locked chip | .chip-avatar--locked | Non-button <span> on the .chip-avatar locked modifier — transparent fill, hairline outline, muted label, pointer-events:none. Shows the order's pair; never opens the picker. |
| Fiat footer | .ui-amount-card__footer › .ui-amount-card__fiat | Fiat only — the locked-pair variant drops the standard card's balance / MAX / price-impact slots (it edits an order, not a wallet flow). The value tracks amount × the order's stored unit price (from.usd/from.amount, to.usd/to.amount), repainted by recomputeBuy() on every Sell / price edit. |
| Expiry | .dropdown | Own [data-modify-expiry-*] hooks (NOT the compose widget's, which share a localStorage key). Selection is controller-managed in memory. |
- Modify IS cancel-and-replace — a 3-step flow. A limit order’s terms can’t change in place on-chain, so the modal walks the reality: Edit terms → Cancel order → Place order. The cancel confirm is a real transaction — the old row flips to Cancelled in the Orders table behind the modal, there is no Back past it, and closing the modal afterwards leaves the old order cancelled, exactly as on-chain.
- Repeat skips the cancel — a 2-step flow on the same controller.
open(id, { mode: 'repeat' })(terminal orders) seeds the form with the order’s terms; nothing has been committed before the place confirm, so Back from the place step IS allowed. Title flips to “Repeat order”; not a separate modal. - The pair is locked. Changing the pair would be a new order, not a modify. The token chips render populated + inert; only price, amount, and expiry are editable on the edit step.
- Sell prefill differs by mode. Modify prefills the remaining amount (
amount × (1 − filled/100)) with a45% filledline above it (the info-icon tooltip explains that editing changes only the unfilled remainder; hidden whenfilled === 0); Repeat prefills the full original amount (a fresh order — filled is moot) and snaps expiry to the order’s original duration. - Status-gated entry. The Orders kebab offers Modify + Cancel for active orders (open / partial), and Repeat for terminal orders (filled / expired / cancelled) — see
.ui-orders-panel. - Both confirms write through the source. The cancel step calls
LifiTransactions.cancelLimit(id), the place stepcreateLimit(srcId, patch)(clones the source’s identity into a freshopenorder) — each mutates the source, thenLifiOrdersPanel.render(). Activity + tx-details stay consistent — per CLAUDE.md → Unified transactions data layer. (updateLimithas no caller since the stepped flow — kept as data-layer API.) - Price direction is reference-side aware (mirrors
orders-data.js → refIsFrom). The fixture’striggerPrice/marketare the reference (volatile) side’s price, soopen()seeds the price card’s denom by direction (volatile→stable =quote-per-base; stable→volatile =base-per-quotewith thedata-market-overrideinverted).placeOrder()stores the trigger back in the order’s own convention plus a signeddistancePct, and the receipts render the rate volatile-as-base (1 WBTC = 92,000 USDC). - Reuse without re-wiring. The price card reuses the canonical primitive's per-card methods (
render/renderProximity/wireCard); the[data-standalone]/[data-limit-standalone]opt-outs keep the compose widget's bootstrap and document iterators from grabbing the modal instance.
- Controller —
modify-order.js(window.LifiModifyOrder).open(orderId, { mode })('modify'3-step /'repeat'2-step) prefills + renders; per-step chrome is painted into[data-modify-stepper]/[data-modify-footer]/ the two[data-modify-*-summary]hooks byrenderStep(); the confirm handlers run a simulated signature, then mutate + re-render. - Markup —
playground.html, searchmodifyOrderModal(+cancelOrderModalfor the destructive confirm); kebab wiring is the delegated[data-order-action]handler. - Kebab gating —
orders-list.js→slotKebabCell(active → Modify + Cancel; terminal → Repeat). - Price-card reuse —
ui-limit-price-card.js:wireCard(exported), thedata-market-overridehook ingetMarketRate, and the:not([data-limit-standalone])guards on the 3 document iterators. - Mutations —
lifi-transactions-data.js→cancelLimit(the cancel confirm; also backs the kebab’s standalone cancel sheet) /createLimit(the place confirm; repeat uses it alone).updateLimithas no caller since the stepped flow — kept as data-layer API. Bootstrap opt-out:swap.jsinit()skips.ui-card[data-standalone]. - CSS —
swap.css, searchModify-order modal(.ui-modify-card/.ui-modify-pair/.ui-modify-steps/.ui-modify-panel+ the net-zero shadow gutter). The stepper primitive isstyles.css→.steps--circles-inline; locked chips use.chip-avatar--locked(styles.css).
Transaction summary .ui-tx-summary
The shared from→to flow card — every slot optional. Serves Review, the execution recaps, Activity, and Transaction details.
One card, two screens. The Activity list renders .ui-tx-summary--interactive items (the whole card opens Transaction details); the Transaction-details top card renders the base, non-interactive variant plus the rate + gas __foot. The chevron expands the Route inline in both. Composes .list-item, .avatar tandems, and .detail-list.
Route expanded · .ui-tx-summary
- Network cost
- <$0.01
- Price impact
- 0%
- Exchange rate
- 1 USDC ≈ 1 USDC
- Estimated time
- 0s
<!-- Activity list item: add --interactive + role=button (div root, NOT button,
so it can host the chevron button child — see Rules) -->
<div class="card ui-tx-summary ui-tx-summary--interactive" role="button" tabindex="0">
<div class="ui-tx-summary__head">
<span class="ui-tx-summary__date">March 31, 2026</span>
<span class="ui-tx-summary__time">6:49 AM</span>
</div>
<div class="ui-tx-summary__flow">
<div class="list-item ui-tx-summary__row"> <!-- from -->
<span class="list-item__lead"><!-- avatar-tandem --></span>
<div class="list-item__body">
<div class="list-item__title ui-tx-summary__amount">4</div>
<div class="list-item__desc">$4.00 · USDC on Base</div>
</div>
</div>
<div class="ui-tx-summary__connector"><!-- ↓ --></div>
<div class="list-item ui-tx-summary__row"> <!-- to + chevron in __trail --></div>
</div>
<div class="ui-tx-summary__detail" hidden>
<div class="ui-tx-summary__detail-head"><!-- Route + provider --></div>
<dl class="detail-list"><!-- Network cost / Price impact / Rate / ETA --></dl>
</div>
<!-- Transaction details only: rate + gas foot fed from the route rows -->
<div class="ui-tx-summary__foot">
<span class="ui-tx-summary__rate">1 USDC ≈ 1 USDC</span>
<span class="ui-tx-summary__foot-trail"><!-- .ui-quote-metric--fee pill --></span>
</div>
</div>
Rendered by swap.js → initActivity() via a single txSummaryInner(tx, inList) builder — the Activity list and the Transaction-details card share one source so the markup can't drift.
| Token | Value | Applies to |
|---|---|---|
| --card-radius | 16 px | Card surface (inherits .card) |
| gap | --space-12 | Head / flow / detail stack |
| --text-h3 | 24 px | .ui-tx-summary__amount (display tier) |
| --text-caption | 12 px | .ui-tx-summary__head (date / time) |
| --space-48 | 48 px | .ui-tx-summary__connector width (avatar column) |
| avatar-tandem--lg | 48 px | Lead token avatar |
| Element | Role |
|---|---|
| .ui-tx-summary | Card root (composes .card) |
| __head | Date (left) + __time (right), caption tier |
| __flow | From row · __connector (↓) · To row |
| __row | A .list-item with padding-inline cancelled to align to the card edge |
| __amount | The row headline number, bumped to the --text-h3 display tier |
| __toggle | Chevron in the to-row .list-item__trail; flips __detail[hidden] |
| __detail | Collapsible Route: __detail-head (label + provider mark, or the Review screen's route .avatar-stack at 20 px) + .detail-list |
| __foot | Optional rate + metrics line: __rate (left, tabular) + __foot-trail (.ui-quote-metric pills) — Review (rate + gas + eta) and Transaction details (rate + gas, from the route rows); Activity list omits it |
| --interactive | Whole card opens Transaction details (hover = canonical UI-card 3% accent + elev-2) |
Screen composition. The Activity screen is a .ui-activity-list of --interactive cards. Transaction details stacks one base card + a Receipts card (.status-row rows) + a Transfer ID card.
- One slot vocabulary, four surfaces (June 2026 convergence). Activity / Transaction details render the full anatomy; the Review summary card composes it headless (+
__detailfee rows + the__footrate/metrics line); the execution flow's.ui-tx-pairhosts a bare__flowand.ui-tx-receiveda bare__row. Every slot is optional — the review-only.ui-review-row/.ui-review-summary__*/.ui-review-detail*glue was retired into these slots, atomic, no aliases. - One builder for the rendered screens. The Activity list and the Transaction-details card render from
txSummaryInner(tx, inList)— single source, no drift.inListswaps the to-row meta (provider name vs. destination chain) and gates the__foot(details only). The review card is static markup populated via[data-review-ref]slots. --interactiveroot is a<div role="button">, never a<button>. The card hosts the chevron<button>child; a button root would be auto-closed by the HTML parser (see Card root element rule in CLAUDE.md). Keyboard activation (Enter / Space) is wired in JS.- Flow tandems carry the chain badge only. One
--brchain badge per token avatar — never a second--blbridge/provider badge (two corner marks overload the avatar). The provider reads from the to-row meta (“· Eco”) and the Route__detail-headmark. The dual-badge tandem stays a documented.avatar-tandemoption for future needs. - Chevron stops propagation. The toggle expands the Route inline without triggering the card's navigation.
- Receipts reuse
.status-row. Don't author a bespoke receipt row — the canonical status-row (icon / label / action,--success/--info) already covers it. - In-widget state, not a modal. Activity + Transaction details are
data-stateon the.ui-card(siblings ofreview/settings), each with a prev-state stash for back-nav.
- CSS:
swap.css— search.ui-tx-summary(primitive) and.ui-activity__body/.ui-tx-details__body(screen glue). - Markup:
playground.html—data-state-content="activity"anddata-state-content="tx-details". - JS:
swap.js → initActivity()(render + nav);activity-data.js(window.LifiActivitymock log).
Execution status .ui-tx-status
The widget's execution screen — hero mark + status copy + progressive step checklist. data-tx-outcome drives running / success / partial / failed.
One pane, four outcomes — data-tx-outcome on the .ui-tx wrapper picks the hero mark (80 px .spinner ring while running, .spot-icon-4xl --round tone disc on outcome), the lower card (from/↓/to pair vs Received), and the footer pair (See details + Done vs Remove + Retry). Steps are .status-row rows appended as the execution advances. Try it live on the playground — ⌥-click the Review CTA forces the failed path, ⇧-click forces partial.
Success · data-tx-outcome="success" — disc + steps + Received + See details / Done
<!-- data-tx-outcome on the wrapper is the single visual-state switch:
running | success | partial | failed (gating lives in swap.css) -->
<div class="ui-tx" data-state-content="tx-progress" data-tx-outcome="running">
<header class="screen-header"><!-- back + mode title --></header>
<div class="ui-tx__body">
<div class="card ui-tx-status" aria-live="polite">
<span class="ui-tx-status__icon">
<!-- 80px .spinner (running) + three .spot-icon-4xl --round tone discs,
each [data-tx-disc="success|partial|failed"] — CSS shows ONE -->
</span>
<div class="ui-tx-status__text">
<div class="ui-tx-status__title">Waiting for wallet signature confirmation</div>
<p class="ui-tx-status__desc" hidden></p>
</div>
<div class="ui-tx-status__steps"><!-- progressive .status-row rows --></div>
</div>
<div class="card ui-tx-received"><!-- "Received" .ui-card-label + token row (success / partial) --></div>
<div class="card ui-tx-pair"><!-- from / ↓ / to recap (running / failed) --></div>
</div>
<div class="action-bar ui-tx-actions--done"><!-- See details + Done --></div>
<div class="action-bar ui-tx-actions--failed"><!-- Remove + Retry --></div>
</div>
Driven by swap.js → runTxProgress(card, force) — the simulated step machine appends .status-row rows via the shared statusRowHTML() builder (the same one that renders the Transaction-details receipts) and flips data-tx-outcome.
| Token | Value | Applies to |
|---|---|---|
| --spinner-size | --space-80 | The running ring (stroke steps to --space-4 at this scale) |
| spot-icon-4xl | 80 px tile · 40 px glyph | Outcome disc (--round + --success / --warn / --danger) |
| --text-body-lg | 18 px · --fw-semibold | __title |
| --text-sm | 14 px · --text-secondary | __desc (partial / failed explanation) |
| gap | --space-8 | __steps stack (checklist rows run a denser --space-8 gap / --space-12 inline padding than base .status-row) |
| padding-block | --space-24 --space-16 | The status card's hero breathing room |
| Element | Role |
|---|---|
| .ui-tx[data-tx-outcome] | Pane root — the outcome attribute is the single visual-state switch (CSS gating in swap.css) |
| .ui-tx-status | Status card (composes .card) — centered hero column |
| __icon | Hero mark host: the 80 px .spinner + three [data-tx-disc] tone discs; exactly one shows per outcome |
| __title / __desc | Status headline + optional explanation (partial / failed) |
| __steps | Progressive .status-row checklist — approval → transaction → sent-to-wallet |
| .ui-tx-received | "Received" card (.ui-card-label + token row) — success / partial only |
| .ui-tx-pair | from / ↓ / to recap card — running / failed only |
| .ui-tx-actions--done / --failed | Outcome-gated .action-bar pairs: See details + Done vs Remove + Retry |
Outcome map. running → spinner + pair card, no footer. success → success disc + steps + Received + See details / Done. partial → warn disc + desc + steps + Received + See details / Done. failed → danger disc + desc + failed step + pair card + Remove / Retry.
- The outcome attribute drives everything. JS appends steps and flips
data-tx-outcome; which mark, lower card, and footer show is pure CSS. Don't toggle[hidden]per element — that's the parallel-state drift this gate replaced. - The result disc is the system convention.
.spot-icon-4xl --round+ tone — the same composition the Portal withdraw-fees flow uses for its result heroes. Don't author a bespoke disc (the zero-consumer.ui-result-statewas retired June 2026 for exactly this). - Steps and receipts share one builder.
statusRowHTML()inswap.jsrenders both the live checklist and the Transaction-details receipts — single source, the surfaces can't drift. - Every attempted run is recorded into the unified transactions source (
LifiTransactions.addTransaction— completed / refunded / failed, in-memory) so it appears newest-first in the Activities tab. See details reads the recorded tx back throughLifiActivityand lands on the shared Transaction-details screen (.ui-tx-summary+ receipts + Transfer ID) — one source, no parallel details surface. - Step labels stay single-line. The checklist scope ellipsizes long labels (address short-forms) instead of wrapping the row.
- Theme Composer ready. Every paint routes through semantic tones (
--success / --warn / --danger / --info) and accents — a scoped canvas preset re-tints the discs, rows, and CTAs. - Demo forcing. ⌥-click the Review CTA → failed; ⇧-click → partial; Retry runs the success path. Real execution wires into
runTxProgress's step callbacks.
- CSS:
swap.css— searchtx-progress (.ui-tx*)(layout glue + the outcome gating). - Markup:
playground.html—data-state-content="tx-progress". - JS:
swap.js → runTxProgress() / initTxProgress() / statusRowHTML(). - Spec:
design/components/swap.md → Execution flow. - Mock: Figma Widget → Flow (Review / Waiting / Completed / Partially Successful / Failed frames).
var(--*) token so the chart retints when the palette changes and flips automatically when the theme changes. Pairs with .ui-token-info above to form the full "what is this token, what's it worth, where has it been" composition; the spot price + deltas live in the token-info row, the chart carries the historical visualization. First primitive in the Market Data group; sibling chart types (line / area / depth / mini) compose the same timeframe + canvas chrome with a different series renderer.Price chart .ui-price-chart
Token candlestick chart — timeframe quick-zoom (1D / 1W / 1M / All) above a Lightweight Charts canvas with limit-price annotation.
Every chart below is live — drag to pan, scroll to zoom, hover for the crosshair. Click a timeframe to re-range. The dashed accent line is the canonical limit-price annotation (set inline here at $2,253 since the playground form isn't on this page). Spot price + percent deltas have moved to .ui-token-info — see the Token info card below for the paired composition.
<!-- Price chart section. NOT a card — drops surface chrome so the
chart fills its parent panel edge-to-edge. The enclosing
.panel.ui-card (or .ds-doc-card-demo, on the catalog) carries
the surface recipe; this is a section primitive that stacks
the timeframe row above the chart canvas. Spot price + deltas
live in .ui-token-info above (see #ui-token-info). -->
<div class="ui-price-chart">
<!-- Timeframe quick-zoom — single-selection .chip.chip-neutral
group; data-timeframe="1D|1W|1M|all"; .is-active = aria-pressed. -->
<div class="ui-price-chart__timeframes" role="radiogroup" aria-label="Time range">
<button class="chip chip-neutral" data-timeframe="1D">1D</button>
<button class="chip chip-neutral is-active" data-timeframe="1W" aria-pressed="true">1W</button>
<button class="chip chip-neutral" data-timeframe="1M">1M</button>
<button class="chip chip-neutral" data-timeframe="all">All</button>
</div>
<!-- Chart canvas. data-tradingview-chart is the auto-init hook;
tradingview-chart.js renders the Lightweight Charts canvas
inside and resizes on container changes. -->
<div class="tradingview-chart"
data-tradingview-chart
aria-label="Price chart"></div>
</div>
<!-- Page-level scripts (load order matters — the library must be present
before tradingview-chart.js runs its auto-init on DOMContentLoaded). -->
<script src="https://unpkg.com/lightweight-charts@4/dist/lightweight-charts.standalone.production.js"></script>
<script src="tradingview-chart.js"></script>
Layout slots — the chart is the historical visualization layer. Spot price + delta chips live in .ui-token-info above, not in this primitive. The chart carries only its timeframe quick-zoom row and the canvas. Every color the canvas paints — candles, grid, axes, crosshair, font — resolves to a var(--*) token at chart init and on every theme flip.
| Slot | Default | Notes |
|---|---|---|
| Root | flex column · gap 12 · flex 1 · min-height 0 | Section primitive, not a card — no bg, no border, no shadow, no padding. The enclosing panel carries the surface recipe. |
__timeframes gap | var(--space-4) · 4 px | Tight cluster — same-axis siblings of one quick-zoom group. |
| Chart container | flex 1 · margin-inline: calc(-1 * --space-24) | Bleeds past the enclosing panel's 24 px horizontal padding so the price-axis renders flush. Bottom keeps panel's padding so time-axis labels breathe. |
| Chart canvas | autoSize: true | Lightweight Charts reads container dimensions and resizes the canvas to match. min-height: 0 lets it shrink inside a flexed parent. |
| Up candle | var(--success) | Resolved at chart-init + on every theme change. |
| Down candle | var(--danger) | Same — re-resolved on theme flip. |
| Grid lines | rgba(127,127,127,0.06) | Theme-neutral hairline; reads identical light/dark. |
| Limit-price line | var(--accent-primary) · 1 px · dashed | The annotation that paints when the user has a limit set. Reads brand-accent in any palette. |
| Crosshair | rgba(127,127,127,0.4) · 1 px · dashed | Hover affordance; normal mode (vertical + horizontal lines). |
| Font (canvas text) | Figtree, system-ui, … | Set on chart layout.fontFamily so axis labels match site type. |
| Sub-part | Class | Role |
|---|---|---|
| Root | .ui-price-chart | Flex column. Stretches to fill its parent panel; min-height 0 so the chart canvas can shrink inside a capped widget. |
| Timeframes group | .ui-price-chart__timeframes | Flex row, 4 px gap. role="radiogroup" for assistive tech. |
| Timeframe chip | .chip.chip-neutral[data-timeframe] | One button per zoom preset (1D / 1W / 1M / all). .is-active + aria-pressed="true" on whichever is selected. JS re-ranges all charts on the page via chart.timeScale().setVisibleRange(). |
| Chart canvas | .tradingview-chart[data-tradingview-chart] | Auto-init hook. tradingview-chart.js queries this selector on DOMContentLoaded, creates a chart per match, and resizes on container changes via Lightweight Charts' autoSize. |
| Limit annotation | (no DOM — series.createPriceLine()) | Rendered by Lightweight Charts on the canvas, not in the DOM. JS reads .ui-limit-price-card__amount + the card's data-denom and converts to the chart's native USDC-per-ETH unit. Re-paints on every input / denom change (150 ms debounce). |
- NOT a card — section primitive. No bg, no border, no shadow, no padding. The enclosing
.panel/.ui-card/ catalog.ds-doc-card-democarries the surface recipe. Drop into ANY card-shaped parent — Receive panel, dashboard tile, modal — and the chart picks up the surface's bg automatically via the ancestor-walk inresolvePanelBg(). - Spot price + deltas live in
.ui-token-info, not here. The chart is the historical visualization layer; current state (price, % deltas) belongs in the token-info row above. Retired May 2026 v2: the previous__header/__price/__deltasslots were folded into.ui-token-infoso the token-info primitive is self-contained for reuse in overlays where the chart isn't paired below. Don't reintroduce a price header on this primitive — extend the timeframe row, add a sibling chart-options row, or fold new state into.ui-token-infoinstead. - Every paint resolves to a token. Candles →
var(--success)/var(--danger); limit line →var(--accent-primary); text →var(--text-secondary); surface bg → walked from the chart's ancestor chain. AMutationObserverondata-themere-applies these on theme flip (no chart re-create). Don't inline a hex color in the chart options — the palette swap will leak. - Chart canvas bleeds horizontally, breathes vertically.
margin-inline: calc(-1 * var(--space-24))on.tradingview-chartpushes the canvas past the enclosing panel's horizontal padding so the price-axis labels render flush. Bottom keeps the panel's natural padding so time-axis labels (21 / 26 / May / 6 / …) don't run into the panel's rounded bottom edge. The panel'soverflow: hidden+border-radiusclip the chart's straight bottom corners to the panel's rounded shape. - Timeframe row uses the canonical chip family — no custom button. Each preset is
.chip.chip-neutralat the default tier (24 px tall, 12 px text). Single-selection: exactly one carries.is-active+aria-pressed="true"at any time. The active state inherits the canonical.chip-neutral.is-activeaccent fill from the chip family rule. JS toggles both classes on click; don't author timeframe-specific button styling. - Limit-price line is owned by the chart, not the form. The line is a
series.createPriceLine()annotation rendered on the canvas — there's no DOM node. JS reads the form value via.ui-limit-price-card__amount, inverts based ondata-denom, and writes viaapplyOptions(). Removing the line (when the form is empty / invalid) callsseries.removePriceLine()— never set color to transparent as a soft hide. - Lightweight Charts is the rendering layer; this primitive owns the chrome. The library handles candles, axes, crosshair, pan/zoom interactions; the primitive handles timeframes + surface composition. Don't reach into the chart canvas to add UI — extend the timeframes row or add a sibling row.
- One chart per
[data-tradingview-chart]on the page. The init runs once per matching element. Multiple charts on one page are fine (they all listen for theme + form changes from one observer set). Don't re-init manually — let the DOMContentLoaded auto-init handle it. - Demo data today, real data tomorrow — per-token shape. Candles come from a deterministic seeded random walk parameterized by the buy token: seed hashed from the symbol, anchor read from
sim.getPriceUsd()(with a static fallback table for the catalog page that doesn't load the sim), volatility tuned per asset (ETH 1.3 %/hr, WBTC 1.1 %, SOL 1.9 %, ARB 2.2 %, …). Production wiring (CoinGecko, LI.FI price feed, or the route quote endpoint) replacesgenerateDemoData()intradingview-chart.js— the per-token API stays the same. - Latest candle updates every 2 s in the demo. A synthetic tick simulator in
tradingview-chart.jsdrifts the current bar's close on a Brownian-style walk scaled from the per-token hourly volatility, calling Lightweight Charts'series.update()with the new{ time, open, high, low, close }on every tick. The spot price and percent-delta chips in.ui-token-infore-render on the same paint so the chart and chips converge by construction. The loop pauses ondocument.hiddenvia Page Visibility and resumes on return. When the wall-clock crosses the current bar's hour-end, a new bar opens at the previous close and the simulator drifts from there. Production wiring replaces the synthetic tick generator with a real WS feed reader — theseries.update()API is the load-bearing primitive and stays the same. - Chart follows the buy token. A
MutationObserveron[data-role="to"]'sdata-symbol+data-chainrepaints the candle series, re-applies the active timeframe, and re-paints the limit-price line in the same tick (no pop-in). The paired.ui-token-inforepaints from the same observer pass — both surfaces converge by construction. Catalog reference is static authored markup (no[data-role="to"]on this page), so the observer short-circuits and leaves the demo as-authored. - Stable buy token → real chart, low volatility. When the buy token is a stablecoin (
USDC/USDT/DAI/USDS), the chart renders with its own seeded series atTOKEN_VOLATILITY0.001 — near-flat at peg in calm markets, but a real series so a depeg moment (USDC March 2023, UST May 2022, USDD multiple times) would render natively as candles instead of a hidden slot..ui-price-chart[data-stable]stays as a semantic hook for future stable-specific behavior (the attribute is set on every to-token change) but no CSS branch hangs off it — the prior "chart suppressed + empty-state" model was retired May 2026 atomically with the.ui-price-chart__emptyelement. Don't reintroduce a hide branch — stables move, and the chart is exactly the surface that surfaces it. - Pair with
.ui-token-infoabove in the Receive panel. Token-info carries identity + spot state (avatar tandem + ticker · chain + price + deltas); the chart carries historical visualization. Both retract on Swap / Bridge modes (the standard quote list takes over). - Sibling chart types compose the same chrome. Future
.ui-price-chart--line/--area/--depth/--minivariants share the timeframes + canvas shape. The series renderer is the differentiator — same primitive, different chart options passed to the library. Plan modifier classes, not parallel components, for the next chart types in the Market Data group.
Implementation:
swap.css—.ui-price-chartrules (search the section comment "Price chart section for Limit mode", around the__timeframes/.tradingview-chartblocks).tradingview-chart.js— Lightweight Charts integration. Auto-inits on DOMContentLoaded; public effects only — no exposed API. Internal:chartOptions(container)resolves layout / grid / scale colors from tokens;candleSeriesOptions()resolves up/down tones;getTokenDataParams(symbol)+generateDemoData(symbol)produce the per-token seeded random walk;computeDeltas(candles)derives the 5m / 1h / 6h / 24h chips from the same series;renderTokenInfo()repaints the.ui-token-inforow's identity + spot + deltas;repaintForToken()swaps the chart series on token change;ensurePriceLine()paints the limit annotation;watchTheme()+watchLimitPrice()+watchTimeframes()+watchToToken()wire the live updates.https://unpkg.com/lightweight-charts@4/dist/lightweight-charts.standalone.production.js— the library itself. Apache 2.0, ~35 KB gzipped. Pinned to the v4 major; check release notes before bumping.playground.html— canonical consumer at the Receive panel of the swap widget in Limit mode. Composes.ui-token-infoabove +.ui-price-chartbelow inside the panel shell.design-system/index.html— this catalog card. The Preview pane composes the same markup with a value-set state pre-baked so the chart renders on first paint.design/components/swap.md— written spec for the primitive's surface recipe + Lightweight Charts integration rationale (pivot from the iframe-based Advanced Chart Widget that couldn't blend with the panel surface).
Related primitives: .chip.chip-delta-pos / .chip-delta-neg (delta tone family), .chip--bare (frameless chrome), .chip-window (annotation sub-slot inside delta chips), .ui-token-info (paired token-identity header above the chart), .ui-limit-price-card (source of the annotated limit-price value).
__identity, __price, __deltas — let each consumer include only the rows it needs.Token info .ui-token-info
Self-contained token snapshot — identity row + spot price + percent-delta chips. Composable slots; reuse in overlays and sticky headers.
Default — all three slots
Compact — identity + price, single 24h delta
Identity only — spot lives elsewhere
<!-- Token snapshot — wrapper primitive with composable slots.
Each consumer includes only the rows it needs:
__identity avatar tandem + ticker · chain (required)
__spot optional row hosting price + deltas
__price spot price, value-display 36 px tier
__deltas inline-flex of N bare delta chips -->
<div class="ui-token-info">
<!-- Identity row — composes .list-item.list-item--lg. The
__identity modifier zeros row chrome (padding / min-height /
radius) and inlines the title + desc pair at matched 18 px. -->
<div class="list-item list-item--lg ui-token-info__identity">
<span class="list-item__lead">
<!-- Token mark + chain badge — see #avatar tandem variant. -->
<span class="avatar-tandem">
<img class="avatar avatar--circle" src="avatars/tokens/eth.svg" alt="">
<img class="avatar-badge avatar-badge--br"
src="avatars/chains/ethereum.svg" alt="on Ethereum">
</span>
</span>
<div class="list-item__body">
<div class="list-item__title">ETH</div>
<div class="list-item__desc">Ethereum</div>
</div>
</div>
<!-- Spot row — baseline-aligned flex; wraps when narrow. Drop
this whole block for identity-only variants. -->
<div class="ui-token-info__spot">
<div class="ui-token-info__price">$2,112.40</div>
<div class="ui-token-info__deltas" aria-label="Price changes">
<!-- N .chip.chip-delta-{pos|neg}.chip--bare per window. The
time-window (5m / 1h / 6h / 24h) lives inside the chip
as a .chip-window sub-slot — see #chip-bare and #chip-window. -->
<span class="chip chip-delta-pos chip--bare">
<svg class="chip-icon">…</svg>0.07%<span class="chip-window">5m</span>
</span>
<!-- + 1h / 6h / 24h -->
</div>
</div>
</div>
Wrapper + slots — composition is the API. .ui-token-info is a flex-column wrapper with three named child slots: __identity (composes .list-item.list-item--lg), __spot (a baseline flex row hosting __price + __deltas). Slots are opt-in by markup — the consumer includes only what it needs. Price typography mirrors .ui-amount-card__amount + .ui-limit-price-card__amount exactly (36 px display-tier), so all four large numeric values across the widget read at the same visual weight.
| Slot | Default | Notes |
|---|---|---|
| Root | flex column · gap 12 | Wrapper primitive. No bg, no padding — the parent panel carries the surface. |
__identity chrome | padding 0 · min-height 0 · border-radius 0 | Zeroes the .list-item--lg row chrome since the row sits in a panel, not a scrolling list. |
__identity --avatar-size | var(--space-40) · 40 px | Matches .list-item--lg default. Tandem badge auto-sizes via the avatar cascade — never set width/height directly on the inner .avatar. |
__identity body direction | row · baseline · gap 8 | Inline-stack title + desc at matched body-lg size so the pair reads as one heading ("ETH Ethereum") instead of a stacked title/subtitle. |
__identity title font-size | var(--text-body-lg) · 18 px | Bumped from the default .list-item--lg title (16 px) for the page-title-tier role. |
__identity desc font-size | var(--text-body-lg) · 18 px | Matched to the title so the pair reads as ONE heading (matched size, weight separates: --text-primary vs --text-secondary). |
__spot layout | flex row · baseline · space-between · wrap · gap 12 | Price string left, delta chip group right (wraps to next line when narrow). |
__price font-size | var(--text-h2-lg) · 36 px | Value-display tier. Bold (--fw-bold), tight tracking (--tracking-firm), line-height: 1.1 (literal, display ratio). |
__price color | var(--text-primary) | Same ink as .ui-amount-card__amount at value-set state. |
__price numerics | font-variant-numeric: tabular-nums | So the price doesn't jitter as digits tick. |
__deltas gap | var(--space-12) · 12 px | Unit-to-unit gap between delta chips. Per-chip internal spacing (arrow → value → window) comes from the chip family's own ladder. |
| Sub-part | Class | Role |
|---|---|---|
| Root | .ui-token-info | Flex-column wrapper. Hosts the slot composition. |
| Identity row | .list-item.list-item--lg.ui-token-info__identity | Composes the canonical large list-row primitive. The __identity modifier zeros row chrome (padding / min-height / radius) and inlines the title + desc pair at matched body-lg size. |
| Lead | .list-item__lead | Hosts the tandem avatar. Inherits the .list-item--lg lead width. |
| Tandem mark | .avatar-tandem | Composite avatar — see #avatar tandem variant. Sizes via the parent's --avatar-size cascade (never set width/height directly). |
| Token glyph | .avatar.avatar--circle | Primary avatar — the token's brand mark. Circular crop. |
| Chain badge | .avatar-badge.avatar-badge--br | Bottom-right corner overlay — the chain the token lives on. Auto-cuts a mask into the primary avatar so the badge sits inside a clean inset. |
| Body | .list-item__body | Inline-flex row, baseline-aligned. Hosts the title + desc pair as one sentence. |
| Title | .list-item__title | Ticker (ETH / USDC / WBTC) — primary ink, 18 px, weight inherited from the list-item primitive. |
| Description | .list-item__desc | Chain or full name (Ethereum / USD Coin / Bitcoin) — secondary ink, 18 px (matched to title for the inline-heading effect). |
| Spot row | .ui-token-info__spot | Baseline-aligned flex row hosting price + deltas. Drop this whole block for identity-only variants (picker hover cards, list-row headings). |
| Spot price | .ui-token-info__price | Current spot price as a value-display number. Typography mirrors .ui-amount-card__amount. |
| Deltas group | .ui-token-info__deltas | Inline-flex of N bare delta chips. Auto-wraps with 12 px row gap. |
| Delta chip | .chip.chip-delta-{pos|neg}.chip--bare | Composed primitive — see .chip-delta-* and .chip--bare. The time-window label sits inside the chip via .chip-window (no sibling label, no wrapper). |
| Delta arrow | .chip-icon | Leading Lucide chevron — move-up for positive, move-down for negative. Tone via currentColor. |
| Window label | .chip-window | 5m / 1h / 6h / 24h. Lives inside the chip; muted ink so the percent value leads. |
- Wrapper + composable slots.
.ui-token-infois a flex-column wrapper;__identity/__spot/__price/__deltasare named slots. Consumers include only what they need — overlay popovers and sticky headers commonly drop one delta chip or both rows of state; full token cards include all three slots. Composition is the variant system; there are no--modifierclasses for slot visibility. - Identity row stays composed on
.list-item.list-item--lg. Same primitive the token picker uses; the__identitymodifier zeros row chrome (padding / min-height / radius) and inlines title + desc at matched 18 px. If.list-itemchanges, this row inherits the change. - Price typography matches
.ui-amount-card__amountexactly. 36 px / 700 /--tracking-firm/ 1.1 line-height / tabular-nums / primary ink. All four large numeric values across the swap widget (Sell / Buy / Limit / Spot) read at one visual weight. If any of those move, mirror here in the same edit. (Bug history: the Display line-height rule still applies — never reach for--lh-displayor--lh-herohere; the pixel-anchored tokens overlap at this size.) - Spot lives here, not in
.ui-price-chart. May 2026 v2 — the chart's previous__header+__price+__deltasslots retired atomically; the spot row moved into this primitive so the token-info renders as a self-contained snapshot for overlay reuse. Don't reintroduce a price slot on.ui-price-chart— that primitive is the historical visualization layer; current state belongs here. - Delta chips put the time-window INSIDE the chip via
.chip-window. The retired pair-wrapper pattern (separate label sibling) was migrated May 2026 to the chip family's child-slot model. Don't reach for a separate label or a wrapper — the chip is one unit (arrow + value + window). - Avatar sizing cascades via
--avatar-size. Never set width/height directly on the inner.avatar— the tandem badge geometry depends on the cascade. The__identitymodifier publishes 40 px to match.list-item--lg. - Title + desc are inline at matched size — read as one heading. Both run at 18 px so the pair reads "ETH Ethereum" as one heading rather than as a title-and-subtitle stack. The default
.list-item--lgbody stacks vertically — that's correct for picker rows; here the row is a heading, not a list item. - Data is read-only here — token comes from the form's
[data-role="to"]trigger. In the playground aMutationObserverintradingview-chart.jswatches the trigger'sdata-symbol+data-chainand repaints identity (avatar tandem + ticker + chain name), spot price (sim.getPriceUsd()), and delta chips (derived from the chart's per-token candle series) in one pass. No picker, no click affordance — the row is a snapshot of whatever the swap form points at. Catalog reference is static authored markup (no[data-role="to"]on this page); the observer short-circuits and leaves the demo as-authored. - Stable buy token → same row, real values. When the symbol is a stablecoin (
USDC/USDT/DAI/USDS), the spot displays the sim's actual price via the standardfmtSpotUsd()formatter and the delta chips render from the chart's per-token candle series like every other token..ui-token-info[data-stable]stays as a semantic hook but no CSS branch hangs off it — the prior "delta chips hidden + spot forced to$1.00" model was retired May 2026 atomically with the chart's empty-state. Sibling.ui-price-chartbranches the same way (real chart at low volatility). The reason in both places: stables CAN depeg, and the row + chart are exactly the surfaces a trader reads to spot that. Don't reach for a--stablemodifier or a separate primitive — the row primitive has no opinion on whether the underlying asset is pegged. - Don't reach for the wrapper for picker rows. The token picker uses bare
.list-item.list-item--lgdirectly — picker rows DO want padding, hover bg, and the vertical title/desc stack. Reach for.ui-token-infoONLY when the composition is acting as a self-contained token snapshot (heading + spot + deltas), not when it's a row in a scrolling list. - Future overlay variants extend slots, not modifiers. When designing the next consumer (popover hover-card, sticky scroll-header, portfolio row, notification), reach for slot composition: drop
__spotfor identity-only, drop__deltasfor price-only, drop__identityfor a price-pill use. Don't add.ui-token-info--popover/--sticky/--rowchrome classes — the wrapper has no opinion on outer context; slot inclusion + the parent surface carry the variant identity.
Implementation:
swap.css—.ui-token-inforules (search the section comment "Token identity + at-a-glance state"). Promoted from a.list-itemmodifier to a wrapper primitive in May 2026 v2 — the v1 atomic retirement note in the CSS comment carries the diff.playground.html— canonical consumer at the top of the Receive panel inside[data-limit-receive]. Live-wired (May 2026) to the form's[data-role="to"]trigger via aMutationObserverintradingview-chart.js→watchToToken(): identity + spot + delta chips repaint on every token change, in the same observer pass that repaints the chart canvas below. Initial markup inplayground.htmlseeds ETH/Ethereum; the observer overwrites on the first paint.tradingview-chart.js— the renderer for this row's data.renderTokenInfo(symbol, chainKey, candles)writes the four slot updates (token avatar src, chain badge src + alt, ticker, chain name) plus spot price (resolvePriceUsd()→ sim or fallback table) plus delta chips (computeDeltas()over the chart's candle series). Co-located with the chart so one observer drives both surfaces.styles.css— the.list-item+.list-item--lg+.avatar-tandem+.chip.chip-delta-*+.chip--bare+.chip-windowbase rules (the primitives this row composes).
Related primitives: .list-item (identity-row base), .avatar-tandem (token + chain badge composite), .chip.chip-delta-pos / .chip-delta-neg (delta tone family), .chip--bare (frameless chrome), .chip-window (annotation sub-slot inside delta chips), .ui-price-chart (historical visualization layer that pairs below in the Receive panel).
Checkout flow panel .ui-checkout
Host-agnostic guided pay-in flow — renders inline or in a modal; the funding source picked on the entry screen selects the sequence.
Static snapshot of the entry screen (Choose funding source). The full interactive flow — token/currency picker, connect wizard, amount entry, deposit address, the Deposit-with-Cash (Transak) path (fiat entry → the fixed-brand Transak sub-modal → pipeline checklist → completion), plus the cash error/refund states — runs live in the Playground (set Widget = Checkout in the rail). LI.FI screens wear a centered .screen-header (back · title · close) + a "Powered by LI.FI" footer; the Transak sub-modal wears its own brand (see Rules).
Choose funding source
Use your tokens
Buy tokens
The flow is host-agnostic. checkout.js renders the current screen into any [data-checkout-flow] host — an inline .ui-card.ui-checkout on the canvas, or the #checkoutModal overlay. The widget-type axis (body[data-widget-type]) and the host axis (body[data-checkout-host]) are driven from the rail.
<!-- Inline host (on the canvas) -->
<div class="checkout-stage">
<div data-checkout-host="inline">
<article class="panel ui-card ui-checkout" data-checkout-flow></article>
</div>
</div>
<!-- Modal host (over the partner page) -->
<div class="modal-overlay" id="checkoutModal" hidden>
<div class="modal panel ui-checkout" data-checkout-flow></div>
</div>
<!-- checkout.js paints the current screen into every [data-checkout-flow] -->
The shared front spine (funding-source → select-token → amount) diverges per method. FLOWS[source] selects the sequence; renderScreen() composes the shared chrome around a per-step body.
| Screen | Composed from | Notes |
|---|---|---|
| Choose funding source | .list--cards · .list-item--interactive · .spot-icon--neutral · .avatar-stack · .divider--label | The entry chooser. Trailing stack previews each method's chain coverage. |
| Connect wallet (Pay from Wallet) | window.LifiWalletConnect | The shared connect wizard. Skipped when a wallet is already connected. |
| Select token | .ui-token-picker | Held balances (Pay from Wallet) or the full token universe (Transfer). |
| Amount | .ui-amount-card · .chip-shortcuts · .detail-list | Single-column Send/Receive (no FAB seam); live quote via LifiQuoteSim. |
| Deposit address (Transfer) | .alert--warn · .ui-checkout-qr · .ui-checkout-address | Address + scannable QR (window.LifiQR) + expiry + watching state. |
| In progress | .ui-checkout-status · .spinner | Centered ring + status; auto-advances (mock confirmation). |
| Completed | .ui-checkout-status · .spot-icon-4xl--round | Success disc + .ui-tx-summary. |
| Deposit details | .ui-tx-summary · .detail-list | The receipt. |
| Cash — currency / amount (Transak) | .ui-token-picker (flag rows) · .ui-checkout-fiat · .chip-shortcuts · .alert--info | Fiat front spine — pick a currency, enter a fiat amount; live-derived stETH receive. |
| Cash — Transak sub-modal (6 screens) | .ui-transak-* (fixed brand) | The embedded Transak widget in its OWN brand — see Rules. |
| Cash — in progress / completed / details | .ui-checkout-steps (.status-row --active/--pending) · .ui-checkout-countdown · .ui-checkout-received · .ui-checkout-transfer | Multi-stage pipeline checklist + countdown ring + receipt. |
| Cash — error / refund | .ui-checkout-status--message · .ui-checkout-actions · .spot-icon-4xl--round | Cancelled · can't-buy · couldn't-reach · something-wrong · refund-sent. |
Genuinely-new sub-primitives
No prior DS equivalent:
| Class | Role |
|---|---|
| .ui-checkout | The flow panel — composes .panel.ui-card (inline) or .modal.panel (modal); centers the screen-header title throughout. |
| .ui-checkout__powered | The persistent "Powered by LI.FI" footer (LI.FI wordmark, currentColor). |
| .ui-checkout-status | Centered status block — a large ring (in-progress) or success disc (completed) + a label. Composes .card for the surface. |
| .ui-checkout-qr | The deposit-address QR card — a fixed white plate (dark modules, for scannability in both themes) + the token logo centered. [data-checkout-qr-code] hosts window.LifiQR.toSVG. |
| .ui-checkout-address | Mono deposit address + a copy button. |
| .ui-checkout-fiat | The cash Send/Receive card layout — asset identity in the head (flag/tandem + role + code), big amount below. Composes the .ui-amount-card surface. |
| .ui-checkout-steps | The cash pipeline checklist — a neutral-grey .status-row stack using the new --active (spinner) / --pending (empty ring) states. |
| .ui-checkout-received · -receipt · -transfer | The cash back half — Received card · details receipt · Transfer-ID card (copy + Contact Support). |
| .ui-checkout-status--message · .ui-checkout-actions | Error/refund — centered tone disc + heading + description, with a stacked (errors) or row (refund) button pair. |
| .ui-transak-* | Page-local provider chrome for the Transak sub-modal (fixed brand) — NOT reusable DS primitives. See Rules. |
- Checkout is a widget TYPE, not an exchange mode.
body[data-widget-type="exchange" | "checkout"]swaps.widget-stagefor.checkout-stage. Default isexchange(the code-level cold-load fallback — a saved pick only overrides, never seeds a non-default). - Host-agnostic. The same
renderScreen()output paints into either an inline.ui-card.ui-checkoutor the#checkoutModaloverlay, gated bybody[data-checkout-host="inline" | "modal"]. The modal auto-resizes between steps (window.LifiModalResize). - Per-method flow engine.
FLOWS[state.source]selects the screen sequence. Pay-from-Wallet has a connect interstitial then goes straight to progress; Transfer skips connect and inserts the deposit-address screen; Deposit-with-Cash (Transak) runs a fiat front spine → the Transak sub-modal → a LI.FI back half. Add a method = oneFLOWSentry + its branch. - The Transak sub-modal is a FIXED-BRAND surface — the second exception to theme-via-canvas after the QR plate. The six
tk-*screens recreate the embedded Transak widget in Transak's OWN brand (fixed white + Transak blue + "Powered by Transak"), in both light AND dark mode. A surface representing an external system wears its identity, not the LI.FI theme.render()flags the hostdata-checkout-provider="transak";.ui-transak-*is page-local provider chrome — never reach for it elsewhere. - Error / warning / refund are terminal branches, reached via
showState(kind)— not linear steps. Dismissing the Transak sub-modal mid-flow →cash-cancelled; ⌥-click "Continue to payment" →cash-error(the swap-widget demo-fail convention);window.LifiCheckout.show(kind)renders any state for the catalog/console. - Reuse over re-invent. The token/currency picker IS
.ui-token-picker, the amount cards ARE.ui-amount-card, the receipt IS.ui-tx-summary, the checklist IS.status-row— not re-compositions. Cash quotes derive live from the price oracle, never the stale Figma fixtures. - Themed via the canvas, never per-component. The LI.FI screens live inside
[data-palette-canvas], so the Theme Composer re-colors them like the swap widget — no checkout-specific theming. - Two surfaces are fixed (not themed): the QR plate (white with dark modules, scannability) and the Transak sub-modal (Transak's brand, the external-embed representation). Both stay fixed in dark mode.
Implementation:
checkout.js—window.LifiCheckout: the flow state machine (FLOWS·renderScreen· per-step body renderers), the widget-type + host axes, the modal open/close, and the screen wiring.checkout.css— thedata-widget-type/data-checkout-hostgates + the tier-3.ui-checkout-*layout glue.qr.js—window.LifiQR.toSVG, a dependency-free QR generator (Nayuki lineage) for the deposit-address screen.playground.html+swap.css— the rail Widget/Checkout sections, the.checkout-stage+#checkoutModalhosts, and thedata-widget-typeCSS gates.- Spec:
design/components/checkout.md.