Say what a person can act on, and paint it in ink they can see

The header named the signing protocol — `MPC · CGGMP21` — and a transfer row
badged it again. Neither is something a visitor can do anything with, and the
header is the surface everybody reads. The copy now says what a person needs:
which network, that a deposit address is involved, that no single party can
release the funds. The cluster still runs whatever BRIDGE_MPC_PROTOCOL selects.

The pickers could not be operated from a keyboard. Every key was answered on
the menu and nothing ever focused the menu, so opening with Enter left the
arrows dead. They are answered on the trigger now, which holds focus for the
whole interaction, with aria-activedescendant naming the row the cursor is on.
Each picker takes a label bound to it or a name read with it, and the prop type
admits no third shape.

The brand primary is #000000 and the SDK painted its selected row, its live
phase dot, the refuel toggle and the header's only call to action with it —
black on a black page. Marking a thing is the neutral scale's job. The accent
stays as the published brand hook; nothing inside the SDK reads it.

The rest is the page reading like the exchange it sits beside: a flat ground,
panels separated by an edge rather than a shadow, one type scale, field names
in one case, and hints raised out of the ink you have to lean in for.

@luxfi/bridge 1.0.9.
This commit is contained in:
2026-08-30 17:10:40 -07:00
parent f3af7910ab
commit 6ee87f25ce
17 changed files with 788 additions and 553 deletions
+32
View File
@@ -108,6 +108,38 @@ mountBridge({
Mirrors `mountExchange` from the upstream exchange SDK. One declarative entry,
build-time brand config, no hostname detection inside the SDK.
### What the SDK's chrome may say, and what it paints with
Two rules the UI is built to, both of which were violated and are now
structural rather than advisory:
**The page never names the signing protocol.** The header used to carry an
`MPC · CGGMP21` chip and a transfer row used to badge `t.mpc.protocol`. A
protocol name is not something a visitor can act on, and the header is the one
surface everybody reads. What a person needs is which network they are on, that
a deposit address is involved, and that no single party can release funds — all
of which the copy now says in those words. `BRIDGE_MPC_PROTOCOL` still selects
what the cluster runs; it just does not select what the page says. The rendered
page, its attributes and every open menu are checked against the protocol
vocabulary in the browser, not by grep over source.
**The SDK's own chrome does not paint with `--bridge-accent`.** The token is
bound to `--brand-primary`, and Lux's brand primary is `#000000` — so the
selected option's label, the live phase dot, the refuel toggle and the header's
only call to action were all being drawn black on a black page. Marking a thing
is the neutral scale's job (`--bridge-text` / `--bridge-text-muted` /
`--bridge-text-subtle`), which is legible whatever a tenant sets. The accent
stays defined as the published brand hook for host pages; nothing inside the
SDK reads it.
Two smaller ones that follow from those. Every picker takes either `label` (a
`<label>` bound to the trigger) or `name` (read, not drawn) — the prop type
admits no third shape, so a nameless control does not compile. And every key a
picker answers is handled on the trigger, which holds focus for the whole
interaction, with `aria-activedescendant` naming the row the cursor is on;
handling them on the menu meant the arrows were dead, because nothing ever
focused the menu.
## Publishing
`.github/workflows/publish.yml` fires on `v*` tag push, runs on the
+4 -2
View File
@@ -262,8 +262,10 @@ quote attempt — that's the most common signal of a misconfigured deploy.
### Browser sanity (manual)
1. Open `https://bridge.lux.network/` in a fresh incognito window.
2. Header should show the env chip (`MAINNET` green) and the
`MPC · <protocol>` chip.
2. Header should show the env chip (`MAINNET`, green) and nothing else
beside the wordmark. The signing protocol is not named on the page —
`BRIDGE_MPC_PROTOCOL` selects what the cluster runs, not what a visitor
is told.
3. Open the chain selector — you should see 22 active chains pulled from
`bridge-api.lux.network/api/networks` (Bitcoin, Solana, Ton, XRP,
Polkadot, Ethereum, Base, Lux, Zoo, Polygon, Optimism, Arbitrum, Celo,
+1 -1
View File
@@ -67,7 +67,7 @@ is the failure mode this doc is designed to catch.
Open `http://localhost:3001/` in a fresh incognito window.
- [ ] Header reads `LUX Bridge` + a `TESTNET` chip + `MPC · CGGMP21` chip.
- [ ] Header reads `LUX Bridge` + a `TESTNET` chip, and nothing else.
If the chip says `MAINNET`, the env var didn't reach `import.meta.env`
— restart Vite with the prefix.
- [ ] Devtools → Network → first request to `/api/networks` carries
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@luxbridge/lux-tenant",
"version": "1.0.1",
"version": "1.0.2",
"private": true,
"description": "bridge.lux.network — Lux-branded tenant build consuming @luxfi/bridge + @luxfi/brand.",
"license": "BSD-3-Clause",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@luxfi/bridge",
"version": "1.0.8",
"version": "1.0.9",
"description": "Lux Bridge SDK — embed bridge.lux.network into any host application",
"publishConfig": {
"registry": "https://registry.npmjs.org/",
@@ -37,7 +37,7 @@ describe('ChainSelector — family label mapping', () => {
render(
<ChainSelector label="From" chains={[eth, sol, ton]} current={eth} onChange={vi.fn()} />,
)
fireEvent.click(screen.getByRole('button', { name: /Ethereum/ }))
fireEvent.click(screen.getByRole('combobox', { name: /From/ }))
expect(screen.getByText('EVM')).toBeTruthy()
expect(screen.getByText('Solana')).toBeTruthy()
expect(screen.getByText('TON')).toBeTruthy()
@@ -51,7 +51,7 @@ describe('ChainSelector — family label mapping', () => {
render(
<ChainSelector label="From" chains={[eth, mystery]} current={eth} onChange={vi.fn()} />,
)
fireEvent.click(screen.getByRole('button', { name: /Ethereum/ }))
fireEvent.click(screen.getByRole('combobox', { name: /From/ }))
expect(screen.getByText('NEARPROTOCOL')).toBeTruthy()
})
})
@@ -62,8 +62,8 @@ describe('ChainSelector — selection', () => {
render(
<ChainSelector label="From" chains={[eth, sol, ton]} current={eth} onChange={onChange} />,
)
fireEvent.click(screen.getByRole('button', { name: /Ethereum/ }))
fireEvent.click(screen.getByRole('option', { name: /Solana/ }))
fireEvent.click(screen.getByRole('combobox', { name: /From/ }))
fireEvent.mouseDown(screen.getByRole('option', { name: /Solana/ }))
expect(onChange).toHaveBeenCalledWith(sol)
})
+144 -94
View File
@@ -1,14 +1,16 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// Select replaced the native <select> specifically so the asset/chain
// pickers could show a logo + secondary label — but that trade means
// every bit of keyboard behavior a native <select> gets for free
// (arrow-key nav, Home/End, Escape, click-outside-to-dismiss) had to be
// hand-rolled, and none of it had a dedicated test. AssetInput.test.tsx
// exercises Select only incidentally (open + click one option); this
// file covers the interaction contract described in Select.tsx's own
// header comment directly.
// Select replaced the native <select> so the chain and asset pickers could
// show a logo and a secondary line — and that trade means every bit of
// keyboard behaviour a native control gets for free had to be written out.
// This file is where that behaviour is held to its word.
//
// Two things it pins that the earlier version did not. Every key is answered
// on the trigger, because the menu was never focused and the arrows were
// therefore dead after opening with Enter. And the control carries a name:
// `label` draws one, `name` reads one, and there is no third way to render a
// picker whose only name is the value it happens to be holding.
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
@@ -24,177 +26,225 @@ const options: SelectOption[] = [
function renderSelect(valueId = 'eth') {
const onChange = vi.fn()
const value = options.find((o) => o.id === valueId)!
render(<Select value={value} options={options} onChange={onChange} />)
render(<Select name="Asset" value={value} options={options} onChange={onChange} />)
return { onChange }
}
function openPopover() {
fireEvent.click(screen.getByRole('button', { name: /ETH|SOL|XRP/ }))
const trigger = () => screen.getByRole('combobox')
function open() {
fireEvent.click(trigger())
}
describe('Select — open/close', () => {
it('is closed by default and opens on trigger click', () => {
renderSelect()
expect(screen.queryByRole('listbox')).toBeNull()
openPopover()
open()
expect(screen.getByRole('listbox')).toBeTruthy()
})
it('toggles closed on a second trigger click', () => {
renderSelect()
openPopover()
open()
expect(screen.getByRole('listbox')).toBeTruthy()
openPopover()
open()
expect(screen.queryByRole('listbox')).toBeNull()
})
it('closes on a mousedown outside the component, without committing', () => {
const { onChange } = renderSelect()
openPopover()
expect(screen.getByRole('listbox')).toBeTruthy()
open()
fireEvent.mouseDown(document.body)
expect(screen.queryByRole('listbox')).toBeNull()
expect(onChange).not.toHaveBeenCalled()
})
it('does not close on a mousedown inside the popover', () => {
renderSelect()
openPopover()
open()
fireEvent.mouseDown(screen.getByRole('listbox'))
expect(screen.getByRole('listbox')).toBeTruthy()
})
it.each(['ArrowDown', 'Enter', ' '])('opens on %s at the trigger', (key) => {
it.each(['ArrowDown', 'ArrowUp', 'Enter', ' ', 'Home', 'End'])(
'opens on %s at the trigger',
(key) => {
renderSelect()
fireEvent.keyDown(trigger(), { key })
expect(screen.getByRole('listbox')).toBeTruthy()
},
)
it('Escape closes the popover, and the trigger still holds focus', () => {
renderSelect()
fireEvent.keyDown(screen.getByRole('button', { name: /ETH/ }), { key })
expect(screen.getByRole('listbox')).toBeTruthy()
trigger().focus()
open()
fireEvent.keyDown(trigger(), { key: 'Escape' })
expect(screen.queryByRole('listbox')).toBeNull()
expect(document.activeElement).toBe(trigger())
})
it('Escape closes the popover and returns focus to the trigger', () => {
it('answers Escape only while open, so a closed picker leaves it to the page', () => {
renderSelect()
const trigger = screen.getByRole('button', { name: /ETH/ })
openPopover()
fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Escape' })
expect(screen.queryByRole('listbox')).toBeNull()
expect(document.activeElement).toBe(trigger)
const e = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
trigger().dispatchEvent(e)
expect(e.defaultPrevented).toBe(false)
})
})
describe('Select — committing a value', () => {
it('clicking an option commits it and closes the popover', () => {
it('pressing an option commits it and closes the popover', () => {
const { onChange } = renderSelect()
openPopover()
fireEvent.click(screen.getByRole('option', { name: /SOL/ }))
open()
fireEvent.mouseDown(screen.getByRole('option', { name: /SOL/ }))
expect(onChange).toHaveBeenCalledWith(options[1])
expect(screen.queryByRole('listbox')).toBeNull()
})
it('does not call onChange when dismissed without a selection', () => {
const { onChange } = renderSelect()
openPopover()
open()
fireEvent.mouseDown(document.body)
expect(onChange).not.toHaveBeenCalled()
})
it('returns focus to the trigger after a commit', () => {
renderSelect()
trigger().focus()
open()
fireEvent.mouseDown(screen.getByRole('option', { name: /SOL/ }))
expect(document.activeElement).toBe(trigger())
})
})
describe('Select — keyboard navigation', () => {
function highlightedId(): string | undefined {
const opt = screen.getAllByRole('option').find((el) => el.getAttribute('data-highlighted') === 'true')
return opt?.getAttribute('data-index') ?? undefined
function cursor(): string | undefined {
return screen
.getAllByRole('option')
.find((el) => el.getAttribute('data-highlighted') === 'true')
?.getAttribute('data-index') ?? undefined
}
it('starts highlighted on the currently-selected option', () => {
it('starts on the currently-selected option', () => {
renderSelect('sol')
openPopover()
expect(highlightedId()).toBe('1') // sol is options[1]
open()
expect(cursor()).toBe('1')
})
it('ArrowDown/ArrowUp move the highlight and clamp at the ends', () => {
renderSelect('eth') // starts at index 0
openPopover()
const listbox = screen.getByRole('listbox')
fireEvent.keyDown(listbox, { key: 'ArrowUp' }) // clamp: stays at 0
expect(highlightedId()).toBe('0')
fireEvent.keyDown(listbox, { key: 'ArrowDown' })
expect(highlightedId()).toBe('1')
fireEvent.keyDown(listbox, { key: 'ArrowDown' })
expect(highlightedId()).toBe('2')
fireEvent.keyDown(listbox, { key: 'ArrowDown' }) // clamp: stays at last index
expect(highlightedId()).toBe('2')
it('ArrowDown/ArrowUp move the cursor and clamp at the ends', () => {
renderSelect('eth')
open()
fireEvent.keyDown(trigger(), { key: 'ArrowUp' })
expect(cursor()).toBe('0')
fireEvent.keyDown(trigger(), { key: 'ArrowDown' })
expect(cursor()).toBe('1')
fireEvent.keyDown(trigger(), { key: 'ArrowDown' })
expect(cursor()).toBe('2')
fireEvent.keyDown(trigger(), { key: 'ArrowDown' })
expect(cursor()).toBe('2')
})
it('Home/End jump to the first/last option', () => {
renderSelect('sol')
openPopover()
const listbox = screen.getByRole('listbox')
fireEvent.keyDown(listbox, { key: 'End' })
expect(highlightedId()).toBe('2')
fireEvent.keyDown(listbox, { key: 'Home' })
expect(highlightedId()).toBe('0')
open()
fireEvent.keyDown(trigger(), { key: 'End' })
expect(cursor()).toBe('2')
fireEvent.keyDown(trigger(), { key: 'Home' })
expect(cursor()).toBe('0')
})
it('Enter commits the highlighted option, not just the selected one', () => {
it('Enter commits where the cursor is, not where the value was', () => {
const { onChange } = renderSelect('eth')
openPopover()
const listbox = screen.getByRole('listbox')
fireEvent.keyDown(listbox, { key: 'ArrowDown' }) // highlight -> sol
fireEvent.keyDown(listbox, { key: 'Enter' })
open()
fireEvent.keyDown(trigger(), { key: 'ArrowDown' })
fireEvent.keyDown(trigger(), { key: 'Enter' })
expect(onChange).toHaveBeenCalledWith(options[1])
})
it('Space also commits the highlighted option', () => {
it('Space also commits where the cursor is', () => {
const { onChange } = renderSelect('eth')
openPopover()
const listbox = screen.getByRole('listbox')
fireEvent.keyDown(listbox, { key: 'ArrowDown' })
fireEvent.keyDown(listbox, { key: ' ' })
open()
fireEvent.keyDown(trigger(), { key: 'ArrowDown' })
fireEvent.keyDown(trigger(), { key: ' ' })
expect(onChange).toHaveBeenCalledWith(options[1])
})
it('hovering an option updates the highlight', () => {
it('reaches every option from the keyboard alone — open, walk, commit', () => {
const { onChange } = renderSelect('eth')
fireEvent.keyDown(trigger(), { key: 'Enter' })
fireEvent.keyDown(trigger(), { key: 'ArrowDown' })
fireEvent.keyDown(trigger(), { key: 'ArrowDown' })
fireEvent.keyDown(trigger(), { key: 'Enter' })
expect(onChange).toHaveBeenCalledWith(options[2])
})
it('hovering an option moves the cursor', () => {
renderSelect('eth')
openPopover()
open()
fireEvent.mouseEnter(screen.getByRole('option', { name: /XRP/ }))
expect(highlightedId()).toBe('2')
expect(cursor()).toBe('2')
})
it('names the option under the cursor, so the reading follows the arrows', () => {
renderSelect('eth')
open()
const on = () => trigger().getAttribute('aria-activedescendant')
const first = on()
expect(first).toBe(screen.getAllByRole('option')[0]!.id)
fireEvent.keyDown(trigger(), { key: 'ArrowDown' })
expect(on()).toBe(screen.getAllByRole('option')[1]!.id)
})
})
describe('Select — the control has a name', () => {
it('a drawn label names the control and points at it', () => {
render(<Select label="From" value={options[0]} options={options} onChange={vi.fn()} />)
const control = screen.getByRole('combobox', { name: /From/ })
const label = screen.getByText('From')
expect(label.getAttribute('for')).toBe(control.id)
})
it('a name reads without drawing a second word on screen', () => {
render(<Select name="You send asset" value={options[0]} options={options} onChange={vi.fn()} />)
expect(screen.getByRole('combobox', { name: 'You send asset' })).toBeTruthy()
expect(screen.queryByText('You send asset')).toBeNull()
})
it('the name survives a custom trigger body', () => {
render(
<Select
name="You receive asset"
value={options[0]}
options={options}
onChange={vi.fn()}
renderTrigger={(opt) => <span>Custom: {opt.label}</span>}
/>,
)
expect(screen.getByRole('combobox', { name: 'You receive asset' })).toBeTruthy()
expect(screen.getByText('Custom: ETH')).toBeTruthy()
})
})
describe('Select — rendering', () => {
it('marks the currently-selected option with aria-selected', () => {
renderSelect('sol')
openPopover()
const solOption = screen.getByRole('option', { name: /SOL/ })
expect(solOption.getAttribute('aria-selected')).toBe('true')
const ethOption = screen.getByRole('option', { name: /ETH/ })
expect(ethOption.getAttribute('aria-selected')).toBe('false')
open()
expect(screen.getByRole('option', { name: /SOL/ }).getAttribute('aria-selected')).toBe('true')
expect(screen.getByRole('option', { name: /ETH/ }).getAttribute('aria-selected')).toBe('false')
})
it('renders the secondary label under the primary one', () => {
renderSelect()
openPopover()
open()
expect(screen.getByText('Ripple')).toBeTruthy()
})
it('supports a custom renderTrigger', () => {
const value = options[0]
render(
<Select
value={value}
options={options}
onChange={vi.fn()}
renderTrigger={(opt) => <span>Custom: {opt.label}</span>}
/>,
)
expect(screen.getByText('Custom: ETH')).toBeTruthy()
})
it('renders a field label when provided', () => {
render(<Select label="Asset" value={options[0]} options={options} onChange={vi.fn()} />)
expect(screen.getByText('Asset')).toBeTruthy()
it('puts one tab stop on the whole picker, not one per option', () => {
renderSelect()
open()
const stops = document.querySelectorAll('[tabindex]:not([tabindex="-1"]), button, a[href], input')
expect(stops.length).toBe(1)
expect(stops[0]).toBe(trigger())
})
})
@@ -82,7 +82,7 @@ beforeEach(() => {
describe('TransferStatus — empty + basic row', () => {
it('shows the empty-state message with no transfers', () => {
render(<TransferStatus transfers={[]} />)
expect(screen.getByText(/No transfers yet/)).toBeTruthy()
expect(screen.getByText(/Nothing yet/)).toBeTruthy()
})
it('renders amounts, resolved chain names, and the phase label', () => {
@@ -166,9 +166,12 @@ describe('TransferStatus — MPC session line', () => {
]}
/>,
)
expect(screen.getByText('cggmp21')).toBeTruthy()
expect(screen.getByText('sess_a…7890')).toBeTruthy()
// The reference support asks for, and where the release has got to.
// Which protocol the signers ran is not drawn: a person cannot act on it,
// and it is the one line on this row every visitor reads.
expect(screen.getByText('Ref sess_a…7890')).toBeTruthy()
expect(screen.getByText('· signing')).toBeTruthy()
expect(screen.queryByText(/cggmp21/i)).toBeNull()
})
it('shows "session aborted" instead of a truncated id when aborted', () => {
@@ -182,7 +185,7 @@ describe('TransferStatus — MPC session line', () => {
]}
/>,
)
expect(screen.getByText('session aborted')).toBeTruthy()
expect(screen.getByText('Release cancelled')).toBeTruthy()
expect(screen.getByText('· quorum lost')).toBeTruthy()
})
})
@@ -190,13 +193,13 @@ describe('TransferStatus — MPC session line', () => {
describe('TransferStatus — cosigner badges', () => {
it('renders no badges when no layered cosigner is configured', () => {
render(<TransferStatus transfers={[baseTransfer()]} />)
expect(screen.queryByText('Native MPC')).toBeNull()
expect(screen.queryByText('Lux')).toBeNull()
})
it('renders Native MPC + Utila when utila is configured', () => {
mockConfig = { mpc: { utila: { vaultId: 'vault-1' } } } as Partial<BridgeConfig>
render(<TransferStatus transfers={[baseTransfer()]} />)
expect(screen.getByText('Native MPC')).toBeTruthy()
expect(screen.getByText('Lux')).toBeTruthy()
expect(screen.getByText('+ Utila')).toBeTruthy()
expect(screen.queryByText('+ Fireblocks')).toBeNull()
})
+79 -29
View File
@@ -61,14 +61,50 @@ const stack: React.CSSProperties = {
gap: 'var(--bridge-stack-gap)',
}
const title: React.CSSProperties = {
fontSize: 'var(--bridge-title-size)',
fontWeight: 700,
letterSpacing: '-0.03em',
lineHeight: 1.1,
margin: 0,
}
// The sentence under the title. Set at body size in the muted ink rather than
// the subtle one: it is the only place the page says what it does, and a
// screen that dims its own explanation has written it for nobody.
const intro: React.CSSProperties = {
fontSize: 'var(--bridge-body-size)',
lineHeight: 1.55,
color: 'var(--bridge-text-muted)',
margin: '10px 0 0',
}
const footer: React.CSSProperties = {
textAlign: 'center',
padding: '20px 16px',
fontSize: 11,
color: 'var(--bridge-text-subtle)',
display: 'flex',
justifyContent: 'center',
padding: '28px var(--bridge-page-padding-x) 40px',
borderTop: '1px solid var(--bridge-border)',
}
const footerText: React.CSSProperties = {
width: '100%',
maxWidth: 'var(--bridge-stack-max-width)',
fontSize: 'var(--bridge-note-size)',
lineHeight: 1.6,
color: 'var(--bridge-text-subtle)',
}
const footerLink: React.CSSProperties = {
color: 'var(--bridge-text-muted)',
textDecoration: 'underline',
textUnderlineOffset: 3,
}
const footerCode: React.CSSProperties = {
fontFamily: 'var(--bridge-font-mono)',
color: 'var(--bridge-text-muted)',
}
/**
* Inner BridgeApp tree — runs *inside* WagmiProvider so the wallet hooks
* have a wagmi context to read.
@@ -119,36 +155,50 @@ const BridgeAppInner: FC = () => {
<Header wallet={wallet} fromChain={swap.fromChain} />
<main style={main}>
<div style={stack}>
<div>
<h1 style={title}>Bridge</h1>
<p style={intro}>
Move an asset from one chain to another. Name the two chains and
an amount, and this quotes the crossing before you commit to it.
</p>
</div>
<SwapForm swap={swap} wallet={wallet} transfers={transfers} />
<TransferStatus transfers={transfers.transfers} />
</div>
</main>
<footer style={footer}>
<span>Network: {env}</span>
{docsUrl ? (
<>
{' · '}
<a
href={docsUrl}
target="_blank"
rel="noreferrer noopener"
style={{ color: 'var(--bridge-text-muted)' }}
>
Docs
</a>
</>
) : null}
{supportEmail ? (
<>
{' · '}
<a
href={`mailto:${supportEmail}`}
style={{ color: 'var(--bridge-text-muted)' }}
>
{supportEmail}
</a>
</>
) : null}
<div style={footerText}>
<p style={{ margin: 0 }}>
You keep your keys. This page never holds one — you pay a deposit
address from a wallet you control, and the far side is delivered to
an address you name.
{docsUrl ? (
<>
{' '}
<a
href={docsUrl}
target="_blank"
rel="noreferrer noopener"
style={footerLink}
>
How the bridge works
</a>{' '}
is the longer answer.
</>
) : null}
</p>
<p style={{ margin: '10px 0 0' }}>
Network <span style={footerCode}>{env}</span>
{supportEmail ? (
<>
{' · '}
<a href={`mailto:${supportEmail}`} style={footerLink}>
{supportEmail}
</a>
</>
) : null}
</p>
</div>
</footer>
</div>
)
+31 -25
View File
@@ -42,50 +42,50 @@ const wrap: CSSProperties = {
gap: 6,
}
// One shape for every field name on the card. The chain pickers and the
// destination field said "From" and "Destination address"; these two said
// "YOU SEND" in spaced capitals, which reads as a different form on the same
// card. Sentence case, one size, one weight.
const labelRow: CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: 11,
gap: 12,
fontSize: 'var(--bridge-label-size)',
lineHeight: '20px',
color: 'var(--bridge-text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
fontWeight: 600,
fontWeight: 500,
}
const balanceLine: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
textTransform: 'none',
letterSpacing: 0,
fontWeight: 500,
fontSize: 11,
color: 'var(--bridge-text-muted)',
gap: 8,
fontWeight: 400,
fontSize: 'var(--bridge-note-size)',
color: 'var(--bridge-text-subtle)',
}
const maxBtn: CSSProperties = {
background: 'var(--bridge-accent-soft)',
color: 'var(--bridge-accent)',
border: '1px solid var(--bridge-accent-border)',
background: 'transparent',
color: 'var(--bridge-text)',
border: '1px solid var(--bridge-border-strong)',
borderRadius: 'var(--bridge-radius-pill)',
padding: '2px 8px',
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.05em',
textTransform: 'uppercase',
padding: '3px 10px',
fontSize: 11,
fontWeight: 500,
cursor: 'pointer',
transition: 'background-color var(--bridge-transition-fast)',
transition: 'border-color var(--bridge-transition-fast)',
}
const row: CSSProperties = {
display: 'flex',
gap: 10,
alignItems: 'stretch',
background: 'var(--bridge-bg-input)',
gap: 12,
alignItems: 'center',
background: 'var(--bridge-bg-soft)',
border: '1px solid var(--bridge-border)',
borderRadius: 'var(--bridge-radius-md)',
padding: '12px 14px',
padding: '10px 12px',
}
const inputStyle: CSSProperties = {
@@ -176,12 +176,17 @@ export const AssetInput: FC<AssetInputProps> = ({
<div style={labelRow}>
<span>{label}</span>
{readOnly ? (
<span>estimated</span>
<span style={balanceLine}>Estimated</span>
) : balanceText !== null ? (
<span style={balanceLine}>
<span>Balance: {balanceText} {asset.symbol}</span>
{showMax && balanceText !== '…' && balanceQuery.data ? (
<button type="button" style={maxBtn} onClick={onMax}>
<button
type="button"
style={maxBtn}
onClick={onMax}
aria-label={`Send my full ${asset.symbol} balance`}
>
Max
</button>
) : null}
@@ -207,6 +212,7 @@ export const AssetInput: FC<AssetInputProps> = ({
/>
<div style={assetSelectWrap}>
<Select
name={`${label} asset`}
value={value}
options={options}
onChange={(opt) => {
+23 -8
View File
@@ -1,13 +1,19 @@
// Card primitive — a styled container.
// A panel — a bordered region with a name on it.
//
// Phase 3 R2 will replace this with @hanzo/gui's `Card`. The wrapper exists
// so the swap is mechanical: every call site uses `<Card>` and Phase 3 only
// touches this one file.
// The name is part of the panel rather than a heading each caller draws for
// itself, because a page of panels where one is titled at 16px and the next at
// 18px reads as two designs. `title` also gives the region a heading a screen
// reader can jump to, which a bare div with a styled span does not.
//
// Nothing here casts a shadow. The panels sit on the page, and an edge one
// pixel wide does the separating.
import type { CSSProperties, FC, ReactNode } from 'react'
export interface CardProps {
children: ReactNode
/** What this panel is. Drawn as its heading and read as its region name. */
title?: string
style?: CSSProperties
/** Skip default padding (caller provides its own). */
bare?: boolean
@@ -21,17 +27,26 @@ const base: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 'var(--bridge-card-gap)',
boxShadow: 'var(--bridge-shadow-card)',
}
export const Card: FC<CardProps> = ({ children, style, bare }) => (
<div
const heading: CSSProperties = {
margin: 0,
fontSize: 'var(--bridge-panel-title-size)',
fontWeight: 600,
letterSpacing: '-0.02em',
color: 'var(--bridge-text)',
}
export const Card: FC<CardProps> = ({ children, title, style, bare }) => (
<section
aria-label={title}
style={{
...base,
...(bare ? { padding: 0 } : null),
...style,
}}
>
{title ? <h2 style={heading}>{title}</h2> : null}
{children}
</div>
</section>
)
+31 -58
View File
@@ -1,9 +1,10 @@
// Header — brand area + env/protocol chips + wallet connect.
// Header — the brand, which network this is, and the wallet.
//
// Reads brand metadata from the SDK config (which downstream consumers set
// once at mount time via setConfig). The env + MPC protocol chips make
// the active deploy target explicit so users can tell mainnet from testnet
// at a glance and ops can verify the post-quantum protocol is wired right.
// once at mount time via setConfig). The environment chip stays because
// "am I on mainnet or testnet" is a fact a person needs before they send
// money. What the signing cluster runs is not: it names an internal protocol
// a user cannot act on, and a header is the one surface every visitor reads.
import type { CSSProperties, FC } from 'react'
import { getConfig } from '../../config'
@@ -22,7 +23,7 @@ const header: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
gap: 12,
padding: 'var(--bridge-header-padding-y) var(--bridge-header-padding-x)',
borderBottom: '1px solid var(--bridge-border)',
background: 'var(--bridge-bg)',
@@ -40,69 +41,49 @@ const brand: CSSProperties = {
}
const brandLogo: CSSProperties = {
width: 28,
height: 28,
width: 24,
height: 24,
borderRadius: 6,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background:
'linear-gradient(135deg, var(--bridge-accent), var(--bridge-accent-hover))',
color: 'white',
fontSize: 14,
fontWeight: 700,
background: 'var(--bridge-bg-input)',
border: '1px solid var(--bridge-border)',
color: 'var(--bridge-text)',
fontSize: 12,
fontWeight: 600,
}
const brandName: CSSProperties = {
fontSize: 'var(--bridge-brand-name-size)',
fontWeight: 600,
letterSpacing: '-0.01em',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}
const chipRow: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
marginLeft: 8,
minWidth: 0,
}
const chipBase: CSSProperties = {
const chip: CSSProperties = {
display: 'var(--bridge-chip-display)',
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.05em',
alignItems: 'center',
marginLeft: 4,
fontSize: 11,
fontWeight: 500,
letterSpacing: '0.04em',
textTransform: 'uppercase',
padding: '2px 6px',
borderRadius: 4,
padding: '3px 8px',
borderRadius: 'var(--bridge-radius-pill)',
border: '1px solid var(--bridge-border)',
color: 'var(--bridge-text-muted)',
background: 'var(--bridge-bg-input)',
background: 'var(--bridge-bg-soft)',
whiteSpace: 'nowrap',
}
const envMainnet: CSSProperties = {
...chipBase,
const chipLive: CSSProperties = {
...chip,
color: 'var(--bridge-success)',
borderColor: 'rgba(76, 175, 80, 0.32)',
background: 'rgba(76, 175, 80, 0.08)',
}
const envTestnet: CSSProperties = {
...chipBase,
color: 'var(--bridge-accent)',
borderColor: 'rgba(91, 141, 239, 0.32)',
background: 'rgba(91, 141, 239, 0.08)',
}
// The MPC protocol chip is secondary information (ops + dev focus) — hide
// it on phone-sized viewports to free header width for the wallet pill.
// Driven by --bridge-mpc-chip-display so theme.css owns the breakpoint.
const mpcChip: CSSProperties = {
...chipBase,
display: 'var(--bridge-mpc-chip-display)' as CSSProperties['display'],
borderColor: 'var(--bridge-success-border)',
background: 'var(--bridge-success-soft)',
}
const right: CSSProperties = {
@@ -119,16 +100,15 @@ export const Header: FC<HeaderProps> = ({ wallet, fromChain }) => {
const name = cfg.brand?.name ?? 'Bridge'
const logo = cfg.brand?.logoUrl
const initial = name.charAt(0).toUpperCase()
const envStyle = cfg.env === 'mainnet' ? envMainnet : envTestnet
const protocol = cfg.mpc?.protocol ?? 'cggmp21'
const live = cfg.env === 'mainnet'
return (
<header style={header}>
<div style={brand}>
{logo ? (
<img
src={logo}
alt={name}
style={{ width: 28, height: 28, borderRadius: 6 }}
alt=""
style={{ width: 24, height: 24, borderRadius: 6 }}
/>
) : (
<span style={brandLogo} aria-hidden>
@@ -136,14 +116,7 @@ export const Header: FC<HeaderProps> = ({ wallet, fromChain }) => {
</span>
)}
<span style={brandName}>{name}</span>
<div style={chipRow}>
<span style={envStyle} title={`Bridge environment: ${cfg.env}`}>
{cfg.env}
</span>
<span style={mpcChip} title={`MPC threshold protocol: ${protocol}`}>
mpc · {protocol}
</span>
</div>
<span style={live ? chipLive : chip}>{cfg.env}</span>
</div>
<div style={right}>
<WalletConnect wallet={wallet} fromChain={fromChain} />
+120 -132
View File
@@ -1,18 +1,27 @@
// Custom popover-style Select.
// A picker over a named set — a chain, an asset.
//
// Replaces native <select> with a controllable trigger + popover menu so
// each option can carry a logo and secondary text (e.g. asset name under
// symbol). The native <select> couldn't render either, which is why the
// chain/asset pickers used to look out of place vs the rest of the chrome.
// Replaces native <select> so each option can carry a logo and a secondary
// line (an asset's name under its symbol, a chain's family under its name).
// The native control renders neither, which is why the pickers used to look
// pasted onto the rest of the card.
//
// Behavior: click-outside closes, Escape closes, ↑/↓ moves the highlight,
// Enter / Space commits the highlighted item. Click anywhere outside the
// trigger or popover dismisses without committing.
// Keyboard, and why it is all on the trigger. The menu used to hold the key
// handler, and nothing ever focused the menu — so opening with Enter left the
// arrows dead and the only way through the list was a mouse. The trigger keeps
// focus for the whole interaction and `aria-activedescendant` says which option
// the keyboard is on, which is the combobox pattern and the reason there is one
// handler here rather than two that can disagree.
//
// No portal dependency — the popover anchors directly below the trigger
// via absolute positioning. This keeps the SDK free of @hanzogui/portal
// for now; if a host page clips the popover with overflow:hidden we can
// re-introduce a portal at that point.
// The label names the control. `label` renders a <label> that points at the
// trigger's own id, so a screen reader reads "From, Lux" rather than "Lux" —
// and where a field's name is already carried by the layout (the asset picker
// sits inside a row the amount input already names), `name` supplies it without
// drawing a second word on screen. One of the two is required, which is what
// makes a nameless picker unspellable rather than merely discouraged.
//
// No portal. The menu anchors below the trigger with absolute positioning; if
// a host page ever clips it with overflow:hidden that is when a portal earns
// its dependency.
import {
useCallback,
@@ -39,9 +48,16 @@ export interface SelectOption {
logoUrl?: string
}
export interface SelectProps {
/** Field label, rendered above the trigger. */
label?: string
/**
* What this picker is for, said once. Either it is drawn above the control
* (`label`) or it is read out without being drawn (`name`) — never neither,
* which is the shape of the rule rather than a comment asking for it.
*/
type Named =
| { label: ReactNode; name?: never }
| { name: string; label?: never }
export type SelectProps = Named & {
/** Currently-selected option. */
value: SelectOption
/** Full option set. */
@@ -58,47 +74,23 @@ const wrap: CSSProperties = {
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: 6,
gap: 8,
minWidth: 0,
flex: 1,
}
const labelStyle: CSSProperties = {
fontSize: 11,
fontSize: 'var(--bridge-label-size)',
color: 'var(--bridge-text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
fontWeight: 600,
}
const trigger: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 8,
background: 'var(--bridge-bg-input)',
border: '1px solid var(--bridge-border)',
borderRadius: 'var(--bridge-radius-md)',
color: 'var(--bridge-text)',
padding: '10px 12px',
fontSize: 14,
fontWeight: 500,
outline: 'none',
width: '100%',
cursor: 'pointer',
transition: 'background-color var(--bridge-transition-fast), border-color var(--bridge-transition-fast)',
lineHeight: '20px',
}
const triggerOpen: CSSProperties = {
background: 'var(--bridge-bg-hover)',
borderColor: 'var(--bridge-border-focus)',
boxShadow: '0 0 0 3px var(--bridge-accent-soft)',
}
const caret: CSSProperties = {
marginLeft: 'auto',
fontSize: 10,
color: 'var(--bridge-text-muted)',
transition: 'transform var(--bridge-transition-fast)',
const triggerText: CSSProperties = {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
const popover: CSSProperties = {
@@ -110,7 +102,7 @@ const popover: CSSProperties = {
}
const itemSecondary: CSSProperties = {
fontSize: 11,
fontSize: 'var(--bridge-note-size)',
color: 'var(--bridge-text-muted)',
fontWeight: 400,
}
@@ -126,15 +118,7 @@ const itemPrimaryRow: CSSProperties = {
const DefaultTrigger: FC<{ option: SelectOption }> = ({ option }) => (
<>
<Logo url={option.logoUrl} fallback={option.label} />
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{option.label}
</span>
<span style={triggerText}>{option.label}</span>
</>
)
@@ -173,7 +157,7 @@ export const Logo: FC<{
<span className={cls}>
<img
src={url}
alt={fallback}
alt=""
loading="lazy"
onError={() => setFailed(true)}
/>
@@ -187,8 +171,25 @@ export const Logo: FC<{
)
}
/** Where an arrow key lands, or null for a key this control does not own. */
function moved(key: string, at: number, of: number): number | null {
switch (key) {
case 'ArrowDown':
return Math.min(of - 1, at + 1)
case 'ArrowUp':
return Math.max(0, at - 1)
case 'Home':
return 0
case 'End':
return of - 1
default:
return null
}
}
export const Select: FC<SelectProps> = ({
label,
name,
value,
options,
onChange,
@@ -196,18 +197,21 @@ export const Select: FC<SelectProps> = ({
renderTrigger,
}) => {
const [open, setOpen] = useState(false)
const [highlight, setHighlight] = useState<number>(() =>
const [at, setAt] = useState<number>(() =>
Math.max(0, options.findIndex((o) => o.id === value.id)),
)
const wrapRef = useRef<HTMLDivElement | null>(null)
const popoverRef = useRef<HTMLDivElement | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const listboxId = useId()
const own = useId()
const listId = `${own}-list`
const labelId = `${own}-label`
const optionId = (i: number) => `${own}-opt-${i}`
// Sync highlight when value changes externally.
// Sync the cursor when the value changes externally.
useEffect(() => {
const idx = options.findIndex((o) => o.id === value.id)
if (idx >= 0) setHighlight(idx)
const i = options.findIndex((o) => o.id === value.id)
if (i >= 0) setAt(i)
}, [value.id, options])
// Click-outside to dismiss.
@@ -215,105 +219,80 @@ export const Select: FC<SelectProps> = ({
if (!open) return
const onDocClick = (e: MouseEvent) => {
if (!wrapRef.current) return
if (!wrapRef.current.contains(e.target as Node)) {
setOpen(false)
}
if (!wrapRef.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
}, [open])
// Auto-scroll highlighted item into view.
// Keep the option the keyboard is on inside the scroll window.
useLayoutEffect(() => {
if (!open || !popoverRef.current) return
const el = popoverRef.current.querySelector<HTMLElement>(
`[data-index="${highlight}"]`,
)
if (el) el.scrollIntoView({ block: 'nearest' })
}, [highlight, open])
popoverRef.current
.querySelector<HTMLElement>(`[data-index="${at}"]`)
?.scrollIntoView({ block: 'nearest' })
}, [at, open])
const commit = useCallback(
(idx: number) => {
const opt = options[idx]
(i: number) => {
const opt = options[i]
if (!opt) return
onChange(opt)
setOpen(false)
// Return focus to trigger after commit for keyboard users.
triggerRef.current?.focus()
},
[options, onChange],
)
const onTriggerKey = useCallback(
// Every key this control answers for, in one place, on the element that
// holds focus for the whole interaction.
const onKey = useCallback(
(e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setOpen(true)
}
},
[],
)
const onListKey = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Escape') {
if (!open) return
e.preventDefault()
setOpen(false)
triggerRef.current?.focus()
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlight((h) => Math.min(options.length - 1, h + 1))
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlight((h) => Math.max(0, h - 1))
return
}
if (e.key === 'Home') {
e.preventDefault()
setHighlight(0)
return
}
if (e.key === 'End') {
e.preventDefault()
setHighlight(options.length - 1)
return
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
commit(highlight)
if (open) commit(at)
else setOpen(true)
return
}
const to = moved(e.key, at, options.length)
if (to === null) return
e.preventDefault()
setAt(to)
setOpen(true)
},
[options.length, highlight, commit],
[open, at, options.length, commit],
)
const triggerStyle: CSSProperties = open
? { ...trigger, ...triggerOpen }
: trigger
const caretStyle: CSSProperties = open
? { ...caret, transform: 'rotate(180deg)' }
: caret
return (
<div ref={wrapRef} style={{ ...wrap, ...style }}>
{label ? <span style={labelStyle}>{label}</span> : null}
{label ? (
<label id={labelId} htmlFor={own} style={labelStyle}>
{label}
</label>
) : null}
<button
ref={triggerRef}
id={own}
type="button"
style={triggerStyle}
className="bridge-select-trigger"
data-open={open ? 'true' : 'false'}
onClick={() => setOpen((o) => !o)}
onKeyDown={onTriggerKey}
onKeyDown={onKey}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
aria-controls={listId}
aria-activedescendant={open ? optionId(at) : undefined}
{...(label ? { 'aria-labelledby': `${labelId} ${own}` } : { 'aria-label': name })}
>
{renderTrigger ? renderTrigger(value) : <DefaultTrigger option={value} />}
<span style={caretStyle} aria-hidden>
▾
</span>
<span className="bridge-select-caret" aria-hidden />
</button>
{open ? (
<div
@@ -321,25 +300,34 @@ export const Select: FC<SelectProps> = ({
className="bridge-popover"
style={popover}
role="listbox"
id={listboxId}
tabIndex={-1}
onKeyDown={onListKey}
id={listId}
aria-label={typeof label === 'string' ? label : name}
>
{options.map((opt, i) => {
const selected = opt.id === value.id
const highlighted = i === highlight
return (
<button
/*
* A div, not a button. An option inside a listbox is reached by
* the arrows on the trigger, and a button inside it puts a
* second tab stop on every row of a list that can be forty long.
* Pointer users still click it; `onMouseDown` rather than
* `onClick` so the commit happens before the click-outside
* listener sees the press.
*/
<div
key={opt.id}
type="button"
id={optionId(i)}
role="option"
className="bridge-popover-item"
data-index={i}
data-selected={selected ? 'true' : 'false'}
data-highlighted={highlighted ? 'true' : 'false'}
data-highlighted={i === at ? 'true' : 'false'}
aria-selected={selected}
onMouseEnter={() => setHighlight(i)}
onClick={() => commit(i)}
onMouseEnter={() => setAt(i)}
onMouseDown={(e) => {
e.preventDefault()
commit(i)
}}
>
<Logo url={opt.logoUrl} fallback={opt.label} />
<div style={itemPrimaryRow}>
@@ -348,7 +336,7 @@ export const Select: FC<SelectProps> = ({
<span style={itemSecondary}>{opt.secondary}</span>
) : null}
</div>
</button>
</div>
)
})}
</div>
+62 -55
View File
@@ -40,12 +40,12 @@ const chainsRow: CSSProperties = {
const reverseBtn: CSSProperties = {
alignSelf: 'flex-end',
flexShrink: 0,
marginBottom: 6,
background: 'var(--bridge-bg-elevated)',
border: '1px solid var(--bridge-border-strong)',
marginBottom: 0,
background: 'var(--bridge-bg-input)',
border: '1px solid var(--bridge-border)',
borderRadius: '50%',
width: 34,
height: 34,
width: 36,
height: 36,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
@@ -55,8 +55,7 @@ const reverseBtn: CSSProperties = {
cursor: 'pointer',
lineHeight: 1,
padding: 0,
transition: 'background-color var(--bridge-transition-fast), border-color var(--bridge-transition-fast), transform var(--bridge-transition-fast)',
boxShadow: '0 2px 6px rgba(0,0,0,0.35)',
transition: 'background-color var(--bridge-transition-fast), border-color var(--bridge-transition-fast)',
}
const quoteCard: CSSProperties = {
@@ -72,7 +71,9 @@ const quoteCard: CSSProperties = {
const quoteRow: CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
fontSize: 12,
gap: 12,
fontSize: 'var(--bridge-note-size)',
lineHeight: 1.6,
color: 'var(--bridge-text-muted)',
}
@@ -84,27 +85,28 @@ const quoteRowStrong: CSSProperties = {
const submit: CSSProperties = {
marginTop: 4,
background:
'linear-gradient(180deg, var(--bridge-accent-hover) 0%, var(--bridge-accent) 100%)',
color: 'white',
border: 'none',
background: 'var(--bridge-text)',
color: 'var(--bridge-bg)',
border: '1px solid var(--bridge-text)',
borderRadius: 'var(--bridge-radius-md)',
padding: '14px 16px',
minHeight: 48,
fontSize: 15,
fontWeight: 600,
letterSpacing: '-0.01em',
cursor: 'pointer',
width: '100%',
transition: 'transform var(--bridge-transition-fast), filter var(--bridge-transition-fast)',
boxShadow: '0 6px 20px var(--bridge-accent-soft)',
transition: 'opacity var(--bridge-transition-fast)',
}
// Disabled says why in its own text, so it stays legible rather than fading
// to a shape. The border is what marks it as the same control, unavailable.
const submitDisabled: CSSProperties = {
...submit,
background: 'var(--bridge-bg-input)',
background: 'transparent',
border: '1px solid var(--bridge-border)',
color: 'var(--bridge-text-muted)',
cursor: 'not-allowed',
boxShadow: 'none',
}
const errorBox: CSSProperties = {
@@ -117,9 +119,9 @@ const errorBox: CSSProperties = {
}
const noticeBox: CSSProperties = {
background: 'var(--bridge-accent-soft)',
border: '1px solid var(--bridge-accent-border)',
color: 'var(--bridge-text-muted)',
background: 'var(--bridge-bg-soft)',
border: '1px solid var(--bridge-border)',
color: 'var(--bridge-text-subtle)',
borderRadius: 'var(--bridge-radius-sm)',
padding: '8px 10px',
fontSize: 11,
@@ -135,21 +137,24 @@ const noticeBadge: CSSProperties = {
fontWeight: 700,
letterSpacing: '0.05em',
textTransform: 'uppercase',
borderRadius: 4,
background: 'rgba(91, 141, 239, 0.18)',
color: 'var(--bridge-accent)',
borderRadius: 'var(--bridge-radius-pill)',
background: 'transparent',
border: '1px solid var(--bridge-border-strong)',
color: 'var(--bridge-text)',
}
const refuelRow: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
gap: 16,
padding: '12px 14px',
background: 'var(--bridge-bg-soft)',
border: '1px solid var(--bridge-border)',
borderRadius: 'var(--bridge-radius-sm)',
fontSize: 12,
color: 'var(--bridge-text-muted)',
borderRadius: 'var(--bridge-radius-md)',
fontSize: 'var(--bridge-note-size)',
lineHeight: 1.5,
color: 'var(--bridge-text-subtle)',
}
const refuelLabel: CSSProperties = {
@@ -161,48 +166,49 @@ const refuelLabel: CSSProperties = {
const destWrap: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
gap: 8,
}
const destLabelRow: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: 11,
fontSize: 'var(--bridge-label-size)',
lineHeight: '20px',
color: 'var(--bridge-text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
fontWeight: 600,
fontWeight: 500,
}
const destUseWalletBtn: CSSProperties = {
background: 'var(--bridge-accent-soft)',
color: 'var(--bridge-accent)',
border: '1px solid var(--bridge-accent-border)',
background: 'transparent',
color: 'var(--bridge-text)',
border: '1px solid var(--bridge-border-strong)',
borderRadius: 'var(--bridge-radius-pill)',
padding: '2px 8px',
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.05em',
textTransform: 'uppercase',
padding: '3px 10px',
fontSize: 11,
fontWeight: 500,
cursor: 'pointer',
}
const destInput: CSSProperties = {
width: '100%',
minHeight: 44,
background: 'var(--bridge-bg-input)',
border: '1px solid var(--bridge-border)',
borderRadius: 'var(--bridge-radius-md)',
color: 'var(--bridge-text)',
padding: '10px 12px',
fontSize: 13,
outline: 'none',
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
fontFamily: 'var(--bridge-font-mono)',
}
// The one sentence on this card that says where the money goes. It used to be
//10px in the faintest ink on the page; it is body text now, because a person
// who cannot read it does not learn that they are paying an address rather
// than pressing a button.
const destHint: CSSProperties = {
fontSize: 10,
fontSize: 'var(--bridge-note-size)',
lineHeight: 1.55,
color: 'var(--bridge-text-subtle)',
letterSpacing: 0,
textTransform: 'none',
@@ -212,14 +218,14 @@ const destHint: CSSProperties = {
const refuelTitle: CSSProperties = {
color: 'var(--bridge-text)',
fontWeight: 500,
fontSize: 12,
fontSize: 'var(--bridge-body-size)',
}
const toggleTrack = (active: boolean): CSSProperties => ({
width: 32,
height: 18,
borderRadius: 'var(--bridge-radius-pill)',
background: active ? 'var(--bridge-accent)' : 'var(--bridge-border-strong)',
background: active ? 'var(--bridge-text)' : 'var(--bridge-border-strong)',
border: 'none',
cursor: 'pointer',
position: 'relative',
@@ -234,8 +240,8 @@ const toggleKnob = (active: boolean): CSSProperties => ({
width: 14,
height: 14,
borderRadius: '50%',
background: 'white',
transition: 'left var(--bridge-transition-fast)',
background: active ? 'var(--bridge-bg)' : 'var(--bridge-text)',
transition: 'left var(--bridge-transition-fast), background-color var(--bridge-transition-fast)',
})
export const SwapForm: FC<SwapFormProps> = ({ swap, wallet, transfers }) => {
@@ -333,7 +339,7 @@ export const SwapForm: FC<SwapFormProps> = ({ swap, wallet, transfers }) => {
}
return (
<Card>
<Card title="Crossing">
<div style={chainsRow}>
<ChainSelector
label="From"
@@ -411,10 +417,10 @@ export const SwapForm: FC<SwapFormProps> = ({ swap, wallet, transfers }) => {
aria-label="Destination address"
/>
<span style={destHint}>
The bridge will issue an MPC-secured deposit address — send your{' '}
{swap.fromAsset.symbol} from any {swap.fromChain.name} wallet to
that address and the {swap.toAsset.symbol} will arrive at your
destination after the threshold quorum signs.
You will be given a deposit address to pay. Send your{' '}
{swap.fromAsset.symbol} to it from any {swap.fromChain.name} wallet;
the {swap.toAsset.symbol} arrives here once the deposit is confirmed
and the release is approved. No single party can release it alone.
</span>
</div>
@@ -427,7 +433,7 @@ export const SwapForm: FC<SwapFormProps> = ({ swap, wallet, transfers }) => {
type="button"
role="switch"
aria-checked={swap.refuel}
aria-label="Toggle refuel"
aria-label="Refuel destination gas"
style={toggleTrack(swap.refuel)}
onClick={() => swap.setRefuel(!swap.refuel)}
>
@@ -473,9 +479,10 @@ export const SwapForm: FC<SwapFormProps> = ({ swap, wallet, transfers }) => {
{cosigners.length > 0 ? (
<div style={noticeBox}>
<span style={noticeBadge}>2-of-2</span>
<span style={noticeBadge}>2 approvals</span>
<span>
Settlement gated by native Lux MPC + {cosigners.join(' + ')} cosign.
Release needs Lux and {cosigners.join(' and ')} to approve, not
either one alone.
</span>
</div>
) : null}
@@ -27,7 +27,7 @@ export interface TransferStatusProps {
const phaseLabel: Record<TransferPhase, string> = {
pending: 'Pending',
signing: 'Signing (MPC threshold)',
signing: 'Approving release',
broadcasting: 'Broadcasting',
completed: 'Completed',
refunding: 'Refunding',
@@ -37,8 +37,8 @@ const phaseLabel: Record<TransferPhase, string> = {
const phaseColor: Record<TransferPhase, string> = {
pending: 'var(--bridge-text-muted)',
signing: 'var(--bridge-accent)',
broadcasting: 'var(--bridge-accent-hover)',
signing: 'var(--bridge-text)',
broadcasting: 'var(--bridge-text)',
completed: 'var(--bridge-success)',
// Refund phases sit between "still working" and "done" — render in a
// warning hue so users can tell they got their funds back but the
@@ -49,18 +49,10 @@ const phaseColor: Record<TransferPhase, string> = {
failed: 'var(--bridge-danger)',
}
const heading: CSSProperties = {
fontSize: 12,
color: 'var(--bridge-text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
marginBottom: 4,
}
const empty: CSSProperties = {
fontSize: 12,
fontSize: 'var(--bridge-body-size)',
lineHeight: 1.55,
color: 'var(--bridge-text-subtle)',
fontStyle: 'italic',
}
const row: CSSProperties = {
@@ -86,35 +78,39 @@ const dot: CSSProperties = {
marginRight: 8,
}
const mpcLine: CSSProperties = {
const sessionLine: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 11,
color: 'var(--bridge-text-muted)',
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
gap: 6,
flexWrap: 'wrap',
fontSize: 'var(--bridge-note-size)',
color: 'var(--bridge-text-subtle)',
fontFamily: 'var(--bridge-font-mono)',
}
const badgeRow: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
flexWrap: 'wrap',
}
const badgeNote: CSSProperties = {
fontSize: 'var(--bridge-note-size)',
color: 'var(--bridge-text-subtle)',
}
const badge: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '2px 6px',
fontSize: 10,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
borderRadius: 4,
background: 'rgba(91, 141, 239, 0.12)',
color: 'var(--bridge-accent)',
border: '1px solid rgba(91, 141, 239, 0.32)',
padding: '3px 8px',
fontSize: 11,
fontWeight: 500,
borderRadius: 'var(--bridge-radius-pill)',
background: 'var(--bridge-bg-soft)',
color: 'var(--bridge-text-muted)',
border: '1px solid var(--bridge-border)',
}
const depositPanel: CSSProperties = {
@@ -122,8 +118,8 @@ const depositPanel: CSSProperties = {
flexDirection: 'column',
gap: 6,
padding: 10,
background: 'var(--bridge-accent-soft)',
border: '1px solid var(--bridge-accent-border)',
background: 'var(--bridge-bg-soft)',
border: '1px solid var(--bridge-border-strong)',
borderRadius: 'var(--bridge-radius-md)',
}
@@ -135,7 +131,7 @@ const depositHeading: CSSProperties = {
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--bridge-accent)',
color: 'var(--bridge-text)',
}
const depositAddressRow: CSSProperties = {
@@ -231,21 +227,23 @@ export const TransferStatus: FC<TransferStatusProps> = ({ transfers }) => {
if (cfg.mpc?.utila) {
cosignerBadges.push({
label: 'Utila',
title: `Layered cosign — Utila vault ${cfg.mpc.utila.vaultId ?? cfg.mpc.utila.orgId}`,
title: 'Utila must also approve the release',
})
}
if (cfg.mpc?.fireblocks) {
cosignerBadges.push({
label: 'Fireblocks',
title: `Layered cosign — Fireblocks vault ${cfg.mpc.fireblocks.vaultAccountId ?? cfg.mpc.fireblocks.apiKey}`,
title: 'Fireblocks must also approve the release',
})
}
return (
<Card>
<div style={heading}>Transfers</div>
<Card title="Transfers">
{transfers.length === 0 ? (
<div style={empty}>No transfers yet. Submit a bridge above to start.</div>
<div style={empty}>
Nothing yet. A crossing you start appears here and stays until it
settles or is refunded.
</div>
) : (
<div>
{transfers.map((t) => (
@@ -276,15 +274,18 @@ export const TransferStatus: FC<TransferStatusProps> = ({ transfers }) => {
<DepositPanel transfer={t} fromChainName={shortChainName(t.fromChainId)} />
) : null}
{/*
The release reference and where it has got to. The session id
is here because it is what support asks for; which protocol
the signers ran is not something a person can act on, so it
is not drawn.
*/}
{t.mpc ? (
<div style={mpcLine}>
<span style={badge} title={`MPC protocol ${t.mpc.protocol}`}>
{t.mpc.protocol}
</span>
<div style={sessionLine}>
<span>
{t.mpc.sessionId === 'aborted'
? 'session aborted'
: shortAddress(t.mpc.sessionId, 6, 4)}
? 'Release cancelled'
: `Ref ${shortAddress(t.mpc.sessionId, 6, 4)}`}
</span>
<span style={{ color: phaseColor[t.phase] }}>
· {t.mpc.status}
@@ -299,14 +300,13 @@ export const TransferStatus: FC<TransferStatusProps> = ({ transfers }) => {
{cosignerBadges.length > 0 ? (
<div style={badgeRow}>
<span style={badge} title="Native Lux MPC threshold sign">
Native MPC
</span>
<span style={badge}>Lux</span>
{cosignerBadges.map((b) => (
<span key={b.label} style={badge} title={b.title}>
+ {b.label}
</span>
))}
<span style={badgeNote}>both must approve the release</span>
</div>
) : null}
+36 -19
View File
@@ -66,30 +66,37 @@ const wrap: CSSProperties = {
gap: 4,
}
// The brand primary is black on this tenant, and a black fill on a black page
// is a button with no edge — measured, the header's only call to action was
// white text and nothing else. So the affordance is the outline: the same
// bordered pill the rest of the chrome wears, at the one weight that says
// press me.
const buttonBase: CSSProperties = {
background: 'var(--bridge-accent)',
color: 'white',
border: 'none',
borderRadius: 'var(--bridge-radius-sm)',
padding: '8px 14px',
background: 'transparent',
color: 'var(--bridge-text)',
border: '1px solid var(--bridge-text)',
borderRadius: 'var(--bridge-radius-pill)',
padding: '0 16px',
height: 36,
fontSize: 13,
fontWeight: 600,
fontWeight: 500,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 8,
transition: 'background-color 120ms ease',
whiteSpace: 'nowrap',
transition: 'background-color var(--bridge-transition-fast), color var(--bridge-transition-fast)',
}
const pillBase: CSSProperties = {
background: 'var(--bridge-bg-input)',
color: 'var(--bridge-text)',
border: '1px solid var(--bridge-border)',
borderRadius: 'var(--bridge-radius-sm)',
padding: '6px 10px',
borderRadius: 'var(--bridge-radius-pill)',
padding: '0 14px',
height: 36,
fontSize: 12,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
fontFamily: 'var(--bridge-font-mono)',
display: 'inline-flex',
alignItems: 'center',
gap: 8,
@@ -341,7 +348,8 @@ const NonEvmConnect: FC<{ wallet: BridgeWallet; fromChain: Chain }> = ({
if (address) {
return (
<Button
<button
type="button"
style={pillBase}
onClick={() => {
void wallet.disconnect(family)
@@ -359,13 +367,14 @@ const NonEvmConnect: FC<{ wallet: BridgeWallet; fromChain: Chain }> = ({
aria-hidden
/>
{shortAddress(address)}
</Button>
</button>
)
}
return (
<div style={wrap}>
<Button
<button
type="button"
style={{
...buttonBase,
opacity: wallet.connecting ? 0.7 : 1,
@@ -376,7 +385,7 @@ const NonEvmConnect: FC<{ wallet: BridgeWallet; fromChain: Chain }> = ({
}}
>
{wallet.connecting ? 'Connecting…' : `Connect ${label} Wallet`}
</Button>
</button>
{error ? (
<span
role="alert"
@@ -487,7 +496,8 @@ const EvmConnect: FC<{ wallet: BridgeWallet; defaultChainId: string }> = ({
// Connected — show the address pill (no picker).
if (wallet.address) {
return (
<Button
<button
type="button"
style={pillBase}
onClick={wallet.disconnect}
aria-label={`Disconnect wallet ${wallet.address}`}
@@ -503,7 +513,7 @@ const EvmConnect: FC<{ wallet: BridgeWallet; defaultChainId: string }> = ({
aria-hidden
/>
{shortAddress(wallet.address)}
</Button>
</button>
)
}
@@ -622,7 +632,14 @@ const EvmConnect: FC<{ wallet: BridgeWallet; defaultChainId: string }> = ({
return (
<div ref={triggerWrapRef} style={wrap}>
<Button
{/*
A native button. The kit's takes focus on mount, which put a keyboard
user and a screen reader on "Connect Wallet" before either had heard
what the page was — measured, it was the document's activeElement at
load. Nothing here needs what the kit adds.
*/}
<button
type="button"
style={{
...buttonBase,
opacity: wallet.connecting ? 0.7 : 1,
@@ -636,7 +653,7 @@ const EvmConnect: FC<{ wallet: BridgeWallet; defaultChainId: string }> = ({
aria-expanded={open}
>
{wallet.connecting ? 'Connecting…' : 'Connect Wallet'}
</Button>
</button>
{dialogNode}
</div>
)
+165 -73
View File
@@ -30,41 +30,55 @@
}
:root {
/* Surfaces — layered dark stack with subtle blue undertone for depth. */
--bridge-bg: #07080b;
--bridge-bg-soft: #0d0e13;
--bridge-bg-elevated: #13141a;
--bridge-bg-input: #1a1c24;
--bridge-bg-hover: #20232d;
/* Surfaces. A near-black ground with two lifts above it — the page, the
panel, the control. Three greys is what the screen needs to separate
them; a fourth reads as a gradient nobody asked for. */
--bridge-bg: #000000;
--bridge-bg-soft: #0a0a0b;
--bridge-bg-elevated: #0d0d0e;
--bridge-bg-input: #171717;
--bridge-bg-hover: #1f1f1f;
/* Borders. */
--bridge-border: #23252e;
--bridge-border-strong: #2e313c;
--bridge-border-focus: #4661c2;
--bridge-border: #262626;
--bridge-border-strong: #333333;
--bridge-border-focus: #a3a3a3;
/* Text. */
--bridge-text: #f7f8fa;
--bridge-text-muted: #9097a5;
--bridge-text-subtle: #5f6573;
/* Text.
*
* Three weights of legibility, and every one of them is meant to be read.
* `subtle` is the floor: at 12px on this ground it measures 6.2:1, which is
* where a sentence explaining what a deposit address is has to sit. A hint
* a person squints at is a hint that does not get read, and the sentence
* under the destination field is the one that says where the money goes. */
--bridge-text: #ededed;
--bridge-text-muted: #a3a3a3;
--bridge-text-subtle: #8b8b8b;
/* Accent — driven by `--brand-primary`, with sane fallback. */
/* The brand hue, published for host pages to paint with.
*
* The SDK's own chrome does not read it, and that is deliberate rather than
* an oversight. A tenant sets `--brand-primary` to whatever its brand is —
* Lux sets it to black — and a control that paints its selected row, its
* live badge and its toggle in that colour paints them black on a black
* page. Measured: the selected option's label and the phase dot both went
* invisible. Marking a thing is the neutral scale's job, which is legible
* by construction; the brand shows up where a brand belongs, in the mark
* and the wordmark. */
--bridge-accent: var(--brand-primary, #5b8def);
--bridge-accent-hover: var(--brand-secondary, #7a9ff5);
--bridge-accent-soft: rgba(91, 141, 239, 0.12);
--bridge-accent-border: rgba(91, 141, 239, 0.32);
/* Semantic. */
--bridge-danger: #f43d3d;
--bridge-danger-soft: rgba(244, 61, 61, 0.1);
--bridge-danger-border: rgba(244, 61, 61, 0.4);
--bridge-success: #34c759;
--bridge-success-soft: rgba(52, 199, 89, 0.1);
--bridge-success-border: rgba(52, 199, 89, 0.32);
--bridge-warning: #ffb02e;
--bridge-danger: #f87171;
--bridge-danger-soft: rgba(248, 113, 113, 0.1);
--bridge-danger-border: rgba(248, 113, 113, 0.35);
--bridge-success: #4ade80;
--bridge-success-soft: rgba(74, 222, 128, 0.1);
--bridge-success-border: rgba(74, 222, 128, 0.3);
--bridge-warning: #fbbf24;
/* Radii. Bridge cards use the large radius — modern bridge UIs run 16–20px. */
--bridge-radius: 20px;
--bridge-radius-md: 14px;
/* Radii. */
--bridge-radius: 16px;
--bridge-radius-md: 12px;
--bridge-radius-sm: 10px;
--bridge-radius-pill: 999px;
@@ -81,17 +95,21 @@
--bridge-font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
monospace;
/* Shadows — layered, soft, used on cards + popovers. */
--bridge-shadow-card:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 1px 2px rgba(0, 0, 0, 0.4),
0 10px 30px rgba(0, 0, 0, 0.5);
/* Type scale. Stated once so a heading and the sentence under it move
together, and a size is chosen by what it is rather than by a number at
the call site. */
--bridge-title-size: 34px;
--bridge-panel-title-size: 19px;
--bridge-body-size: 14px;
--bridge-note-size: 12px;
--bridge-label-size: 12px;
/* Shadows. Panels sit on the ground rather than float above it — an edge
does the separating, which is why there is one shadow here and it belongs
to the thing that genuinely overlaps: the popover. */
--bridge-shadow-popover:
0 1px 0 rgba(255, 255, 255, 0.06) inset,
0 10px 40px rgba(0, 0, 0, 0.6);
--bridge-shadow-glow:
0 0 0 1px var(--bridge-accent-border),
0 0 24px var(--bridge-accent-soft);
0 12px 40px rgba(0, 0, 0, 0.8);
/* Motion. */
--bridge-transition-fast: 120ms cubic-bezier(0.4, 0, 0.2, 1);
@@ -103,47 +121,49 @@
* here are desktop-sized; the (max-width: 640px) and (max-width: 480px)
* overrides below tighten the values for tablet and phone.
*/
--bridge-page-padding-x: 16px;
--bridge-page-padding-y: 32px;
--bridge-card-padding: 20px;
--bridge-card-gap: 14px;
--bridge-stack-gap: 16px;
--bridge-stack-max-width: 480px;
--bridge-header-padding-x: 20px;
--bridge-page-padding-x: 24px;
--bridge-page-padding-y: 40px;
--bridge-card-padding: 24px;
--bridge-card-gap: 18px;
--bridge-stack-gap: 20px;
--bridge-stack-max-width: 520px;
--bridge-header-padding-x: 24px;
--bridge-header-padding-y: 14px;
--bridge-asset-select-width: 140px;
--bridge-input-font-size: 24px;
--bridge-asset-select-width: 148px;
--bridge-input-font-size: 28px;
--bridge-brand-name-size: 15px;
--bridge-chip-display: inline-flex;
--bridge-mpc-chip-display: inline-flex;
}
/* Tablet — slightly tighter spacing, identical layout structure. */
@media (max-width: 640px) {
:root {
--bridge-page-padding-x: 14px;
--bridge-page-padding-y: 20px;
--bridge-card-padding: 16px;
--bridge-card-gap: 12px;
--bridge-stack-gap: 12px;
--bridge-header-padding-x: 14px;
--bridge-page-padding-x: 16px;
--bridge-page-padding-y: 28px;
--bridge-card-padding: 20px;
--bridge-card-gap: 16px;
--bridge-stack-gap: 16px;
--bridge-header-padding-x: 16px;
--bridge-header-padding-y: 12px;
--bridge-asset-select-width: 120px;
--bridge-input-font-size: 22px;
--bridge-asset-select-width: 128px;
--bridge-input-font-size: 24px;
--bridge-title-size: 28px;
}
}
/* Phone — MPC chip hidden to free header width for the wallet pill, amount
* input shrunk a notch, page padding reduced to its iOS-safe minimum. */
/* Phone — amount input shrunk a notch, page padding reduced to its iOS-safe
* minimum, and the env chip stood down so the header holds a wordmark and a
* wallet pill without either being cut. */
@media (max-width: 480px) {
:root {
--bridge-page-padding-x: 12px;
--bridge-page-padding-y: 16px;
--bridge-card-padding: 14px;
--bridge-asset-select-width: 110px;
--bridge-input-font-size: 20px;
--bridge-page-padding-y: 20px;
--bridge-card-padding: 16px;
--bridge-asset-select-width: 116px;
--bridge-input-font-size: 22px;
--bridge-brand-name-size: 14px;
--bridge-mpc-chip-display: none;
--bridge-title-size: 26px;
--bridge-chip-display: none;
}
}
@@ -157,16 +177,14 @@ body {
font-family: var(--bridge-font);
}
/* Flat ground. The page was painted with an accent wash behind the header,
which put a coloured haze under the one panel a person reads numbers off.
The panel's own edge does the separating now. */
.bridge-root {
font-family: var(--bridge-font);
background:
radial-gradient(
ellipse 80% 60% at 50% 0%,
var(--bridge-accent-soft) 0%,
transparent 60%
),
var(--bridge-bg);
background: var(--bridge-bg);
color: var(--bridge-text);
font-size: var(--bridge-body-size);
min-height: 100vh;
display: flex;
flex-direction: column;
@@ -256,16 +274,13 @@ body {
background: var(--bridge-bg-hover);
}
.bridge-popover-item[data-selected='true'] {
color: var(--bridge-accent);
}
.bridge-popover-item[data-selected='true']::after {
content: '';
width: 6px;
height: 6px;
flex-shrink: 0;
border-radius: 50%;
background: var(--bridge-accent);
background: var(--bridge-text);
margin-left: auto;
}
@@ -306,3 +321,80 @@ body {
height: 32px;
font-size: 13px;
}
/* ─── Focus ──────────────────────────────────────────────────────────
*
* One ring, on every control that takes focus from a keyboard. It is
* `:focus-visible` rather than `:focus` so a mouse click does not leave a
* halo, and it is stated once here rather than per component because a
* keyboard user following a form down the card needs the mark to look the
* same each time it lands.
*/
.bridge-root :focus-visible {
outline: 2px solid var(--bridge-border-focus);
outline-offset: 2px;
border-radius: var(--bridge-radius-sm);
}
/* ─── Select trigger ─────────────────────────────────────────────────
*
* A class rather than an inline style, because the two states that matter
* — hover and keyboard focus — are pseudo-classes, and inline styles have
* none. The open state is data, so it is a data attribute.
*/
.bridge-select-trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 44px;
padding: 10px 12px;
background: var(--bridge-bg-input);
border: 1px solid var(--bridge-border);
border-radius: var(--bridge-radius-md);
color: var(--bridge-text);
font-size: var(--bridge-body-size);
font-weight: 500;
text-align: left;
transition:
background-color var(--bridge-transition-fast),
border-color var(--bridge-transition-fast);
}
.bridge-select-trigger:hover {
border-color: var(--bridge-border-strong);
background: var(--bridge-bg-hover);
}
.bridge-select-trigger[data-open='true'] {
border-color: var(--bridge-border-focus);
background: var(--bridge-bg-hover);
}
/* The caret. It turns because the menu is open, and it is drawn from a
border rather than a glyph so it keeps its weight at any type size. */
.bridge-select-caret {
margin-left: auto;
flex-shrink: 0;
width: 8px;
height: 8px;
border-right: 1.5px solid var(--bridge-text-muted);
border-bottom: 1.5px solid var(--bridge-text-muted);
transform: translateY(-2px) rotate(45deg);
transition: transform var(--bridge-transition-fast);
}
.bridge-select-trigger[data-open='true'] .bridge-select-caret {
transform: translateY(1px) rotate(-135deg);
}
.bridge-select-trigger:hover .bridge-select-caret,
.bridge-select-trigger[data-open='true'] .bridge-select-caret {
border-color: var(--bridge-text);
}
/* The option the keyboard is on. Marked with a visible edge as well as a
fill, so it survives a screen where the fill difference is one step. */
.bridge-popover-item[data-highlighted='true'] {
box-shadow: inset 0 0 0 1px var(--bridge-border-strong);
}