Web3 — Token Swap (from/to · slippage · route)
A glassy DEX swap card for the fictional NovaSwap aggregator on Lumen Chain, built in HTML, CSS, and vanilla JS. From and To panels each carry a monospace amount input, live fiat estimate, balance with a MAX button, and a searchable token-select modal across eight made-up assets. A flip button reverses direction, while a live rate, animated price-impact, minimum-received, and a slippage popover update as you type. The big Swap action opens a confirm step with risk callouts, a mock signing and submitted flow, a fake tx hash, and toast feedback.
MCP
Kod
:root {
--bg: #0a0b0f;
--surface: #13151c;
--surface-2: #1b1e27;
--elevated: #23262f;
--text: #e9ecf2;
--muted: #8a90a2;
--line: rgba(255, 255, 255, 0.08);
--line-2: rgba(255, 255, 255, 0.16);
--accent: #7c5cff;
--accent-2: #00e0c6;
--accent-glow: rgba(124, 92, 255, 0.45);
--pos: #26d07c;
--neg: #ff4d6d;
--warn: #ffb347;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--r-pill: 999px;
--font: "Space Grotesk", system-ui, sans-serif;
--mono: "JetBrains Mono", ui-monospace, monospace;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--font);
line-height: 1.5;
color: var(--text);
background:
radial-gradient(900px 540px at 14% -8%, rgba(124, 92, 255, 0.18), transparent 60%),
radial-gradient(760px 520px at 96% 110%, rgba(0, 224, 198, 0.14), transparent 62%),
var(--bg);
}
.mono {
font-family: var(--mono);
font-feature-settings: "tnum" 1, "zero" 1;
}
.stage {
min-height: 100vh;
display: grid;
place-items: center;
padding: 28px 16px 56px;
}
/* ---------- Card ---------- */
.swap-card {
width: min(440px, 100%);
position: relative;
border-radius: var(--r-lg);
padding: 18px;
background:
linear-gradient(180deg, rgba(27, 30, 39, 0.92), rgba(19, 21, 28, 0.92));
border: 1px solid var(--line);
backdrop-filter: blur(14px);
box-shadow:
0 30px 70px -28px rgba(0, 0, 0, 0.75),
0 0 0 1px rgba(255, 255, 255, 0.02) inset;
}
.swap-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, var(--accent-glow), transparent 36%, transparent 64%, rgba(0, 224, 198, 0.32));
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.swap-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
position: relative;
}
.head-title {
display: flex;
align-items: center;
gap: 10px;
}
.head-title h1 {
font-size: 1.18rem;
font-weight: 700;
margin: 0;
letter-spacing: -0.01em;
}
.net-pill {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.74rem;
font-weight: 600;
color: var(--muted);
padding: 4px 10px;
border-radius: var(--r-pill);
background: var(--surface-2);
border: 1px solid var(--line);
}
.net-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--pos);
box-shadow: 0 0 8px var(--pos);
}
.head-tools {
display: flex;
align-items: center;
gap: 6px;
position: relative;
}
.icon-btn {
display: inline-grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: var(--r-sm);
color: var(--muted);
background: var(--surface-2);
border: 1px solid var(--line);
cursor: pointer;
transition: color 0.15s, background 0.15s, transform 0.15s;
}
.icon-btn:hover {
color: var(--text);
background: var(--elevated);
}
.icon-btn:active {
transform: scale(0.94);
}
#refreshBtn.spin svg {
animation: spin 0.7s ease;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---------- Popover ---------- */
.popover {
position: absolute;
top: 42px;
right: 0;
width: 256px;
z-index: 30;
padding: 14px;
border-radius: var(--r-md);
background: var(--elevated);
border: 1px solid var(--line-2);
box-shadow: 0 24px 50px -18px rgba(0, 0, 0, 0.8);
animation: pop 0.16s ease;
}
@keyframes pop {
from {
opacity: 0;
transform: translateY(-6px) scale(0.97);
}
}
.pop-title {
margin: 0 0 9px;
font-size: 0.74rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.slip-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.slip-opt {
font-family: var(--mono);
font-size: 0.82rem;
font-weight: 500;
color: var(--text);
padding: 7px 0;
border-radius: var(--r-sm);
background: var(--surface-2);
border: 1px solid var(--line);
cursor: pointer;
transition: 0.15s;
}
.slip-opt:hover {
border-color: var(--line-2);
}
.slip-opt.is-active {
background: rgba(124, 92, 255, 0.18);
border-color: var(--accent);
color: #fff;
}
.slip-custom {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 4px;
padding: 0 10px;
border-radius: var(--r-sm);
background: var(--surface-2);
border: 1px solid var(--line);
}
.slip-custom.deadline {
width: 120px;
}
.slip-custom input {
width: 100%;
background: none;
border: none;
outline: none;
color: var(--text);
font-family: var(--mono);
font-size: 0.85rem;
padding: 7px 0;
}
.slip-custom span {
color: var(--muted);
font-size: 0.82rem;
}
.slip-warn {
margin: 9px 0 0;
font-size: 0.74rem;
color: var(--warn);
}
/* ---------- Panels ---------- */
.panels {
position: relative;
display: grid;
gap: 6px;
}
.panel {
padding: 14px 16px;
border-radius: var(--r-md);
background: var(--surface);
border: 1px solid var(--line);
transition: border-color 0.18s, box-shadow 0.18s;
}
.panel:focus-within {
border-color: var(--line-2);
box-shadow: 0 0 0 3px rgba(124, 92, 255, 0.14);
}
.panel-top,
.panel-bottom {
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--muted);
}
.bal {
font-size: 0.76rem;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 6px;
}
.bal b {
color: var(--text);
font-weight: 500;
}
.max-btn {
font-family: var(--font);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--accent-2);
padding: 2px 7px;
border-radius: var(--r-pill);
background: rgba(0, 224, 198, 0.12);
border: 1px solid rgba(0, 224, 198, 0.28);
cursor: pointer;
transition: 0.15s;
}
.max-btn:hover {
background: rgba(0, 224, 198, 0.2);
}
.panel-row {
display: flex;
align-items: center;
gap: 10px;
margin: 8px 0;
}
.amount {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
color: var(--text);
font-size: 1.78rem;
font-weight: 500;
letter-spacing: -0.01em;
padding: 0;
}
.amount::placeholder {
color: #565b6b;
}
.amount:disabled {
-webkit-text-fill-color: var(--text);
opacity: 1;
}
.token-btn {
display: inline-flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
padding: 7px 10px 7px 8px;
border-radius: var(--r-pill);
background: var(--elevated);
border: 1px solid var(--line-2);
color: var(--text);
font-family: var(--font);
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: 0.15s;
}
.token-btn:hover {
background: #2b2f3a;
}
.token-btn svg {
color: var(--muted);
}
.t-ico {
display: inline-grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-family: var(--mono);
font-weight: 700;
font-size: 0.82rem;
color: #0a0b0f;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
}
.t-ico[data-sym="USDC"] { background: linear-gradient(135deg, #2775ca, #4f9bff); color: #fff; }
.t-ico[data-sym="ETH"] { background: linear-gradient(135deg, #6b7bff, #8a92b8); color: #fff; }
.t-ico[data-sym="NOVA"] { background: linear-gradient(135deg, #7c5cff, #b06bff); color: #fff; }
.t-ico[data-sym="LUM"] { background: linear-gradient(135deg, #00e0c6, #00b0ff); color: #042a26; }
.t-ico[data-sym="DAI"] { background: linear-gradient(135deg, #f5ac37, #ffd27a); color: #3a2a00; }
.t-ico[data-sym="WBTC"] { background: linear-gradient(135deg, #f7931a, #ffb84d); color: #3a2200; }
.t-ico[data-sym="ARC"] { background: linear-gradient(135deg, #ff4d6d, #ff7aa0); color: #fff; }
.t-ico[data-sym="GLOW"] { background: linear-gradient(135deg, #ffb347, #ffd27a); color: #3a2a00; }
.panel-bottom {
font-size: 0.78rem;
}
.fiat {
color: var(--muted);
}
.chain-tag {
font-size: 0.7rem;
color: var(--muted);
padding: 2px 8px;
border-radius: var(--r-pill);
background: var(--surface-2);
border: 1px solid var(--line);
}
/* ---------- Flip ---------- */
.flip-btn {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 4;
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 12px;
color: var(--text);
background: var(--elevated);
border: 3px solid var(--surface);
cursor: pointer;
transition: transform 0.25s, background 0.15s, color 0.15s;
}
.flip-btn:hover {
background: rgba(124, 92, 255, 0.22);
color: #fff;
}
.flip-btn.flipped {
transform: translate(-50%, -50%) rotate(180deg);
}
/* ---------- Details ---------- */
.details {
margin-top: 10px;
border-radius: var(--r-md);
background: var(--surface);
border: 1px solid var(--line);
overflow: hidden;
}
.rate-line {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 11px 14px;
background: none;
border: none;
color: var(--text);
font-family: inherit;
font-size: 0.84rem;
cursor: pointer;
}
.rate-text {
font-size: 0.84rem;
}
.rate-caret {
color: var(--muted);
transition: transform 0.2s;
}
.rate-line[aria-expanded="true"] .rate-caret {
transform: rotate(180deg);
}
.detail-body {
padding: 4px 14px 12px;
border-top: 1px solid var(--line);
}
.d-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 0;
font-size: 0.82rem;
color: var(--muted);
}
.d-row b {
color: var(--text);
font-weight: 500;
}
.d-row small {
font-size: 0.72rem;
}
.pos { color: var(--pos) !important; }
.neg { color: var(--neg) !important; }
.warn { color: var(--warn) !important; }
.d-row.route {
align-items: center;
flex-wrap: wrap;
}
.route-path {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.hop {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: var(--mono);
font-size: 0.76rem;
color: var(--text);
}
.hop-ico {
display: inline-grid;
place-items: center;
width: 16px;
height: 16px;
border-radius: 50%;
font-size: 0.62rem;
font-weight: 700;
color: #fff;
background: var(--elevated);
}
.hop-arrow {
color: var(--muted);
}
.pool {
font-size: 0.72rem;
font-weight: 600;
color: var(--accent-2);
padding: 2px 7px;
border-radius: var(--r-pill);
background: rgba(0, 224, 198, 0.1);
border: 1px solid rgba(0, 224, 198, 0.22);
cursor: default;
}
/* ---------- CTA ---------- */
.swap-cta {
width: 100%;
margin-top: 12px;
padding: 15px;
border-radius: var(--r-md);
border: none;
cursor: pointer;
font-family: var(--font);
font-size: 1.02rem;
font-weight: 700;
letter-spacing: 0.01em;
color: #fff;
background: linear-gradient(135deg, var(--accent), #9b7bff);
box-shadow: 0 14px 34px -12px var(--accent-glow), 0 0 0 1px rgba(255, 255, 255, 0.06) inset;
transition: filter 0.15s, transform 0.12s, opacity 0.15s;
}
.swap-cta:hover:not(:disabled) {
filter: brightness(1.08);
}
.swap-cta:active:not(:disabled) {
transform: translateY(1px);
}
.swap-cta:disabled {
cursor: not-allowed;
color: var(--muted);
background: var(--surface-2);
border: 1px solid var(--line);
box-shadow: none;
}
.swap-cta.danger {
background: linear-gradient(135deg, var(--neg), #ff7088);
box-shadow: 0 14px 34px -12px rgba(255, 77, 109, 0.45);
}
.card-foot {
margin: 12px 2px 2px;
text-align: center;
font-size: 0.72rem;
color: var(--muted);
}
.card-foot .addr {
color: var(--text);
}
/* ---------- Modals ---------- */
.modal-scrim {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
padding: 16px;
background: rgba(5, 6, 10, 0.66);
backdrop-filter: blur(4px);
animation: fade 0.16s ease;
}
@keyframes fade {
from {
opacity: 0;
}
}
.modal {
width: min(420px, 100%);
max-height: min(620px, 88vh);
display: flex;
flex-direction: column;
border-radius: var(--r-lg);
background: var(--surface-2);
border: 1px solid var(--line-2);
box-shadow: 0 40px 90px -30px rgba(0, 0, 0, 0.85);
animation: rise 0.2s cubic-bezier(0.2, 0.8, 0.3, 1);
overflow: hidden;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(14px) scale(0.98);
}
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px;
}
.modal-head h2 {
margin: 0;
font-size: 1.04rem;
font-weight: 700;
}
.search-wrap {
display: flex;
align-items: center;
gap: 9px;
margin: 0 16px;
padding: 0 12px;
border-radius: var(--r-md);
background: var(--surface);
border: 1px solid var(--line);
}
.search-wrap svg {
color: var(--muted);
flex-shrink: 0;
}
.search-wrap input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text);
font-family: var(--font);
font-size: 0.92rem;
padding: 12px 0;
}
.search-wrap input::placeholder {
color: #565b6b;
}
.common-tokens {
display: flex;
flex-wrap: wrap;
gap: 7px;
padding: 14px 16px 10px;
}
.common-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px 5px 6px;
border-radius: var(--r-pill);
background: var(--surface);
border: 1px solid var(--line);
color: var(--text);
font-weight: 600;
font-size: 0.82rem;
cursor: pointer;
transition: 0.15s;
}
.common-chip:hover {
border-color: var(--line-2);
}
.common-chip .t-ico {
width: 20px;
height: 20px;
font-size: 0.7rem;
}
.token-list {
list-style: none;
margin: 4px 0 8px;
padding: 0 8px 8px;
overflow-y: auto;
}
.token-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px;
border-radius: var(--r-md);
background: none;
border: none;
cursor: pointer;
text-align: left;
color: var(--text);
font-family: inherit;
transition: background 0.13s;
}
.token-row:hover {
background: var(--surface);
}
.token-row[aria-disabled="true"] {
opacity: 0.4;
cursor: not-allowed;
}
.token-row .t-ico {
width: 34px;
height: 34px;
font-size: 1rem;
flex-shrink: 0;
}
.tr-main {
flex: 1;
min-width: 0;
}
.tr-sym {
font-weight: 600;
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 7px;
}
.tr-addr {
font-family: var(--mono);
font-size: 0.7rem;
color: var(--muted);
}
.tr-name {
font-size: 0.78rem;
color: var(--muted);
}
.tr-right {
text-align: right;
flex-shrink: 0;
}
.tr-bal {
font-family: var(--mono);
font-size: 0.86rem;
}
.tr-fiat {
font-family: var(--mono);
font-size: 0.72rem;
color: var(--muted);
}
.tr-current {
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.05em;
color: var(--accent-2);
padding: 1px 6px;
border-radius: var(--r-pill);
background: rgba(0, 224, 198, 0.12);
}
.empty-state {
padding: 22px 16px 28px;
text-align: center;
color: var(--muted);
font-size: 0.88rem;
}
/* ---------- Confirm ---------- */
.confirm-body {
padding: 4px 18px 20px;
}
.conf-leg {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: var(--r-md);
background: var(--surface);
border: 1px solid var(--line);
}
.conf-side {
font-size: 0.78rem;
color: var(--muted);
font-weight: 600;
}
.conf-amt {
font-size: 1.2rem;
font-weight: 500;
}
.conf-divider {
display: grid;
place-items: center;
margin: -6px 0;
position: relative;
z-index: 2;
color: var(--muted);
}
.conf-meta {
margin-top: 14px;
padding: 4px 14px;
border-radius: var(--r-md);
background: var(--surface);
border: 1px solid var(--line);
}
.risk-note {
display: flex;
align-items: flex-start;
gap: 9px;
margin-top: 14px;
padding: 11px 13px;
border-radius: var(--r-md);
font-size: 0.8rem;
color: var(--text);
background: rgba(255, 179, 71, 0.08);
border: 1px solid rgba(255, 179, 71, 0.26);
}
.risk-note svg {
color: var(--warn);
flex-shrink: 0;
margin-top: 1px;
}
.risk-note.danger {
background: rgba(255, 77, 109, 0.08);
border-color: rgba(255, 77, 109, 0.3);
}
.risk-note.danger svg {
color: var(--neg);
}
.confirm-cta {
margin-top: 16px;
}
/* ---------- Status ---------- */
.confirm-body.status {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 26px 24px 30px;
}
.status-spinner {
width: 58px;
height: 58px;
border-radius: 50%;
border: 4px solid var(--line-2);
border-top-color: var(--accent);
animation: spin 0.9s linear infinite;
margin-bottom: 18px;
}
.status-check {
width: 58px;
height: 58px;
display: grid;
place-items: center;
border-radius: 50%;
color: var(--pos);
background: rgba(38, 208, 124, 0.14);
border: 1px solid rgba(38, 208, 124, 0.4);
margin-bottom: 18px;
animation: pop 0.25s ease;
}
.status-title {
margin: 0 0 4px;
font-size: 1.08rem;
font-weight: 700;
}
.status-sub {
margin: 0;
font-size: 0.86rem;
color: var(--muted);
}
.status-hash {
margin-top: 14px;
font-size: 0.82rem;
color: var(--accent-2);
text-decoration: none;
}
.status-hash:hover {
text-decoration: underline;
}
.ghost-btn {
margin-top: 18px;
padding: 11px 30px;
border-radius: var(--r-md);
background: var(--elevated);
border: 1px solid var(--line-2);
color: var(--text);
font-family: var(--font);
font-weight: 600;
cursor: pointer;
transition: 0.15s;
}
.ghost-btn:hover {
background: #2b2f3a;
}
/* ---------- Toast ---------- */
.toast-stack {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 90;
display: flex;
flex-direction: column;
gap: 8px;
width: min(360px, calc(100% - 32px));
}
.toast {
display: flex;
align-items: center;
gap: 9px;
padding: 12px 14px;
border-radius: var(--r-md);
font-size: 0.86rem;
color: var(--text);
background: var(--elevated);
border: 1px solid var(--line-2);
box-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.7);
animation: toastIn 0.22s cubic-bezier(0.2, 0.8, 0.3, 1);
}
.toast.out {
animation: toastOut 0.25s ease forwards;
}
.toast .dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: var(--accent);
}
.toast.ok .dot { background: var(--pos); }
.toast.warn .dot { background: var(--warn); }
.toast.err .dot { background: var(--neg); }
.toast b {
font-family: var(--mono);
font-weight: 500;
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateY(14px);
}
}
@keyframes toastOut {
to {
opacity: 0;
transform: translateY(10px);
}
}
/* ---------- Focus ---------- */
button:focus-visible,
input:focus-visible,
.token-row:focus-visible,
a:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--r-sm);
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.stage {
padding: 16px 12px 40px;
}
.swap-card {
padding: 14px;
}
.amount {
font-size: 1.5rem;
}
.popover {
width: 220px;
}
.conf-amt {
font-size: 1.08rem;
}
.d-row.route {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}(() => {
"use strict";
/* ---------- Mock token universe (fictional) ---------- */
const TOKENS = {
ETH: { sym: "ETH", name: "Ether", addr: "native", usd: 2806.40, bal: 2.481, ico: "Ξ" },
USDC: { sym: "USDC", name: "USD Coin", addr: "0x7a3f8e21d4b6c0f29ac5d3e1b8f47a902c41d", usd: 1.0, bal: 1204.07, ico: "$" },
DAI: { sym: "DAI", name: "Dai Stablecoin", addr: "0x1b9c44ef02a7d8c61f350be9a4d77c0e8821aab3", usd: 0.999, bal: 318.5, ico: "◈" },
NOVA: { sym: "NOVA", name: "Nova Protocol", addr: "0x5e1d77a3c92f04b8e6a1d0c4f9382bb71ce04f12", usd: 4.182, bal: 940.6, ico: "N" },
LUM: { sym: "LUM", name: "Lumen", addr: "0x9f02a7e4b13d8c50fa6e29c1d7480bb35ae71d09", usd: 0.6471, bal: 5210.0, ico: "L" },
WBTC: { sym: "WBTC", name: "Wrapped Bitcoin", addr: "0x3c8a91f0e25d7b46ac1f93d508e2c7710bba49ef", usd: 64210.0, bal: 0.0418, ico: "₿" },
ARC: { sym: "ARC", name: "Arcane Finance", addr: "0x2d70b81e94c3a5f08de1273c6f9920ab41ce8870", usd: 0.0928, bal: 12500.0, ico: "A" },
GLOW: { sym: "GLOW", name: "Glowstone", addr: "0x8b14c0a72fd9e3650bc1a8f47d92301eea5c7b6f", usd: 12.74, bal: 76.2, ico: "G" },
};
const ORDER = ["ETH", "USDC", "NOVA", "LUM", "WBTC", "DAI", "ARC", "GLOW"];
const COMMON = ["ETH", "USDC", "NOVA", "LUM", "WBTC"];
let fromSym = "ETH";
let toSym = "USDC";
let slippage = 0.5;
let lastEdited = "from"; // which side the user typed in
let pickTarget = null; // "from" | "to" while modal open
const $ = (id) => document.getElementById(id);
/* ---------- Formatting helpers ---------- */
const fmt = (n, max = 6) => {
if (!isFinite(n)) return "0";
if (n === 0) return "0";
const abs = Math.abs(n);
const decimals = abs >= 1000 ? 2 : abs >= 1 ? 4 : max;
return n.toLocaleString("en-US", { maximumFractionDigits: decimals });
};
const fmtUsd = (n) =>
"$" + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const parse = (v) => {
const n = parseFloat(String(v).replace(/,/g, ""));
return isNaN(n) ? 0 : n;
};
const rate = (a, b) => TOKENS[a].usd / TOKENS[b].usd; // 1 a = rate b
/* ---------- Toast ---------- */
function toast(msg, kind = "") {
const stack = $("toastStack");
const el = document.createElement("div");
el.className = "toast" + (kind ? " " + kind : "");
el.setAttribute("role", "status");
el.innerHTML = '<span class="dot"></span><span></span>';
el.lastElementChild.innerHTML = msg;
stack.appendChild(el);
setTimeout(() => {
el.classList.add("out");
el.addEventListener("animationend", () => el.remove(), { once: true });
}, 3200);
}
/* ---------- Token button rendering ---------- */
function paintTokenBtn(side) {
const t = TOKENS[side === "from" ? fromSym : toSym];
const btn = $(side + "Token");
btn.querySelector(".t-ico").textContent = t.ico;
btn.querySelector(".t-ico").dataset.sym = t.sym;
btn.querySelector(".t-sym").textContent = t.sym;
}
/* ---------- Core recompute ---------- */
function recompute() {
const fromT = TOKENS[fromSym];
const toT = TOKENS[toSym];
const fromIn = $("fromAmount");
const toIn = $("toAmount");
// balances
$("fromBal").textContent = fmt(fromT.bal);
$("toBal").textContent = fmt(toT.bal);
const r = rate(fromSym, toSym);
let fromAmt = parse(fromIn.value);
let toAmt = parse(toIn.value);
if (lastEdited === "from") {
toAmt = fromAmt * r;
if (fromIn.value.trim() === "") toIn.value = "";
else toIn.value = toAmt > 0 ? toAmt.toLocaleString("en-US", { maximumFractionDigits: toT.usd >= 100 ? 6 : 4, useGrouping: false }) : "";
} else {
fromAmt = toAmt / r;
if (toIn.value.trim() === "") fromIn.value = "";
else fromIn.value = fromAmt > 0 ? fromAmt.toLocaleString("en-US", { maximumFractionDigits: 6, useGrouping: false }) : "";
}
// fiat estimates
$("fromFiat").textContent = fmtUsd(fromAmt * fromT.usd);
$("toFiat").textContent = fmtUsd(toAmt * toT.usd);
// rate line
$("rateText").textContent = `1 ${fromSym} = ${fmt(r)} ${toSym}`;
// price impact: mock — scale gently with trade size in USD
const usdSize = fromAmt * fromT.usd;
let impactPct = Math.min(usdSize / 250000 * 1.4, 9.5); // up to ~9.5%
const impactEl = $("impact");
if (fromAmt <= 0) {
impactEl.textContent = "<0.01%";
impactEl.className = "mono pos";
} else {
impactEl.textContent = (impactPct < 0.01 ? "<0.01" : "-" + impactPct.toFixed(2)) + "%";
impactEl.className = "mono " + (impactPct >= 3 ? "neg" : impactPct >= 1 ? "warn" : "pos");
}
// min received with slippage + impact
const effective = toAmt * (1 - slippage / 100) * (1 - impactPct / 100);
$("minRecv").textContent = fmt(effective) + " " + toSym;
$("slipNote").textContent = `(${slippage}% slippage)`;
// network fee (mock, denominated in ETH-like gas)
const gasEth = 0.0012;
$("netFee").textContent = `~${gasEth} ${fromSym === "ETH" ? "ETH" : "LUM"} ($${(gasEth * TOKENS.ETH.usd).toFixed(2)})`;
// route hops
const path = $("routePath");
path.querySelectorAll(".hop")[0].innerHTML = `<span class="hop-ico">${fromT.ico}</span>${fromSym}`;
path.querySelectorAll(".hop")[1].innerHTML = `<span class="hop-ico">${toT.ico}</span>${toSym}`;
// details visibility
$("details").hidden = !(fromAmt > 0 || toAmt > 0);
updateCta(fromAmt, toAmt, impactPct);
return { fromAmt, toAmt, r, impactPct, effective };
}
function updateCta(fromAmt, toAmt, impactPct) {
const cta = $("swapCta");
cta.classList.remove("danger");
if (fromSym === toSym) {
cta.disabled = true;
cta.textContent = "Select different tokens";
} else if (fromAmt <= 0) {
cta.disabled = true;
cta.textContent = "Enter an amount";
} else if (fromAmt > TOKENS[fromSym].bal) {
cta.disabled = true;
cta.textContent = `Insufficient ${fromSym} balance`;
} else if (impactPct >= 5) {
cta.disabled = false;
cta.classList.add("danger");
cta.textContent = `Swap anyway (−${impactPct.toFixed(1)}% impact)`;
} else {
cta.disabled = false;
cta.textContent = "Swap";
}
}
/* ---------- Amount input handlers ---------- */
function sanitize(el) {
el.value = el.value.replace(/[^0-9.]/g, "").replace(/(\..*)\./g, "$1");
}
$("fromAmount").addEventListener("input", (e) => {
sanitize(e.target);
lastEdited = "from";
recompute();
});
$("toAmount").addEventListener("input", (e) => {
sanitize(e.target);
lastEdited = "to";
recompute();
});
/* ---------- MAX ---------- */
$("maxBtn").addEventListener("click", () => {
const bal = TOKENS[fromSym].bal;
// leave a little for gas if native
const usable = fromSym === "ETH" ? Math.max(bal - 0.001, 0) : bal;
$("fromAmount").value = usable.toLocaleString("en-US", { maximumFractionDigits: 6, useGrouping: false });
lastEdited = "from";
recompute();
toast(`Max <b>${fmt(usable)} ${fromSym}</b> set`);
});
/* ---------- Flip ---------- */
$("flipBtn").addEventListener("click", () => {
[fromSym, toSym] = [toSym, fromSym];
// move the typed amount to keep "you pay" intuitive: swap the values too
const fa = $("fromAmount").value;
$("fromAmount").value = $("toAmount").value;
$("toAmount").value = fa;
lastEdited = "from";
$("flipBtn").classList.toggle("flipped");
paintTokenBtn("from");
paintTokenBtn("to");
recompute();
toast(`Now swapping <b>${fromSym} → ${toSym}</b>`);
});
$("refreshBtn").addEventListener("click", (e) => {
const b = e.currentTarget;
b.classList.remove("spin");
void b.offsetWidth;
b.classList.add("spin");
recompute();
toast("Rates refreshed", "ok");
});
/* ---------- Rate line expand ---------- */
$("rateLine").addEventListener("click", () => {
const line = $("rateLine");
const body = $("detailBody");
const open = line.getAttribute("aria-expanded") === "true";
line.setAttribute("aria-expanded", String(!open));
body.hidden = open;
});
/* ---------- Slippage popover ---------- */
const slipPop = $("slipPop");
function openSlip() {
slipPop.hidden = false;
$("slipBtn").setAttribute("aria-expanded", "true");
document.addEventListener("click", outsideSlip, true);
}
function closeSlip() {
slipPop.hidden = true;
$("slipBtn").setAttribute("aria-expanded", "false");
document.removeEventListener("click", outsideSlip, true);
}
function outsideSlip(e) {
if (!slipPop.contains(e.target) && e.target !== $("slipBtn") && !$("slipBtn").contains(e.target)) closeSlip();
}
$("slipBtn").addEventListener("click", () => (slipPop.hidden ? openSlip() : closeSlip()));
function applySlip(val, fromCustom) {
slippage = val;
document.querySelectorAll(".slip-opt").forEach((b) => {
b.classList.toggle("is-active", !fromCustom && parseFloat(b.dataset.slip) === val);
});
if (!fromCustom) $("slipCustom").value = "";
$("slipWarn").hidden = val < 5;
recompute();
}
$("slipGrid").addEventListener("click", (e) => {
const opt = e.target.closest(".slip-opt");
if (!opt) return;
applySlip(parseFloat(opt.dataset.slip), false);
});
$("slipCustom").addEventListener("input", (e) => {
const v = parse(e.target.value);
if (v > 0) {
document.querySelectorAll(".slip-opt").forEach((b) => b.classList.remove("is-active"));
applySlip(Math.min(v, 50), true);
}
});
/* ---------- Token select modal ---------- */
const tokenScrim = $("tokenScrim");
function renderCommon() {
const wrap = $("commonTokens");
wrap.innerHTML = "";
COMMON.forEach((sym) => {
const t = TOKENS[sym];
const chip = document.createElement("button");
chip.className = "common-chip";
chip.type = "button";
chip.innerHTML = `<span class="t-ico" data-sym="${sym}">${t.ico}</span>${sym}`;
chip.addEventListener("click", () => choose(sym));
wrap.appendChild(chip);
});
}
function renderList(q = "") {
const list = $("tokenList");
const query = q.trim().toLowerCase();
list.innerHTML = "";
let shown = 0;
ORDER.forEach((sym) => {
const t = TOKENS[sym];
const hay = (t.sym + " " + t.name + " " + t.addr).toLowerCase();
if (query && !hay.includes(query)) return;
shown++;
const other = pickTarget === "from" ? toSym : fromSym;
const isCurrent = (pickTarget === "from" ? fromSym : toSym) === sym;
const disabled = sym === other;
const li = document.createElement("li");
const row = document.createElement("button");
row.className = "token-row";
row.type = "button";
row.setAttribute("role", "option");
if (disabled) row.setAttribute("aria-disabled", "true");
const shortAddr = t.addr === "native" ? "Native asset" : t.addr.slice(0, 6) + "…" + t.addr.slice(-4);
row.innerHTML = `
<span class="t-ico" data-sym="${sym}">${t.ico}</span>
<span class="tr-main">
<span class="tr-sym">${sym}${isCurrent ? '<span class="tr-current">SELECTED</span>' : ""}</span>
<span class="tr-name">${t.name} · <span class="tr-addr">${shortAddr}</span></span>
</span>
<span class="tr-right">
<div class="tr-bal">${fmt(t.bal)}</div>
<div class="tr-fiat">${fmtUsd(t.bal * t.usd)}</div>
</span>`;
if (!disabled) row.addEventListener("click", () => choose(sym));
else row.title = "Already selected on the other side";
li.appendChild(row);
list.appendChild(li);
});
$("tokenEmpty").hidden = shown !== 0;
}
function openTokenModal(target) {
pickTarget = target;
renderCommon();
renderList("");
$("tokenSearch").value = "";
tokenScrim.hidden = false;
document.body.style.overflow = "hidden";
setTimeout(() => $("tokenSearch").focus(), 30);
}
function closeTokenModal() {
tokenScrim.hidden = true;
document.body.style.overflow = "";
pickTarget = null;
}
function choose(sym) {
if (pickTarget === "from") {
if (sym === toSym) toSym = fromSym; // auto-swap to avoid collision
fromSym = sym;
} else {
if (sym === fromSym) fromSym = toSym;
toSym = sym;
}
paintTokenBtn("from");
paintTokenBtn("to");
closeTokenModal();
recompute();
toast(`Selected <b>${sym}</b>`);
}
$("fromToken").addEventListener("click", () => openTokenModal("from"));
$("toToken").addEventListener("click", () => openTokenModal("to"));
$("tokenClose").addEventListener("click", closeTokenModal);
$("tokenSearch").addEventListener("input", (e) => renderList(e.target.value));
tokenScrim.addEventListener("click", (e) => {
if (e.target === tokenScrim) closeTokenModal();
});
/* ---------- Confirm flow ---------- */
const confirmScrim = $("confirmScrim");
let pending = null;
$("swapCta").addEventListener("click", () => {
const data = recompute();
if ($("swapCta").disabled) return;
pending = data;
const fromT = TOKENS[fromSym];
const toT = TOKENS[toSym];
$("cFrom").textContent = `${fmt(data.fromAmt)} ${fromSym}`;
$("cTo").textContent = `${fmt(data.toAmt)} ${toSym}`;
$("cRate").textContent = `1 ${fromSym} = ${fmt(data.r)} ${toSym}`;
$("cMin").textContent = `${fmt(data.effective)} ${toSym}`;
$("cImpact").textContent = data.impactPct < 0.01 ? "<0.01%" : "-" + data.impactPct.toFixed(2) + "%";
$("cImpact").className = "mono " + (data.impactPct >= 3 ? "neg" : data.impactPct >= 1 ? "warn" : "pos");
$("cFee").textContent = $("netFee").textContent;
$("riskMin").textContent = `${fmt(data.effective)} ${toSym}`;
const risk = $("riskNote");
const cCta = $("confirmCta");
if (data.impactPct >= 5) {
risk.classList.add("danger");
$("riskText").innerHTML = `High price impact of <b>-${data.impactPct.toFixed(2)}%</b>. You may lose a significant amount. Minimum received <b class="mono">${fmt(data.effective)} ${toSym}</b>.`;
cCta.classList.add("danger");
cCta.textContent = "Confirm high-impact swap";
} else {
risk.classList.remove("danger");
$("riskText").innerHTML = `Output is estimated. You receive at least <b class="mono">${fmt(data.effective)} ${toSym}</b> or the transaction reverts.`;
cCta.classList.remove("danger");
cCta.textContent = "Confirm in wallet";
}
// reset status view
$("confirmReview").hidden = false;
$("confirmStatus").hidden = true;
confirmScrim.hidden = false;
document.body.style.overflow = "hidden";
});
function closeConfirm() {
confirmScrim.hidden = true;
document.body.style.overflow = "";
}
$("confirmClose").addEventListener("click", closeConfirm);
confirmScrim.addEventListener("click", (e) => {
if (e.target === confirmScrim) closeConfirm();
});
$("confirmCta").addEventListener("click", () => {
$("confirmReview").hidden = true;
$("confirmStatus").hidden = false;
$("statusSpinner").hidden = false;
$("statusCheck").hidden = true;
$("statusHash").hidden = true;
$("statusDone").hidden = true;
$("statusTitle").textContent = "Confirm in your wallet";
$("statusSub").textContent = "Waiting for signature…";
// mock signature delay
setTimeout(() => {
$("statusTitle").textContent = "Swapping…";
$("statusSub").textContent = "Transaction submitted to Lumen Chain";
const hash = "0x" + Math.random().toString(16).slice(2, 6) + "…" + Math.random().toString(16).slice(2, 6);
$("statusHash").textContent = hash + " ↗";
$("statusHash").hidden = false;
toast(`Tx submitted <b>${hash}</b>`, "warn");
}, 1400);
// mock confirmation
setTimeout(() => {
$("statusSpinner").hidden = true;
$("statusCheck").hidden = false;
$("statusTitle").textContent = "Swap complete";
$("statusSub").textContent = `Received ${fmt(pending.toAmt)} ${toSym}`;
$("statusDone").hidden = false;
// update mock balances
TOKENS[fromSym].bal = Math.max(TOKENS[fromSym].bal - pending.fromAmt, 0);
TOKENS[toSym].bal += pending.toAmt;
toast(`Swapped <b>${fmt(pending.fromAmt)} ${fromSym}</b> → <b>${fmt(pending.toAmt)} ${toSym}</b>`, "ok");
}, 3300);
});
$("statusDone").addEventListener("click", () => {
closeConfirm();
$("fromAmount").value = "";
$("toAmount").value = "";
lastEdited = "from";
recompute();
});
$("statusHash").addEventListener("click", (e) => {
e.preventDefault();
toast("Explorer is mocked in this demo", "warn");
});
/* ---------- Global keyboard ---------- */
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if (!tokenScrim.hidden) closeTokenModal();
else if (!confirmScrim.hidden && $("confirmStatus").hidden) closeConfirm();
else if (!slipPop.hidden) closeSlip();
});
/* ---------- Init ---------- */
paintTokenBtn("from");
paintTokenBtn("to");
recompute();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Web3 — Token Swap</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="stage">
<section class="swap-card" aria-label="Token swap">
<header class="swap-head">
<div class="head-title">
<h1>Swap</h1>
<span class="net-pill" aria-label="Connected network">
<span class="net-dot" aria-hidden="true"></span> Lumen Chain
</span>
</div>
<div class="head-tools">
<button class="icon-btn" id="refreshBtn" type="button" aria-label="Refresh rate" title="Refresh rate">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" d="M20 11a8 8 0 1 0-2.34 5.66M20 11V5m0 6h-6"/></svg>
</button>
<button class="icon-btn" id="slipBtn" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="slipPop" aria-label="Slippage settings" title="Slippage settings">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"/><path fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" d="m19.4 13-.3.7a1.5 1.5 0 0 0 .3 1.7l.1.1a1.8 1.8 0 1 1-2.5 2.5l-.1-.1a1.5 1.5 0 0 0-1.7-.3 1.5 1.5 0 0 0-.9 1.4V19a1.8 1.8 0 1 1-3.6 0v-.1a1.5 1.5 0 0 0-1-1.4 1.5 1.5 0 0 0-1.7.3l-.1.1a1.8 1.8 0 1 1-2.5-2.5l.1-.1a1.5 1.5 0 0 0 .3-1.7 1.5 1.5 0 0 0-1.4-.9H4a1.8 1.8 0 1 1 0-3.6h.1a1.5 1.5 0 0 0 1.4-1 1.5 1.5 0 0 0-.3-1.7l-.1-.1A1.8 1.8 0 1 1 7.6 4.9l.1.1a1.5 1.5 0 0 0 1.7.3H9.5a1.5 1.5 0 0 0 .9-1.4V4a1.8 1.8 0 1 1 3.6 0v.1a1.5 1.5 0 0 0 .9 1.4 1.5 1.5 0 0 0 1.7-.3l.1-.1a1.8 1.8 0 1 1 2.5 2.5l-.1.1a1.5 1.5 0 0 0-.3 1.7v.1a1.5 1.5 0 0 0 1.4.9h.2a1.8 1.8 0 1 1 0 3.6h-.1a1.5 1.5 0 0 0-1.4.9Z"/></svg>
</button>
<div class="popover" id="slipPop" role="dialog" aria-label="Slippage tolerance" hidden>
<p class="pop-title">Slippage tolerance</p>
<div class="slip-grid" id="slipGrid" role="group" aria-label="Preset slippage">
<button class="slip-opt" type="button" data-slip="0.1">0.1%</button>
<button class="slip-opt is-active" type="button" data-slip="0.5">0.5%</button>
<button class="slip-opt" type="button" data-slip="1">1%</button>
<label class="slip-custom">
<input id="slipCustom" type="number" inputmode="decimal" min="0" max="50" step="0.1" placeholder="Custom" aria-label="Custom slippage percent" />
<span>%</span>
</label>
</div>
<p class="slip-warn" id="slipWarn" hidden>High slippage — your trade may be front-run.</p>
<p class="pop-title" style="margin-top:14px">Transaction deadline</p>
<label class="slip-custom deadline">
<input id="deadline" type="number" inputmode="numeric" min="1" max="60" step="1" value="20" aria-label="Transaction deadline minutes" />
<span>min</span>
</label>
</div>
</div>
</header>
<div class="panels">
<!-- FROM -->
<div class="panel" id="panelFrom">
<div class="panel-top">
<span class="panel-label">You pay</span>
<span class="bal">
Balance: <b id="fromBal" class="mono">2.481</b>
<button class="max-btn" id="maxBtn" type="button">MAX</button>
</span>
</div>
<div class="panel-row">
<input class="amount mono" id="fromAmount" inputmode="decimal" type="text" placeholder="0.0" autocomplete="off" aria-label="Amount to swap from" />
<button class="token-btn" id="fromToken" type="button" aria-haspopup="dialog">
<span class="t-ico" data-sym="ETH" aria-hidden="true">Ξ</span>
<span class="t-sym">ETH</span>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6"/></svg>
</button>
</div>
<div class="panel-bottom">
<span class="fiat mono" id="fromFiat">$0.00</span>
<span class="chain-tag">Lumen Chain</span>
</div>
</div>
<button class="flip-btn" id="flipBtn" type="button" aria-label="Reverse swap direction" title="Reverse direction">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M7 4v13m0 0-3-3m3 3 3-3M17 20V7m0 0 3 3m-3-3-3 3"/></svg>
</button>
<!-- TO -->
<div class="panel" id="panelTo">
<div class="panel-top">
<span class="panel-label">You receive</span>
<span class="bal">Balance: <b id="toBal" class="mono">1,204.07</b></span>
</div>
<div class="panel-row">
<input class="amount mono" id="toAmount" inputmode="decimal" type="text" placeholder="0.0" autocomplete="off" aria-label="Amount to receive" />
<button class="token-btn" id="toToken" type="button" aria-haspopup="dialog">
<span class="t-ico" data-sym="USDC" aria-hidden="true">$</span>
<span class="t-sym">USDC</span>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6"/></svg>
</button>
</div>
<div class="panel-bottom">
<span class="fiat mono" id="toFiat">$0.00</span>
<span class="chain-tag">Lumen Chain</span>
</div>
</div>
</div>
<!-- Rate + details -->
<div class="details" id="details" hidden>
<button class="rate-line" id="rateLine" type="button" aria-expanded="false" aria-controls="detailBody">
<span class="rate-text mono" id="rateText">1 ETH = 2,806.40 USDC</span>
<svg class="rate-caret" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6"/></svg>
</button>
<div class="detail-body" id="detailBody" hidden>
<div class="d-row"><span>Price impact</span><b id="impact" class="mono pos"><0.01%</b></div>
<div class="d-row"><span>Min. received <small id="slipNote">(0.5% slippage)</small></span><b id="minRecv" class="mono">0.00 USDC</b></div>
<div class="d-row"><span>Network fee</span><b class="mono" id="netFee">~0.0012 ETH ($3.37)</b></div>
<div class="d-row route"><span>Route</span>
<div class="route-path" id="routePath" aria-label="Swap route">
<span class="hop"><span class="hop-ico">Ξ</span>ETH</span>
<span class="hop-arrow" aria-hidden="true">→</span>
<span class="pool" title="NovaSwap V3 · 0.05% pool">NovaSwap V3</span>
<span class="hop-arrow" aria-hidden="true">→</span>
<span class="hop"><span class="hop-ico">$</span>USDC</span>
</div>
</div>
</div>
</div>
<button class="swap-cta" id="swapCta" type="button" disabled>Enter an amount</button>
<p class="card-foot">
<span class="mono addr">0x7a3f…c41d</span> · Powered by <b>NovaSwap</b> aggregator
</p>
</section>
</main>
<!-- Token select modal -->
<div class="modal-scrim" id="tokenScrim" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="tokenModalTitle" id="tokenModal">
<div class="modal-head">
<h2 id="tokenModalTitle">Select a token</h2>
<button class="icon-btn" id="tokenClose" type="button" aria-label="Close">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/></svg>
</button>
</div>
<div class="search-wrap">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="m20 20-3.2-3.2" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
<input id="tokenSearch" type="search" placeholder="Search name or paste address" aria-label="Search tokens" autocomplete="off" />
</div>
<div class="common-tokens" id="commonTokens" aria-label="Common tokens"></div>
<ul class="token-list" id="tokenList" role="listbox" aria-label="Token list"></ul>
<p class="empty-state" id="tokenEmpty" hidden>No tokens match that search.</p>
</div>
</div>
<!-- Confirm modal -->
<div class="modal-scrim" id="confirmScrim" hidden>
<div class="modal confirm" role="dialog" aria-modal="true" aria-labelledby="confirmTitle" id="confirmModal">
<div class="modal-head">
<h2 id="confirmTitle">Confirm swap</h2>
<button class="icon-btn" id="confirmClose" type="button" aria-label="Close">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/></svg>
</button>
</div>
<div class="confirm-body" id="confirmReview">
<div class="conf-leg">
<span class="conf-side">From</span>
<span class="conf-amt mono" id="cFrom">0.0 ETH</span>
</div>
<div class="conf-divider" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M12 5v14m0 0-4-4m4 4 4-4"/></svg>
</div>
<div class="conf-leg">
<span class="conf-side">To</span>
<span class="conf-amt mono pos" id="cTo">0.0 USDC</span>
</div>
<div class="conf-meta">
<div class="d-row"><span>Rate</span><b class="mono" id="cRate">—</b></div>
<div class="d-row"><span>Min. received</span><b class="mono" id="cMin">—</b></div>
<div class="d-row"><span>Price impact</span><b class="mono" id="cImpact">—</b></div>
<div class="d-row"><span>Network fee</span><b class="mono" id="cFee">—</b></div>
</div>
<div class="risk-note" id="riskNote">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" d="M12 9v4m0 4h.01M10.3 3.9 2.4 17.6A2 2 0 0 0 4.1 20.6h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"/></svg>
<span id="riskText">Output is estimated. You receive at least <b class="mono" id="riskMin">—</b> or the transaction reverts.</span>
</div>
<button class="swap-cta confirm-cta" id="confirmCta" type="button">Confirm in wallet</button>
</div>
<div class="confirm-body status" id="confirmStatus" hidden>
<div class="status-spinner" id="statusSpinner" aria-hidden="true"></div>
<div class="status-check" id="statusCheck" hidden aria-hidden="true">
<svg viewBox="0 0 24 24" width="34" height="34"><path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" d="m5 13 4 4 10-11"/></svg>
</div>
<p class="status-title" id="statusTitle">Confirm in your wallet</p>
<p class="status-sub" id="statusSub">Waiting for signature…</p>
<a class="status-hash mono" id="statusHash" href="#" hidden>0x9c2e…ae71 ↗</a>
<button class="ghost-btn" id="statusDone" type="button" hidden>Done</button>
</div>
</div>
</div>
<div class="toast-stack" id="toastStack" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Token Swap (from/to · slippage · route)
A self-contained decentralized-exchange swap card for the fictional NovaSwap aggregator on Lumen Chain. Two glassy panels — You pay and You receive — each pair a monospace amount input with a token selector, a live USD estimate, and a balance line; the From side adds a MAX button that leaves a little native gas behind. A floating flip button sits between the panels and reverses the trade direction with a rotating micro-interaction, carrying the entered values across.
Typing into either side instantly computes the other from a mock rate (1 ETH = 2,806.40 USDC), and an expandable details block surfaces the live rate, a size-scaled price impact that shifts from green to amber to red, the minimum received after slippage, the network fee, and a NovaSwap V3 route preview. A slippage popover offers 0.1 / 0.5 / 1% presets plus a custom field and deadline, warning when tolerance gets risky. The token picker is a searchable modal over eight fictional assets (NOVA, LUM, WBTC, ARC, GLOW and more) with common-token chips, balances, and truncated 0x… addresses.
The primary Swap button validates balance and impact — turning into an “Insufficient balance” or high-impact danger state when needed — then opens a confirm sheet that restates the legs, fee, and a minimum-received risk note. Confirming runs a simulated waiting for signature → submitted → complete sequence with a spinner, a mock transaction hash, updated balances, and toast notifications at each step. Everything is keyboard-usable, focus-ringed, responsive down to ~360px, and respects prefers-reduced-motion.
UI-only simulation — no real wallet, RPC, or on-chain calls. Mock data, fictional tokens.