.portal-rail) composes the shared .app-rail shell — a fixed glass .panel — and layers in the design-system .brand-lockup, .search-trigger, .dropdown, and .side-nav primitives; no new chrome.Rail panel .portal-rail
The fixed left rail. Composes the shared .app-rail shell (a glass .panel) and stacks head · search · org · nav · footer. Fully rounded — no minimize handle, so all four corners carry --panel-radius.
The mock below is the rail's actual root-screen markup from portal.html with one surgical change — position: fixed → position: relative on the rail (so it lives inside this viewport instead of floating over the page) and JS hooks (data-view / data-drill / id) stripped. Every visible part is a real composed primitive — Brand lockup, Search trigger, Dropdown, Side nav, Avatar (initial).
<!-- Fixed left rail — composes .panel + the shared .app-rail shell -->
<aside class="panel app-rail portal-rail" data-screen="root" aria-label="Portal navigation">
<div class="app-rail__screen is-active" data-screen-id="root">
<header class="app-rail__head">
<a class="brand-lockup brand-lockup--back">…</a>
<div class="portal-rail__actions"><!-- theme · bell · .portal-rail__toggle --></div>
</header>
<div class="portal-rail__search"><button class="search-trigger">…</button></div>
<div class="portal-rail__org"><div class="dropdown">…</div></div>
<div class="app-rail__section portal-rail__nav">
<nav class="side-nav side-nav--lg">…</nav>
</div>
</div>
<footer class="app-rail__footer portal-rail__foot">…</footer>
</aside>
The root screen is the rail's only .app-rail__screen — the former appearance / theme-edit drills were retired into the Settings view's Theme panel. The head's .portal-rail__toggle (data-action="rail-toggle", wired in portal.js → initPortalRailToggle) flips body.is-rail-mini — a persistent click-toggle PUSH collapse (portal.css → "Collapsed (mini) rail"), not the hover-expand .app-rail--mini.
| Property | Value | Source |
|---|---|---|
| Width | --w-4 (320 px) | Scoped override body.portal-body { --rail-width: var(--w-4) } — shared .app-rail default is --w-6 |
| Collapsed width | --rail-mini (72 px) | body.is-rail-mini shrinks --rail-width — click-toggle PUSH, portal.css → "Collapsed (mini) rail" |
| Radius | --panel-radius (16 px) ×4 | .portal-rail override |
| Surface | --surface-panel-glass | .panel glass recipe |
| Elevation | --elev-3 | .panel |
| Position | fixed, full height | .app-rail |
| Gutter | --panel-padding (24 px) | .panel |
Unlike the playground rail, the Portal rail keeps a full --panel-radius on all four corners — it has no docked .drawer-handle to mate a squared top-right against.
| Slot | Class | Holds |
|---|---|---|
| Shell | .panel.app-rail.portal-rail | Glass surface, fixed geometry, full radius |
| Screen | .app-rail__screen | The root view — the rail's only screen today; only .is-active shows |
| Head | .app-rail__head | .brand-lockup (left) + .portal-rail__actions (theme · bell · collapse toggle) |
| Search | .portal-rail__search | A full-width .search-trigger (⌘K) |
| Org | .portal-rail__org | The .dropdown organization switcher |
| Nav | .app-rail__section.portal-rail__nav | The flush, full-width .side-nav.side-nav--lg |
| Footer | .app-rail__footer.portal-rail__foot | Support / Docs links + account chip; pins to the bottom |
- Full radius on all four corners. The shared
.app-railsquares its top-right so a docked.drawer-handlemeets it flush (the playground's minimize notch). The Portal has no handle, so.portal-railoverrides back to--panel-radius— routed through the token so it tracks the corners composer like any.panel. - Compose, don't re-chrome. Surface / elevation / radius / padding come from
.panel+.app-rail; the Portal layer adds only the head / search / org / nav / footer arrangement. A hand-rolledbackground/box-shadow/border-radiuson the rail is drift. - Collapse is a persistent click-toggle PUSH, not hover-expand. The head's
.portal-rail__toggleflipsbody.is-rail-mini, shrinking--rail-widthto--rail-mini(72 px) so the rail and.app-canvasresize in lockstep; the arrow icon rotates 180° and becomes the sole head control. The shared hover-expand.app-rail--minioverlay is deliberately NOT used here — seedesign/components/navigation.md → App rail(Portal note). - Nav runs flush. The rail's
.panelgutter (24 px) is the only inset —.portal-rail__nav .side-navdrops its own padding so the rows align with the brand lockup and search bar above. - Nav rows fill the width. Every
.side-nav__itemis full-width, so each row's hover / active fill spans the same width rather than hugging its label.
CSS: portal.css — search .portal-rail (the radius override + head/search/org/nav/footer layering) and "Collapsed (mini) rail" (the click-toggle collapse). The shared shell is styles.css — search .app-rail.
Markup: portal.html — the <aside class="panel app-rail portal-rail"> block. JS: portal.js → initPortalRailToggle (the collapse toggle).
Spec: CLAUDE.md → "LI.FI Portal — partner console" (Shell section) and "Panel primitive".
.portal-page-head on top-level pages (Dashboard · Analytics · Members · …) and .portal-entity-head on secondary pages drilled into from a parent (Integration detail · Analytics children). Both are composable slots; the variants below show each slot omission so a designer can read which slots are required vs optional at a glance. Pick by tier — top-level pages → page header; secondary pages drilled into from a parent → entity header.Page header .portal-page-head
Top-level page header — required title + optional sub-paragraph + optional right-aligned actions. Sits atop every top-level Portal view.
Canonical · all slots
Welcome back, Vilen
Here's what's moving across your integrations.
Title only
Members
Title + sub
Status
All systems operational — historical uptime and recent incidents below.
Title + actions
API keys
<!-- Canonical: title + sub + actions. Sub and actions are optional. -->
<div class="portal-page-head">
<div>
<h1 class="portal-page-head__title">Welcome back, Vilen</h1>
<!-- optional -->
<p class="portal-page-head__sub">Here's what's moving across your integrations.</p>
</div>
<!-- optional -->
<div class="portal-page-head__actions">
<button class="btn-secondary btn-with-icon">…</button>
<button class="btn-primary btn-with-icon">…</button>
</div>
</div>
| Property | Value | Token / notes |
|---|---|---|
| Layout | flex, align-end, space-between | title group · actions group |
| Gap (title↔actions) | 24 px | --space-24 |
| Margin-bottom (header→content) | calc(--grid-gap-y + 8) | tracks the Spacing composer + one ladder step extra header breathing |
| Title | --text-h1 | weight --fw-bold, tracking tight, line-height 1.1 |
| Sub (eyebrow→title) | 12 px | --space-12 — internal rhythm |
| Sub (title→sub) | 12 px | --space-12 — internal rhythm |
| Sub copy | --text-body | --text-muted |
| Actions gap | 12 px | --space-12 |
| Class | Required? | Role |
|---|---|---|
| .portal-page-head | Required | Flex container: title group · actions group |
| .portal-page-head__title | Required | The page title (H1) |
| .portal-page-head__sub | Optional | One-line muted sub-paragraph — page narration / context |
| .portal-page-head__actions | Optional | Right-aligned cluster of page-level secondary + primary buttons |
| .portal-page-head__eyebrow | Optional · reserved | Parent-section label (sibling, above) — for secondary top-level pages (Analytics children); paints in --accent-primary |
- Title is the only required slot — sub and actions are optional. Pick the composition by what the page genuinely needs; don't force an empty sub-paragraph or a one-button actions row to "feel complete." A bare title is the right shape for many sub-pages.
- Pick page header vs entity header by tier, not by content shape — top-level page (Dashboard, Members, Settings, Analytics root) →
.portal-page-head; secondary page drilled into from a parent (Integration detail, Analytics → Volume) →.portal-entity-head. The two render differently on purpose: the entity head leads with an avatar + status chip + integrator string because the page is about a specific entity. - Actions are page-level — the actions slot holds CTAs that scope to the WHOLE page (New integration, Add member, Withdraw fees). Intra-section CTAs (refresh a panel, edit a row) belong in their own panel's header, not here.
- Top breathing room is canvas-level — the space above the header is
--canvas-top(Portal scope: 40 px), not a header margin. Don't pad the header itself to fake top breathing; tune the canvas token. - Bottom breathing room tracks the Spacing composer — header→content margin is
calc(--grid-gap-y + --space-8), so it follows whatever vertical rhythm the active theme picked, plus one ladder step for header breathing.
CSS: portal.css — search .portal-page-head.
Tier: page-local tier-3 .portal-* — promotion candidate once a 2nd consumer appears.
Spec: CLAUDE.md → "LI.FI Portal — partner console".
Entity header .portal-entity-head
Secondary-page header — eyebrow · avatar · name + status chip · integrator-string · actions. Avatar + title required; the rest are optional.
Canonical · all slots
oneinteg10
ActiveNo eyebrow · top-level entity
oneinteg10
ActiveNo actions · read-only entity
oneinteg10
ArchivedMinimal · avatar + title
oneinteg10
<!-- Canonical: eyebrow + avatar + title + status chip + sub + actions.
Every slot beyond avatar + title is optional. -->
<!-- optional · parent-section label (sibling) -->
<div class="portal-entity-head__eyebrow">Integrations</div>
<div class="portal-entity-head">
<span class="avatar avatar--initial avatar--rounded portal-entity-head__avatar" data-initial="oneinteg10"></span>
<div class="portal-entity-head__id">
<div class="portal-entity-head__title-row">
<h1 class="portal-entity-head__title">oneinteg10</h1>
<!-- optional · status chip -->
<span class="chip chip-success chip--sm">…</span>
</div>
<!-- optional · mono integrator-string + copy -->
<div class="portal-entity-head__sub">…</div>
</div>
<!-- optional · right-aligned actions -->
<div class="portal-entity-head__actions">…</div>
</div>
| Property | Value | Token / notes |
|---|---|---|
| Layout | flex, align-start, gap 16 | avatar · id (flex-1) · actions |
| Margin-bottom (header→content) | calc(--grid-gap-y + 8) | matches .portal-page-head — same canvas rhythm |
| Avatar | 56 px square | .portal-entity-head__avatar over .avatar--initial (rounded) |
| Eyebrow | --text-caption | bold, tracking wide, uppercase, --accent-primary; margin-bottom 12 |
| Title | --text-h2 | weight --fw-bold, tracking tight, line-height 1.1 |
| Title-row gap | 12 px | --space-12 |
| Sub | --text-sm | --text-muted, mono string + copy button; margin-top 8 |
| Actions gap | 8 px | --space-8 |
| Class | Required? | Role |
|---|---|---|
| .portal-entity-head__eyebrow | Optional | Parent-section label (sibling, above) — names the parent section the page lives under. Omit on top-level entity pages. |
| .portal-entity-head | Required | Flex row: avatar · id · actions |
| .portal-entity-head__avatar | Required | 56 px square initial avatar (rounded) |
| .portal-entity-head__id | Required | Title-row + sub-line, takes remaining width |
| .portal-entity-head__title-row | Required | Name + optional status chip on one line |
| .portal-entity-head__title | Required | Entity name (H1) |
| .chip (in title-row) | Optional | Status chip — Active / Paused / Archived |
| .portal-entity-head__sub | Optional | Mono integrator-string + copy affordance |
| .portal-entity-head__actions | Optional | Right-aligned secondary action buttons |
- The eyebrow marks a secondary page — Integration Detail sits under Integrations, so the header carries a parent-section eyebrow (
Integrations) in the brand accent (--accent-primary). It names the parent only — the current page is the title below, so there's no repeated leaf. Top-level entity pages omit it. - Status chip belongs on the title row — it qualifies the name (Active / Paused / Archived), so it sits inline next to the title, not in the sub-line.
- The integrator-string is copyable — pair the mono string with the copy button; it's the value engineers paste into the SDK.
- Actions are secondary — view / edit use
.btn-secondary; the page's primary action (e.g., Withdraw fees) lives in the Overview metric cards on the page below, not here. - Minimal form keeps avatar + title — the identity (avatar + name) is what makes this "entity" header rather than a page header. If you'd render only a title, reach for
.portal-page-headinstead.
CSS: portal.css — search .portal-entity-head.
Tier: page-local tier-3 .portal-* — promotion candidate once a 2nd consumer appears.
Spec: CLAUDE.md → "LI.FI Portal — partner console".
Empty-state illustration .portal-empty__art
Decorative overlapping-cards + checkmark + add-FAB illustration on a dotted grid, anchoring the "create your first integration" empty state.
Pairs with a .portal-empty__title heading, a .portal-empty__desc line, an actions row, and a 3-up .portal-steps grid below it.
<div class="portal-empty__art" aria-hidden="true">
<span class="portal-empty__card portal-empty__card--back"></span>
<span class="portal-empty__card portal-empty__card--front">
<span class="portal-empty__check"><svg>…</svg></span>
<span class="portal-empty__lines"><i></i><i></i></span>
</span>
<span class="portal-empty__fab"><svg>…</svg></span>
</div>
| Property | Value | Token / notes |
|---|---|---|
| Frame | max 440 × 180 px | --card-radius, --surface-raised |
| Dotted grid | 16 × 16 px | radial-gradient, ink 8% mix |
| Front card | 204 × 104 px | accent 6% fill, accent 30% border, --elev-1 |
| FAB | 40 px | --accent-primary, --elev-2 |
| Class | Role |
|---|---|
| .portal-empty__art | Dotted-grid frame containing the overlapping cards |
| .portal-empty__card--back / --front | The two stacked card silhouettes |
| .portal-empty__check | Accent check disc on the front card |
| .portal-empty__lines | Two faux text lines (80% / 50% width) |
| .portal-empty__fab | Floating add-button in the corner |
- Purely decorative — mark the whole illustration
aria-hidden="true"; the heading + description carry the meaning. - Always pair it with copy + a primary CTA — the illustration sets the tone, the "New integration" button does the work.
- Don't reuse it as a generic blank slate — it's specific to the integrations empty state (cards + add affordance). Other empty states get their own art or a plain
.portal-placeholder.
CSS: portal.css — search .portal-empty__art (the .portal-empty / .portal-steps block).
Tier: page-local tier-3 .portal-* — promotion candidate once a 2nd consumer appears.
Spec: CLAUDE.md → "LI.FI Portal — partner console".
Usage meter .portal-usage
Pill showing N / M plan usage with a mini progress bar; --limit flips it to a danger tone at capacity.
Within plan · .portal-usage
At capacity · .portal-usage--limit
<span class="portal-usage">
<span class="portal-usage__bar">
<span class="portal-usage__fill" style="width: 60%;"></span>
</span>
<span class="portal-usage__text"><strong>6</strong> / 10 integrations</span>
</span>
<!-- At capacity — add --limit (danger tone) -->
<span class="portal-usage portal-usage--limit">…</span>
| Property | Value | Token / notes |
|---|---|---|
| Pill | 4 / 12 padding | --r-full, --surface-card, --border-subtle |
| Bar | 40 × 4 px | --r-full, ink 12% track |
| Fill | width % | --accent-primary (→ --danger on --limit) |
| --limit | danger tone | danger 40% border, 6% fill, danger value ink |
| Class | Role |
|---|---|
| .portal-usage | Pill wrapper (bordered, rounded-full) |
| .portal-usage__bar | Track for the progress fill |
| .portal-usage__fill | Filled portion — width = the N / M fraction |
| .portal-usage__text | Label; strong emphasises the current count |
| .portal-usage--limit | Danger-tone modifier for the at-capacity state |
The bare .portal-usage__bar is reused standalone as the onboarding progress bar — see .portal-onboard-bar.
- Mirror the fill width to the fraction —
__fillwidth % must equal N / M, or the bar lies about usage. - Use
--limitonly at capacity — it's the "you can't add more" signal, paired with a disabled "New integration" CTA and a contact-support note. - Keep the label terse — "N / M integrations". Plan detail belongs in the sibling
.portal-meta-row__note, not the pill.
CSS: portal.css — search .portal-usage.
Tier: page-local tier-3 .portal-* — promotion candidate once a 2nd consumer appears.
Spec: CLAUDE.md → "LI.FI Portal — partner console".
integration-wizard.js) — a 4-step modal built end-to-end on design-system primitives, opened from the integrations view via [data-action="open-integration-modal"]. Documented here: the expandable wallet-family row, the in-field character counter, and the wizard flow itself.Network row .network-row
Expandable wallet-family row: avatar, name, a Required/Optional chip, and an Add / Added / Adding action over a grid-rows drawer.
The wallets step adds one fee-collection wallet per network family. Each family is a .network-row that moves through three states, driven by data-state: empty (an Add affordance), added (the saved address + a remove kebab), and adding (an accent rim + an open drawer holding the add-wallet form). The drawer is a pure-CSS grid-rows 0fr → 1fr accordion — no JS height math.
Empty · data-state="empty"
Added · data-state="added"
Adding · open drawer · data-state="adding"
Fees are sent to this address automatically. You can change it later in Fee Management.
<div class="network-row" data-family="svm" data-state="adding">
<div class="network-row__head">
<img class="avatar avatar--sm avatar--rounded network-row__icon" src="avatars/chains/solana.svg" alt="">
<div class="network-row__body">
<div class="network-row__name">SVM <span class="chip chip-warn chip--sm">Required</span></div>
<span class="network-row__sub">Solana · enter a Solana address</span>
</div>
<div class="network-row__trail"><!-- Add btn · Added chip + kebab · or "Adding" --></div>
</div>
<!-- grid-rows 0fr→1fr drawer; form padding lives on __form, NOT __drawer-inner -->
<div class="reveal network-row__drawer"><div class="reveal__inner">
<div class="network-row__form">…</div>
</div></div>
</div>
Rendered by integration-wizard.js → networkRowHTML(); the form by walletFormHTML().
| Property | Value | Token / notes |
|---|---|---|
| Surface | --surface-card | card tier + --border-subtle + --elev-1 |
| Radius | --card-radius | 16 px |
| Head padding | 12 px | --space-12; row gap --space-12 |
| Row gap | 8 px | .network-row + .network-row → --space-8 |
| Drawer | 0fr → 1fr | grid-rows accordion, 0.28s ease |
| Adding rim | accent 64% | color-mix border + --elev-2 (card .is-active recipe) |
| Form padding | 12 px | on .network-row__form + a --border-subtle top rule |
| Class | Role |
|---|---|
| .network-row | The row; carries data-state="empty|added|adding" + data-family |
| .network-row__head | Avatar + body + trailing action, always visible |
| .network-row__icon | The family's .avatar (chain mark or the L2 plate) |
| .network-row__body | Name row + sub-line (truncating) |
| .network-row__name | Family name + Required/Optional .chip |
| .network-row__sub | Family blurb, or .mono short-address once added |
| .network-row__trail | Add .btn · Added .chip + remove kebab · "Adding" |
| .reveal + .network-row__drawer / .reveal__inner | The universal .reveal drawer (styles.css); the row keeps its marker class + the data-state ancestor opener. Inner stays bare so 0fr hits true zero |
| .network-row__form | Add-wallet form: owns padding + top rule; content is consumer-supplied |
| .network-row__addr | Address input + Paste button row |
| .network-row__form-actions | Right-aligned Cancel + Save |
| .network-row--l2 | Variant for the "Add an EVM L2 wallet" chooser row (select in the trail) |
data-statedrives the three states — empty / added / adding. The accent rim + open drawer are pure CSS off[data-state="adding"]; no JS toggles a class.- Form padding lives on
.network-row__form, not__drawer-inner— the collapsing grid item must stay bare so0frhits true zero (CLAUDE.md → grid-rows accordion bug). The form is the padded child. - Required vs Optional via tone chips —
.chip-warnfor required families (EVM, SVM),.chip-neutralfor optional (Bitcoin, Sui, Tron, L2). - Rows compose design-system primitives —
.avatar,.chip,.btn-*,.input,.field-label-row,.field-valid; the Portal classes only add the row framing + drawer. - Page-local today — one consumer (the wizard). Promote to
styles.csswhen a second "add accounts" flow appears.
CSS: portal.css — search .network-row.
JS: integration-wizard.js → networkRowHTML() + walletFormHTML() (the Wallets step).
Tier: page-local tier-3 — promotion candidate once a 2nd consumer appears.
Spec: CLAUDE.md → "LI.FI Portal — partner console".
In-field counter .input-counter
Wraps a maxlength input so a live count sits inside its right edge, turning danger at the cap. Distinct from the label-row .field-counter.
The wizard's name field overlays the live character count inside the input's right edge — the field reserves right padding so typed text never runs under it. The count is JS-bound (the consumer rewrites the text and toggles --over at the cap); the input's maxlength is the real constraint.
Under the cap · muted count
At the cap · .input-counter__count--over
<span class="input-counter">
<input class="input" type="text" maxlength="64">
<span class="input-counter__count" aria-hidden="true">15 / 64</span>
</span>
/* at the cap: add --over to the count */
<span class="input-counter__count input-counter__count--over">64 / 64</span>
| Property | Value | Token / notes |
|---|---|---|
| Wrapper | position: relative | display: block |
| Input pad-right | 64 px | --space-64 — reserved so text never runs under the count |
| Count position | right 16 px | --space-16, vertically centered, pointer-events: none |
| Count type | 14 px / 600 | --text-sm · --fw-semibold · tabular-nums |
| Count colour | --text-faint | → --danger on --over |
| Class | Role |
|---|---|
| .input-counter | Relative wrapper around the .input; reserves the count's right gutter |
| .input-counter__count | The absolutely-positioned "N / max" caption |
| .input-counter__count--over | At-cap state — recolours the count to --danger |
- The count is JS-bound — the consumer rewrites the text and toggles
--overas the field changes. The CSS only positions + colours; themaxlengthattribute does the real capping. aria-hiddenon the count — it's a redundant visual of themaxlengthconstraint, so it's hidden from the accessibility tree.- Distinct from
.field-counter— that one rides the.field-label-rowbeside the label (a quiet caption). Reach for.input-counterwhen the count should overlay the field; reach for.field-counterwhen it should sit in the label row. - Page-local today — renamed from
.iwiz__*June 2026; single consumer (the wizard name field). Promote tostyles.csswhen a 2nd consumer appears.
CSS: portal.css — search .input-counter.
JS: integration-wizard.js → basicsBody() (the name field).
Sibling: .field-counter (styles.css) — the label-row counter.
Tier: page-local tier-3 — promote to styles.css when a 2nd consumer appears.
Spec: CLAUDE.md → "LI.FI Portal — partner console".
Integration wizard .iwiz
The New-integration 4-step modal flow: stepper, per-step head and body, and a uniform footer, composed from design-system primitives.
The wizard is a JS-driven modal (integration-wizard.js) — renderStep() rewrites a [data-integration-flow] shell per step. The snapshot below is one assembled step (String) showing the four composed layers: the stepper, the head, the step body (here list-card radios + an alert), and the footer. The live 4-step flow runs in the Portal — open portal.html and click New integration.
Choose your string
<!-- portal.html — the thin shell -->
<div class="modal-overlay" id="integrationWizardModal" hidden>
<div class="modal panel modal--lg iwiz" role="dialog" data-integration-flow></div>
</div>
// integration-wizard.js — renderStep() fills it per step:
flow.innerHTML =
'<div class="iwiz__top">' + stepper + '</div>' // stepper
+ '<div class="modal-body">' + head + stepBody + '</div>' // head + body
+ '<footer class="iwiz__footer">…</footer>'; // footer
The four steps
| Step | Collects | Continue when |
|---|---|---|
| Basics | Name (.input-counter) + optional URL | name is non-empty |
| String | The permanent integrator string (suggested or custom) | string ≥ 3 chars |
| Wallets | One .network-row per family (EVM + SVM required) | EVM and SVM added |
| API key | Reveal-once key + a "saved it" confirmation | confirmation checked |
Footer model
| Step | Left | Right cluster |
|---|---|---|
| Basics (first) | Cancel | Continue |
| String / Wallets (middle) | Cancel | ← Back · Continue |
| API key (last) | Cancel | Confirm and finish |
Wallets also surfaces a block-reason (e.g. "SVM wallet required") left of Back until both required wallets are added. The width is .modal--lg (--w-12, 576 px).
| Layer | Role |
|---|---|
| .modal.panel.modal--lg.iwiz | The shell, inside a .modal-overlay; .iwiz sets the column gap |
| .iwiz__top | Full-width .steps--circles-inline stepper (no top-right close) |
| .iwiz__head / __title / __desc | Per-step heading unit inside .modal-body |
| .iwiz__section-label | Mono uppercase group label ("Network families", "API key") |
| .iwiz__alert | Top-aligned .alert for the permanent-string + reveal-once warnings |
| .iwiz__required-progress | "Required wallets added · N / 2" line above the footer |
| .iwiz__key-actions / __saved-card | API-key step: Copy + Download row · "I've saved it" .option-card |
| .iwiz__footer / __footer-left / -right / __status | Cancel left; Back + primary right; block-reason status |
- Composed end-to-end from design-system primitives —
.steps--circles-inline,.list--cards,.option-card,.alert,.input,.network-row,.input-counter,.chip-*,.btn-*. The.iwiz__*classes are only flow-specific layout glue. - No top-right close — Cancel (footer, bottom-left) is the dismiss affordance on every step; ESC and overlay-click also close. The stepper spans the full top edge.
- Uniform footer — Cancel left, primary right; Back stacks left of the primary on the middle steps only (not first, not the terminal API-key step).
- Back is wizard navigation — it steps to the previous step (a deliberate divergence from the modal "back returns to Main" two-tier contract; a connect-style wizard is the depth-bearing flow that carve-out allows).
- Finish emits an event —
lifi:integration-created{ name, string, wallets }; the host (portal.js) persists the integration and opens its Detail page.
JS: integration-wizard.js (window.LifiIntegrationWizard.initWizard); the shell is portal.html → #integrationWizardModal, opened via [data-action="open-integration-modal"].
CSS: portal.css — search .iwiz (the flow-specific layout glue).
Spec: CLAUDE.md → "LI.FI Portal — partner console".
.panel.portal-panel and composes design-system primitives (.list--cards, .data-table, .chip, .btn-*, .alert) with the Portal-specific pieces below.Fee table & summary .portal-fee-chain · .portal-fee-bar · .portal-fees-summary
The Fees tab — a 3-column summary (total / lifetime / rates) above a per-chain collectable table with mini relative-balance bars.
Summary · .portal-fees-summary
Per-chain table · .portal-fee-chain + .portal-fee-bar
| Chain | Collectable | Transactions | Last collected |
|---|---|---|---|
| $7,000,000 | 56,204 | 12h ago | |
| $3,000,000 | 24,891 | 5h ago | |
| $92,410 | 1,208 | 3d ago |
Bar width is each chain's balance as a fraction of the largest balance — a relative-magnitude cue, not a percentage of total.
<!-- Per-chain cell: avatar + name + native amount -->
<span class="portal-fee-chain">
<img class="avatar avatar--circle" src="avatars/chains/optimism.svg" style="--avatar-size: var(--space-28);">
<span>
<span class="portal-fee-chain__name">Optimism</span>
<span class="portal-fee-chain__amt mono">2.4081 ETH</span>
</span>
</span>
<!-- Relative-magnitude bar in the Collectable cell -->
<span class="portal-fee-bar"><span class="portal-fee-bar__fill" style="width: 43%;"></span></span>
| Property | Value | Token / notes |
|---|---|---|
| Summary | 1fr 1fr 1.5fr | hairline divider between columns, gap 24 |
| Summary value | --text-h1 | weight --fw-bold, tracking tight |
| Fee bar | 96 × 4 px | --r-full, ink 10% track, --accent-primary fill |
| Chain avatar | 28 px | --avatar-size: var(--space-28) |
| Class | Role |
|---|---|
| .portal-fees-summary | 3-column total / lifetime / rates grid |
| .portal-fees-rate | Rate row — label + .chip badge |
| .portal-fee-chain | Table cell — avatar + name + native amount |
| .portal-fee-bar / __fill | Relative-balance mini bar in the Collectable cell |
| .portal-fee-age | Status dot + relative time in Last-collected |
- Bar = relative magnitude — scale each fill to the largest chain balance, not to the grand total. It's a "which chains hold the most" scan cue.
- Native amount under the fiat — the mono
__amtline shows the token amount; the fiat headline sits in the Collectable column. - Tabular-nums on every numeric cell — fiat, native, transaction counts all align on the decimal.
- Reuse the chain avatar via
.avatar— never a Portal-specific mark; the chain logos are the design-system/avatars/chainsset.
CSS: portal.css — search Detail · Fees tab (the .portal-fees-* / .portal-fee-* block).
Tier: page-local tier-3 .portal-* family — promotion candidate once a 2nd consumer appears.
Spec: CLAUDE.md → "LI.FI Portal — partner console".
Metric card .portal-metric
Overview metric + action card: an icon-label, a large value, and a footer button or note pinned to the bottom of the panel.
<div class="panel portal-panel portal-metric">
<div class="portal-metric__head"><svg>…</svg><span>Collectable fees</span></div>
<div class="portal-metric__value">$1,284.32</div>
<button class="btn-primary btn-block">Withdraw fees</button>
</div>
<!-- "Good" value tone + a note instead of a button -->
<div class="portal-metric__value portal-metric__value--ok">99.98%</div>
<span class="portal-metric__note">Last 30d uptime</span>
| Property | Value | Token / notes |
|---|---|---|
| Card | flex column | .panel.portal-panel surface, gap 12 |
| Head | --text-sm | --text-muted, icon in --text-faint |
| Value | --text-h2 | weight --fw-bold; --ok → --success |
| Footer | margin-top auto | .btn-block pinned to bottom for even card heights |
| Class | Role |
|---|---|
| .portal-metric | Flex-column body inside a .panel.portal-panel |
| .portal-metric__head | Icon + label row, muted |
| .portal-metric__value | Large headline number |
| .portal-metric__value--ok | Success-toned value (health / uptime) |
| .portal-metric__note | Caption alternative to a footer button |
- One action or one note per card — a
.btn-block(Withdraw / Request) or a__note, never both. - Footer pins to the bottom —
margin-top:autoon.btn-blockkeeps a row of mixed-height cards aligned. - Tone shifts ink, not weight —
--okonly recolours the value to--success; size and weight stay constant (per the state-via-ink rule).
CSS: portal.css — search .portal-metric.
Tier: page-local tier-3 .portal-* — promotion candidate once a 2nd consumer appears.
Spec: CLAUDE.md → "LI.FI Portal — partner console".
Bar list .portal-barlist
Ranked magnitude breakdown — label + value on top, a proportional fill bar below. Drives Top chains, fee splits, and Top routes.
Single chain · base row
Route pair · .portal-barlist__row--route · --sm track
<!-- Base row — single chain -->
<div class="portal-barlist">
<div class="portal-barlist__row">
<span class="portal-barlist__label"><img class="avatar avatar--circle" …>Ethereum</span>
<span class="portal-barlist__val">$18.4M</span>
<span class="portal-barlist__track"><span class="portal-barlist__fill" style="width: 100%;"></span></span>
</div>
</div>
<!-- Route row — avatar-stack + chain1 + arrow ICON + chain2 -->
<div class="portal-barlist__row portal-barlist__row--route">
<span class="portal-barlist__label">
<span class="avatar-stack avatar-stack--xs"><img …><img …></span>
<span class="portal-route__name">Ethereum</span>
<svg class="portal-route__arrow-icon">…</svg>
<span class="portal-route__name">Arbitrum</span>
</span>
<span class="portal-barlist__val">$6K</span>
<span class="portal-barlist__track">…</span>
</div>
| Property | Value | Token / notes |
|---|---|---|
| Row grid | minmax(0,1fr) auto | label | value; track spans 1 / -1 below |
| Row gap | 8 / 12 | --space-8 row · --space-12 column; list gap --space-16 (route lists step up to --space-24) |
| Label / value | --text-sm | route variant bumps to --text-body |
| Track | height 8 | --space-8 · --r-full · 8% text-primary groove. --sm → --space-4 |
| Fill | width % | --accent-primary · set inline from pctOfMax |
| Route avatars | .avatar-stack--xs | 24px overlapping pair (margin-right --space-4); arrow icon --space-16; label gap --space-4 |
| Class | Role |
|---|---|
| .portal-barlist | Flex-column list of ranked rows, gap 16 |
| .portal-barlist--sm | Size variant — thinner 4px track (vs the default 8px) |
| .portal-barlist__row | Grid row — label + value, track below |
| .portal-barlist__row--route | Route variant — avatar-stack + dual chain label, bumped type |
| .portal-barlist__label | Avatar(s) + name(s); min-width:0 to share the row |
| .portal-barlist__val | Right-aligned magnitude value, tabular figures |
| .portal-barlist__track | Full-width bar groove (spans both columns) |
| .portal-barlist__fill | Accent fill; width = value ÷ max |
| .portal-route__name | Chain label inside a route row (nowrap) |
| .portal-route__arrow-icon | Lucide arrow-right ICON between the two chains |
- Fill is proportional to the max —
width = round(value / maxValue × 100)%, with a small floor (6–10%) so the smallest bar stays visible. - Single chain vs route — single-chain breakdowns (volume / revenue by chain, fee types) use the base row; a from→to route uses
--route(an.avatar-stack+ chain1 + arrow icon + chain2 at bumped type). - Arrow is the icon, not the glyph — route rows use the Lucide
arrow-rightSVG (.portal-route__arrow-icon), never a→text character. - Rows are sorted descending — the largest value (100% fill) leads.
CSS: portal.css — search .portal-barlist (route variant: .portal-barlist__row--route).
Renderers: portal-render.js — barlistChain() (single chain) · dashboard-routes (route rows).
Tier: page-local tier-3 .portal-* — promotion candidate once a non-Portal surface needs a ranked bar breakdown.
Spec: CLAUDE.md → "LI.FI Portal — partner console".
Onboarding checklist .portal-checklist
Vertical step list with done / todo check markers and an inline resume link, paired with a progress bar.
- Create integration
- Add a fee wallet
- Generate first API key
- Send a test transaction Resume →
- Invite a teammate
<!-- Progress bar reuses the usage-meter track -->
<span class="portal-usage__bar portal-onboard-bar"><span class="portal-usage__fill" style="width: 60%;"></span></span>
<ul class="portal-checklist">
<li class="is-done"><span class="portal-check"><svg>…</svg></span>Create integration</li>
<li><span class="portal-check portal-check--todo"></span>Send a test transaction
<a class="link-arrow" href="#">Resume →</a></li>
</ul>
| Property | Value | Token / notes |
|---|---|---|
| List | flex column, gap 12 | --text-sm, --text-primary |
| Check | 20 px | 50%, --accent-primary fill, on-accent glyph |
| Todo check | 20 px | transparent, 2px ink-25% border |
| Done row | line-through | --text-muted |
| Class | Role |
|---|---|
| .portal-checklist | Unstyled <ul> — flex-column step list |
| li.is-done | Completed step — muted, struck-through |
| .portal-check | Filled accent disc with a check glyph |
| .portal-check--todo | Empty outlined disc for a pending step |
| .portal-onboard-bar | Progress bar — reuses .portal-usage__bar |
- Done = filled check + line-through; todo = outlined disc, full-ink label. The shape carries state, not colour alone.
- Resume link on the next actionable step — only the first incomplete step gets the inline
.link-arrow. - Pair with the progress bar — the
.portal-onboard-barfill should equal done / total so the bar and the ticks agree.
CSS: portal.css — search .portal-checklist.
Tier: page-local tier-3 .portal-* — promotion candidate once a 2nd consumer appears.
Spec: CLAUDE.md → "LI.FI Portal — partner console".
Wallet group .portal-wallet-group
Fee-receiver group: a default-address row with per-chain override rows nested beneath a connector line, plus a not-configured empty state.
Default + per-chain overrides · .portal-wallet-overrides
Default address used across all EVM chains, plus per-chain overrides.
-
Collected$842.10Default EVM DEFAULT · Ethereum0x2e8a346Ba1E0c7f9913282AFB2705CD00754E478
-
Collected$157.56Base override · Base0x4DFbc42E1e86Fb747A69a3Dc75BA8d6bD0cC7818
Not configured · .portal-wallet-empty
UTXO chains. Not configured for this integration yet.
<div class="portal-wallet-group">
<div class="portal-wallet-group__head">
<div>
<div class="portal-wallet-group__title">EVM Networks <span class="chip chip-neutral chip--sm">1 default · 1 override</span></div>
<p class="portal-wallet-group__desc">Default address …</p>
</div>
</div>
<!-- Default row -->
<ul class="list list--cards"><li class="list-item">…</li></ul>
<!-- Per-chain overrides nest beneath a connector line -->
<div class="portal-wallet-overrides">
<ul class="list list--cards"><li class="list-item">…</li></ul>
</div>
</div>
| Property | Value | Token / notes |
|---|---|---|
| Group gap | 32 px | between adjacent .portal-wallet-group |
| Overrides indent | 24 / 16 px | margin-left + padding-left, 1.5px left connector |
| Rows | — | design-system .list.list--cards + .list-item |
| Empty | dashed | .portal-wallet-empty, --border-subtle |
| Class | Role |
|---|---|
| .portal-wallet-group | One virtual-machine group (EVM / SVM / Bitcoin) |
| .portal-wallet-group__head | Title + count chip + description + add-override CTA |
| .portal-wallet-overrides | Nested override list with a left connector line |
| .portal-wallet-chain | Muted "· <chain>" suffix on a row title |
| .portal-wallet-trail / -actions | Collected amount + per-row icon buttons |
| .portal-wallet-empty | Dashed not-configured placeholder row |
- Default first, overrides nested — the default address is a top-level
.list-item; per-chain overrides indent under.portal-wallet-overridesso the precedence reads visually. - One group per virtual machine — EVM / SVM / Bitcoin each get their own
.portal-wallet-group; the count chip states "N default · M overrides". - Empty ≠ broken — an unconfigured VM shows
.portal-wallet-emptywith an add CTA, not a zero-row table. - Rows are design-system primitives — compose
.list.list--cards+.list-item; the Portal classes only add the group framing.
CSS: portal.css — search .portal-wallet-group (the Detail · Wallets tab block).
Tier: page-local tier-3 .portal-* — promotion candidate once a 2nd consumer appears.
Spec: CLAUDE.md → "LI.FI Portal — partner console".
withdraw-fees.js) — pick a chain's collectable tokens, sign, land on a receipt or a revert state. Opened via [data-action="open-withdraw-modal"] from the Detail Fees table and the Fee Management list; a confirmed signature settles into LifiPartnerOrgs.recordWithdrawal, so every collectable surface drains live behind the overlay. The "Withdraw all fees" button runs the same chassis as a multi-chain batch — one signature per chain through a per-chain checklist (data-withdraw-all on the trigger); on Fee Management the batch runs org-wide — every integration × chain pair, grouped by integration (data-withdraw-all-org).Withdraw fees .wfee
The fee-withdrawal modal flow: select a chain's collectable tokens, sign, land on a receipt or a revert state — all DS primitives.
A JS-driven modal (withdraw-fees.js) — renderStep() rewrites a [data-withdraw-flow] shell per state: select → signing → success | error. The snapshots below show the two full compositions plus the signing / error fragments; the live flow runs in the Portal — open portal.html and hit any Withdraw button on the Fees table (hold ⌥ on the CTA to demo the failure path). Amounts derive from the live price oracle; the figures below are one captured run.
Select · amount hero · select-all · token rows
Withdraw fees
The select-all checkbox sits in its :indeterminate dash state (a partial pick); the hero value dims to faint ink when nothing is selected.
Success · receipt
Fees withdrawn
$31,359 across 3 tokens have been sent to your Arbitrum fee receiver.
- Transaction
- 0x7927…52b82
- Chain
Arbitrum
- Receiver
- 0x00e0…841f
- Gas paid
- $4.91
Receipt rows are a .detail-list--divided in a card frame; the explorer link resolves per chain (Arbiscan, Etherscan, Basescan…).
Signing banner · revert detail
Error · Insufficient_gas
execution reverted: gas estimation failed at block 218682389
Signing seats a .spinner inside the alert's .spot-icon disc — progress + the mandatory shape signal in one. The revert detail is the tier-2 .error-detail — the alert family’s code-bearing sibling (same tone slots and surface recipe, no spot-icon by design).
<!-- portal.html — the thin shell -->
<div class="modal-overlay" id="withdrawFeesModal" hidden>
<div class="modal panel modal--lg wfee" role="dialog" data-withdraw-flow></div>
</div>
<!-- any trigger — chain attr optional (omitted → largest remaining balance) -->
<button class="btn-primary btn-sm" data-action="open-withdraw-modal"
data-withdraw-slug="ledger-live-swap" data-withdraw-chain="arbitrum">Withdraw</button>
// withdraw-fees.js — renderStep() fills the shell per state:
flow.innerHTML = { select, signing, success, error }[state.step]();
The four states
| State | Shows | Advances when |
|---|---|---|
| Select | Amount hero · select-all · token rows · receiver note + gas | "Withdraw fees" with ≥ 1 token picked |
| Signing | Waiting banner · locked rows · disabled "Signing…" | Simulated signature lands (~2.2 s); Cancel returns to select |
| Success | Result disc · receipt · explorer link | Done or close |
| Error | Danger disc · revert copy · mono error detail | Try again re-signs (and succeeds) |
Data & consequences
| Call | Does |
|---|---|
| getWithdrawable(org, slug, chain?) | Seeded token split of the chain's collectable (native + stables, gov token past $250k); amounts derive from the price oracle at call time |
| recordWithdrawal(org, slug, chain, tokens) | Subtracts per token from every collectable surface, writes a "Just now" audit entry, fires 'change' (in-memory; resets on reload) |
| lifi:fees-withdrawn | Window event with { orgId, slug, chain, tokens, usdNum, txHash } for future consumers (+ batch: true on batch legs) |
Width is .modal--lg (--w-12, 576 px), fixed across the flow; height tweens per state via the modal's built-in fluid resize. Gas scales with the batch: base + perToken × selected, seeded per chain. Earned figures (fees KPI, lifetime, txn counts) deliberately never shrink.
| Layer | Role |
|---|---|
| .modal.panel.modal--lg.wfee | The shell, inside .modal-overlay; .wfee sets the column gap |
| .wfee__header / __chain-row / __bar | .screen-header title bar + centered .chip-avatar chain pill; result states keep only the close bar |
| .wfee__amount / -label / -value / -sub | Amount hero on the canonical card surface; --zero dims the value to faint ink |
| .wfee__selectbar / -count | "Select all" .checkbox (with the :indeterminate dash) + the n/m counter |
| .wfee__tokens (+ --locked) | .list--cards rows — checkbox + token avatar lead, amount-over-usd trail; locked during signing |
| .wfee__meta / -note / -gas | Receiver note + the count-scaled gas estimate |
| .wfee__signing-btn | Stock .alert--info with a .spinner in the .spot-icon disc · the disabled "Signing…" CTA |
| .wfee__result / __receipt | Result disc + title + desc · .detail-list--divided receipt card; the revert echo is the tier-2 .error-detail |
| .wfee__actions | Footer row — the primary grows, secondaries hug |
- Composed end-to-end from design-system primitives —
.screen-header,.chip-avatar,.checkbox,.list--cards,.alert+.spot-icon+.spinner,.detail-list,.error-detail,.spot-icon-4xl,.btn-*. The.wfee__*classes are only flow-specific layout glue. - Chain-scoped by design — one chain is one transaction; the header pill names it. The Fee Management trigger omits the chain attr and resolves the integration's largest remaining balance.
- Zero selection shifts ink, not weight — the hero value drops to
--text-faintand the CTA disables; type structure never changes. - Signing locks rows but keeps them legible — only the checkboxes dim (their own
:disabledtreatment); the selection rim and content stay at full ink. - Success settles into the data layer —
recordWithdrawalsubtracts per token, every collectable surface rebinds live via'change', drained chains disable their Withdraw action, and the audit log gains a "Just now" entry. Earned figures don't shrink. - Cancel aborts the signature (the sim timer is cleared) and returns to select with the picks intact; Escape and overlay-click close from any state.
- The sim always succeeds — hold ⌥ on the CTA (or call
LifiWithdrawFees.failNext()) to demo the error path; Try again then succeeds.
JS: withdraw-fees.js (window.LifiWithdrawFees.init); the shell is portal.html → #withdrawFeesModal, opened via [data-action="open-withdraw-modal"] (emitted by portal-render.js on the Detail Fees + Fee Management tables).
CSS: portal.css → "WITHDRAW-FEES MODAL — .wfee".
Data: partner-orgs-data.js → getWithdrawable / recordWithdrawal (the in-memory withdrawn-fee ledger).
Spec: CLAUDE.md → "LI.FI Portal — partner console".
Batch withdraw .wfee__legs
The multi-chain batch behind "Withdraw all fees" — one signature per chain, through a checklist that doubles as the running receipt.
The same .wfee chassis run as a sequential per-chain wizard: bselect → brun → bsummary. The progress model is a checklist, not a stepper — the steps ARE the chains (avatar + amount + status per row), and the select state's checkboxes become progress marks at the same 20 px geometry. Two scopes share the chassis: integration (the Detail Fees card — one integration's chains) and org-wide (Fee Management — every integration × chain pair, grouped by integration). Live in the Portal: Detail → Fees or Fee Management → Withdraw all fees (hold ⌥ to demo a failed leg). The snapshots below are one captured run.
Run · paused on a failed leg · Retry / Skip / Stop
Withdraw all fees
A failed leg PAUSES the batch (it never aborts silently): Retry re-signs the same chain, Skip moves on, Stop ends it. While a leg signs, its mark holds a .spinner and the row carries the selected tint so the eye tracks the signature down the list.
Summary · partial · per-leg recap
Partially withdrawn
$29,847 withdrawn on 2 of 4 chains. The rest stays collectable · withdraw it anytime.
The summary disc grades the outcome — success when every leg landed, warn for a partial run, danger when nothing was withdrawn. Done legs carry per-chain explorer icon-links; the recap is the same checklist in its final state. Contact support appears only when a leg failed.
Org-wide · Fee Management · grouped by integration
Withdraw all fees
The org-wide scope (Fee Management, data-withdraw-all-org) flattens every integration × chain pair into one queue: rows stay chain legs, quiet .wfee__leg-group labels (xs monogram + name) mark each integration cluster, and the header pill names the org instead of an integration. Legs are keyed slug|chain — the same chain can appear once per integration.
<!-- the batch trigger — data-withdraw-all flips the shared opener to the batch;
slug + disabled + gas line stamped per-integration by portal-render.js -->
<button class="btn-primary btn-block btn-with-icon portal-fees-withdraw"
data-action="open-withdraw-modal" data-withdraw-all
data-withdraw-slug="ledger-live-swap">Withdraw all fees</button>
<!-- the ORG-WIDE trigger (Fee Management) — no slug; legs span every integration -->
<button class="btn-primary btn-block btn-with-icon portal-fees-withdraw"
data-action="open-withdraw-modal" data-withdraw-all-org>Withdraw all fees</button>
// withdraw-fees.js — the batch states share the single-flow shell:
flow.innerHTML = { bselect, brun, bsummary }[state.step]();
<!-- org scope only — a quiet integration label precedes each leg cluster -->
<div class="wfee__leg-group">
<span class="avatar avatar--initial avatar--xs avatar--rounded">LS</span>
<span class="wfee__leg-group-name">Ledger Live Swap</span>
</div>
<!-- one leg row (run / summary) — the checkbox slot becomes the status mark -->
<div class="list-item wfee__leg wfee__leg--done">
<span class="list-item__lead wfee__token-lead">
<span class="wfee__leg-mark wfee__leg-mark--done"><!-- check --></span>
<img class="avatar avatar--circle" src="avatars/chains/ethereum.svg">
</span>
<span class="list-item__body">
<span class="list-item__title">Ethereum</span>
<span class="list-item__desc wfee__leg-status">0x4be1…9c2af</span>
</span>
<span class="list-item__trail wfee__leg-trail">$18,420 · explorer link</span>
</div>
The three states
| State | Shows | Advances when |
|---|---|---|
| bselect | Amount hero · select-all-chains · one row per chain (symbols + per-leg gas) · signature-count note | "Withdraw fees" with ≥ 1 chain picked; every chain is pre-checked |
| brun | The checklist signing one leg at a time — waiting banner while signing, danger banner + Retry / Skip / Stop when a leg fails | Each landed leg advances automatically (~1.8 s sim); the last leg lands or Stop is pressed |
| bsummary | Graded result disc · the checklist in its final state with explorer links · total gas | Done or close |
Leg statuses
| Status | Mark | Status line |
|---|---|---|
| queued | Hollow ring | "Queued" |
| signing | .spinner--sm in the mark slot + selected row tint | "Awaiting signature…" |
| done | Filled --success disc, check | The short tx hash (tabular nums) |
| failed | Filled --danger disc, ✕ | "Reverted on-chain" in danger ink |
| skipped | Faint disc, minus | "Skipped · still collectable", muted title |
Data & consequences
| Call | Does |
|---|---|
| getWithdrawableAll(org, slug) | Per-chain ctx array, largest balance first; fully-drained chains dropped — the array IS the signing queue |
| getWithdrawableAllOrg(org) | Org-wide legs — every integration × chain pair, integrations largest-first; feeds the Fee Management trigger |
| recordWithdrawal(…) per leg | Fires as EACH leg lands — collectable surfaces drain live behind the overlay, one chain at a time |
| lifi:fees-withdrawn | One event per landed leg, same payload as the single flow + batch: true |
Stop semantics: zero landed legs → back to bselect with the picks intact (nothing happened); ≥ 1 landed → bsummary with the partial receipt. Gas is per leg (base + perToken × tokens, seeded per chain); the select state totals it and the trigger's caption announces the signature count up front.
| Layer | Role |
|---|---|
| .wfee__legs | The run / summary checklist — read-only .list--cards rows (pointer-events: none; explorer links re-enabled) |
| .wfee__leg-mark (+ --queued/--signing/--done/--failed/--skipped) | The 20 px status disc in the checkbox slot — same geometry, so picks visibly become progress markers |
| .wfee__leg--signing / --done / --failed / --skipped | Row state — selected tint on the in-flight leg; status-line ink shifts per state (ink, never weight) |
| .wfee__leg-trail | Amount + the per-chain explorer icon-link (summary only) |
| .wfee__leg-group (+ -name) | Org-wide only — the quiet integration label (xs monogram + name) emitted whenever the leg queue crosses into the next integration |
| .wfee__chain-row (reused) | The context pill names the batch's SCOPE — the integration's monogram identity (Detail batch) or the org's mark (org-wide), never a chain |
| __amount / __selectbar / __meta / __actions (reused) | Hero, select-all, receiver + gas note, and footer come straight from the single flow |
- Checklist, not stepper —
.steps--circles-inlinefits a fixed set of abstract stages; here the steps ARE the chains (variable count, each carrying avatar + amount + status), failure states are first-class, and the list doubles as the running receipt. - One signature per chain — EVM has no cross-chain atomic withdraw, so the batch is N sequential transactions. The count is announced up front: the hero sub-line, the meta note, and the trigger's gas caption all carry it.
- Landed legs settle immediately —
recordWithdrawalfires per leg, so the surfaces behind the overlay drain chain by chain. A later Stop never rolls an earlier leg back; you can't unsign a landed transaction. - Failure pauses, never aborts silently — the batch holds at the failed leg with Retry / Skip chain / Stop batch. Skipped and failed balances stay collectable; re-run anytime.
- Stop is state-aware — with zero landed legs it returns to bselect with the picks intact; with ≥ 1 it exits through the partial summary so the receipt is never lost.
- Chain granularity here, token granularity there — batch rows pick whole chains; per-token selection stays in the single-chain flow (the per-chain Withdraw buttons).
- Two scopes, one engine — integration-scoped (the Detail Fees card,
data-withdraw-all+ slug) and org-wide (Fee Management,data-withdraw-all-org): legs are integration × chain pairs fromgetWithdrawableAllOrg, grouped by integration in the checklist, keyedslug|chain(a chain alone isn't unique org-wide). The chrome and copy follow the scope; the engine doesn't care. - The summary disc grades the outcome — success / warn (partial) / danger (nothing withdrawn); Contact support appears only when a leg failed.
- Demo — hold ⌥ on the CTA to fail the second leg (the paused state then shows beside a landed one);
LifiWithdrawFees.failNext()fails whichever signature resolves next.
JS: withdraw-fees.js (window.LifiWithdrawFees.openAll integration-scoped / .openAllOrg org-wide; the engine is startBatch / advanceBatch / stopBatch); the Detail trigger is stamped per-integration by portal-render.js → renderDetail, the Fee Management trigger per-org by applyFeesWithdrawAll.
CSS: portal.css → "Batch legs" + .wfee__leg-group (under the .wfee block).
Data: partner-orgs-data.js → getWithdrawableAll / getWithdrawableAllOrg / recordWithdrawal.
Spec: CLAUDE.md → "LI.FI Portal — partner console".