From 39bad4a3e5bc79c0e0c596f00ba15fe9d5b76b96 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:38:49 +0000 Subject: [PATCH] Igneum Wallet UI 3: the wallet on the miner's system (audit, design, build, site) The audit (docs/plans/wallet-ui-3-audit.md, 24 before-captures), the design (docs/plans/wallet-ui-3.md) and the build: five sections in the miner's rail (Home, Send, Receive, History, Settings), the balance first with the money line ("no market price on devnet: coins have no value" until price_gbp_per_ign exists), one node line with Details, Send confirmed in place with the fee behind Details and the pending row after, Receive with the address box and the QR, History with the node's ONE state word per row (pending, included, executed, proven, finalised; the wallet's own verified final wins; failed from the receipt) and a reason line, Settings as plain rows (Security, Backup, This machine with Appearance, the Igneum Wallet update card, Node with the endpoint, Advanced with the remove ask), no dialogs, dark and light, a bottom tab bar under 720 px. The Rust engine is untouched: the node words come from the existing GET /api/tx/. One Swift line removed so the window follows the Mac's appearance. view.test.mjs (11) with lock-screen (5) and update-card (4): 20 pass. tools/ui-mock/wallet.mjs and shoot-wallet.mjs (one niced headless chromium, closed after the batch). Result captures n00 to n24 beside the audit's, and the real Mac window m00 to m06 from this worktree's engine on a scratch data dir with a throwaway wallet (deleted after). Site: wallet.html and the wallet section of index.html on the shipped design; wallet-home.webp and wallet-final.webp from the mock with example addresses. Co-Authored-By: Claude Fable 5.1 --- app/igneum-wallet/ui/app.css | 773 ++++----- app/igneum-wallet/ui/app.js | 1511 ++++++++++------- app/igneum-wallet/ui/index.html | 423 +++-- app/igneum-wallet/ui/view.test.mjs | 161 ++ app/mac/IgneumWallet.swift | 1 - docs/plans/wallet-ui-3-audit.md | 209 +++ docs/plans/wallet-ui-3.md | 246 +++ docs/plans/wallet-ui-3/00-welcome.png | Bin 0 -> 237405 bytes docs/plans/wallet-ui-3/01-create-password.png | Bin 0 -> 82490 bytes docs/plans/wallet-ui-3/02-create-words.png | Bin 0 -> 140646 bytes docs/plans/wallet-ui-3/03-import.png | Bin 0 -> 92131 bytes docs/plans/wallet-ui-3/04-unlock-touch.png | Bin 0 -> 479153 bytes docs/plans/wallet-ui-3/06-home.png | Bin 0 -> 216729 bytes docs/plans/wallet-ui-3/07-home-lower.png | Bin 0 -> 251969 bytes docs/plans/wallet-ui-3/08-send.png | Bin 0 -> 58558 bytes docs/plans/wallet-ui-3/09-send-review.png | Bin 0 -> 125790 bytes docs/plans/wallet-ui-3/10-send-sent.png | Bin 0 -> 122595 bytes docs/plans/wallet-ui-3/11-receive.png | Bin 0 -> 87567 bytes docs/plans/wallet-ui-3/12-tx-final.png | Bin 0 -> 127283 bytes docs/plans/wallet-ui-3/13-settings.png | Bin 0 -> 141837 bytes docs/plans/wallet-ui-3/14-settings-lower.png | Bin 0 -> 170178 bytes docs/plans/wallet-ui-3/15-settings-lowest.png | Bin 0 -> 160109 bytes docs/plans/wallet-ui-3/16-home-public.png | Bin 0 -> 198984 bytes docs/plans/wallet-ui-3/17-home-scanning.png | Bin 0 -> 187987 bytes docs/plans/wallet-ui-3/18-home-nonode.png | Bin 0 -> 177597 bytes docs/plans/wallet-ui-3/19-home-empty.png | Bin 0 -> 180592 bytes docs/plans/wallet-ui-3/20-update-card.png | Bin 0 -> 347759 bytes docs/plans/wallet-ui-3/21-narrow-900-home.png | Bin 0 -> 156711 bytes docs/plans/wallet-ui-3/22-phone-home.png | Bin 0 -> 132262 bytes docs/plans/wallet-ui-3/23-phone-settings.png | Bin 0 -> 119431 bytes docs/plans/wallet-ui-3/24-light-home.png | Bin 0 -> 216490 bytes docs/plans/wallet-ui-3/m00-welcome.png | Bin 0 -> 252985 bytes docs/plans/wallet-ui-3/m01-home.png | Bin 0 -> 190580 bytes docs/plans/wallet-ui-3/m02-history.png | Bin 0 -> 134977 bytes docs/plans/wallet-ui-3/m03-receive.png | Bin 0 -> 162942 bytes docs/plans/wallet-ui-3/m04-settings.png | Bin 0 -> 241348 bytes docs/plans/wallet-ui-3/m05-light.png | Bin 0 -> 186363 bytes docs/plans/wallet-ui-3/m06-lock.png | Bin 0 -> 781442 bytes docs/plans/wallet-ui-3/n00-welcome.png | Bin 0 -> 183323 bytes .../plans/wallet-ui-3/n01-create-password.png | Bin 0 -> 78613 bytes docs/plans/wallet-ui-3/n02-create-words.png | Bin 0 -> 137370 bytes docs/plans/wallet-ui-3/n03-import.png | Bin 0 -> 86851 bytes docs/plans/wallet-ui-3/n04-unlock-touch.png | Bin 0 -> 373796 bytes .../plans/wallet-ui-3/n05-unlock-password.png | Bin 0 -> 375720 bytes docs/plans/wallet-ui-3/n06-home.png | Bin 0 -> 202786 bytes docs/plans/wallet-ui-3/n07-home-lower.png | Bin 0 -> 205822 bytes docs/plans/wallet-ui-3/n08-send.png | Bin 0 -> 85697 bytes docs/plans/wallet-ui-3/n09-send-review.png | Bin 0 -> 135826 bytes docs/plans/wallet-ui-3/n10-send-sent.png | Bin 0 -> 101096 bytes docs/plans/wallet-ui-3/n11-receive.png | Bin 0 -> 104845 bytes docs/plans/wallet-ui-3/n12-tx-final.png | Bin 0 -> 216233 bytes docs/plans/wallet-ui-3/n13-settings.png | Bin 0 -> 209266 bytes docs/plans/wallet-ui-3/n14-settings-lower.png | Bin 0 -> 197625 bytes .../plans/wallet-ui-3/n15-settings-lowest.png | Bin 0 -> 230382 bytes docs/plans/wallet-ui-3/n16-home-public.png | Bin 0 -> 206029 bytes docs/plans/wallet-ui-3/n17-home-scanning.png | Bin 0 -> 208259 bytes docs/plans/wallet-ui-3/n18-home-nonode.png | Bin 0 -> 193814 bytes docs/plans/wallet-ui-3/n19-home-empty.png | Bin 0 -> 136209 bytes docs/plans/wallet-ui-3/n20-update-card.png | Bin 0 -> 323841 bytes .../plans/wallet-ui-3/n21-narrow-900-home.png | Bin 0 -> 113648 bytes docs/plans/wallet-ui-3/n22-phone-home.png | Bin 0 -> 119118 bytes docs/plans/wallet-ui-3/n23-phone-settings.png | Bin 0 -> 136607 bytes docs/plans/wallet-ui-3/n24-light-home.png | Bin 0 -> 205812 bytes site/img/wallet-final.webp | Bin 54052 -> 90212 bytes site/img/wallet-home.webp | Bin 83350 -> 75720 bytes site/index.html | 16 +- site/wallet.html | 39 +- tools/ui-mock/shoot-wallet.mjs | 89 + tools/ui-mock/wallet.mjs | 177 ++ 69 files changed, 2455 insertions(+), 1190 deletions(-) create mode 100644 app/igneum-wallet/ui/view.test.mjs create mode 100644 docs/plans/wallet-ui-3-audit.md create mode 100644 docs/plans/wallet-ui-3.md create mode 100644 docs/plans/wallet-ui-3/00-welcome.png create mode 100644 docs/plans/wallet-ui-3/01-create-password.png create mode 100644 docs/plans/wallet-ui-3/02-create-words.png create mode 100644 docs/plans/wallet-ui-3/03-import.png create mode 100644 docs/plans/wallet-ui-3/04-unlock-touch.png create mode 100644 docs/plans/wallet-ui-3/06-home.png create mode 100644 docs/plans/wallet-ui-3/07-home-lower.png create mode 100644 docs/plans/wallet-ui-3/08-send.png create mode 100644 docs/plans/wallet-ui-3/09-send-review.png create mode 100644 docs/plans/wallet-ui-3/10-send-sent.png create mode 100644 docs/plans/wallet-ui-3/11-receive.png create mode 100644 docs/plans/wallet-ui-3/12-tx-final.png create mode 100644 docs/plans/wallet-ui-3/13-settings.png create mode 100644 docs/plans/wallet-ui-3/14-settings-lower.png create mode 100644 docs/plans/wallet-ui-3/15-settings-lowest.png create mode 100644 docs/plans/wallet-ui-3/16-home-public.png create mode 100644 docs/plans/wallet-ui-3/17-home-scanning.png create mode 100644 docs/plans/wallet-ui-3/18-home-nonode.png create mode 100644 docs/plans/wallet-ui-3/19-home-empty.png create mode 100644 docs/plans/wallet-ui-3/20-update-card.png create mode 100644 docs/plans/wallet-ui-3/21-narrow-900-home.png create mode 100644 docs/plans/wallet-ui-3/22-phone-home.png create mode 100644 docs/plans/wallet-ui-3/23-phone-settings.png create mode 100644 docs/plans/wallet-ui-3/24-light-home.png create mode 100644 docs/plans/wallet-ui-3/m00-welcome.png create mode 100644 docs/plans/wallet-ui-3/m01-home.png create mode 100644 docs/plans/wallet-ui-3/m02-history.png create mode 100644 docs/plans/wallet-ui-3/m03-receive.png create mode 100644 docs/plans/wallet-ui-3/m04-settings.png create mode 100644 docs/plans/wallet-ui-3/m05-light.png create mode 100644 docs/plans/wallet-ui-3/m06-lock.png create mode 100644 docs/plans/wallet-ui-3/n00-welcome.png create mode 100644 docs/plans/wallet-ui-3/n01-create-password.png create mode 100644 docs/plans/wallet-ui-3/n02-create-words.png create mode 100644 docs/plans/wallet-ui-3/n03-import.png create mode 100644 docs/plans/wallet-ui-3/n04-unlock-touch.png create mode 100644 docs/plans/wallet-ui-3/n05-unlock-password.png create mode 100644 docs/plans/wallet-ui-3/n06-home.png create mode 100644 docs/plans/wallet-ui-3/n07-home-lower.png create mode 100644 docs/plans/wallet-ui-3/n08-send.png create mode 100644 docs/plans/wallet-ui-3/n09-send-review.png create mode 100644 docs/plans/wallet-ui-3/n10-send-sent.png create mode 100644 docs/plans/wallet-ui-3/n11-receive.png create mode 100644 docs/plans/wallet-ui-3/n12-tx-final.png create mode 100644 docs/plans/wallet-ui-3/n13-settings.png create mode 100644 docs/plans/wallet-ui-3/n14-settings-lower.png create mode 100644 docs/plans/wallet-ui-3/n15-settings-lowest.png create mode 100644 docs/plans/wallet-ui-3/n16-home-public.png create mode 100644 docs/plans/wallet-ui-3/n17-home-scanning.png create mode 100644 docs/plans/wallet-ui-3/n18-home-nonode.png create mode 100644 docs/plans/wallet-ui-3/n19-home-empty.png create mode 100644 docs/plans/wallet-ui-3/n20-update-card.png create mode 100644 docs/plans/wallet-ui-3/n21-narrow-900-home.png create mode 100644 docs/plans/wallet-ui-3/n22-phone-home.png create mode 100644 docs/plans/wallet-ui-3/n23-phone-settings.png create mode 100644 docs/plans/wallet-ui-3/n24-light-home.png create mode 100644 tools/ui-mock/shoot-wallet.mjs create mode 100644 tools/ui-mock/wallet.mjs diff --git a/app/igneum-wallet/ui/app.css b/app/igneum-wallet/ui/app.css index 356b35332..ab7d2e8f7 100644 --- a/app/igneum-wallet/ui/app.css +++ b/app/igneum-wallet/ui/app.css @@ -1,5 +1,10 @@ -/* Igneum Miner dashboard. The site's tokens (site/index.html): obsidian, graphite, ember, molten, bone, ash; - Unbounded for headings, IBM Plex Sans for text, IBM Plex Mono for numbers and labels. Fonts ship in the binary. */ +/* Igneum Wallet window (wallet-ui-3, on the miner's system: app/igneum-app/ui/app.css, miner-ui-3). The site's tokens: + obsidian, graphite, ember, molten, bone, ash; Unbounded for the page title, the balance and the row's amount; IBM Plex + Sans for words; IBM Plex Mono for labels, units, addresses and small numbers. Fonts ship in the binary. One type scale + (--t-*), one spacing scale (--s-*), one accent (ember). Dark by default, light under prefers-color-scheme or + [data-theme=light]. Layout: a rail on the left (five sections), a thin top bar, the status strip under it, the page in + the middle. The window lays out from 900 x 620 (the hosts' minimum); under 720 px the rail becomes a bottom tab bar. + Nothing here is newer than 2022 CSS (the WebView2 host). */ @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexMono-400.woff2) format('woff2')} @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/IBMPlexMono-500.woff2) format('woff2')} @font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexSans-400.woff2) format('woff2')} @@ -9,426 +14,310 @@ @font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/Unbounded-700.woff2) format('woff2')} @font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(fonts/Unbounded-900.woff2) format('woff2')} -:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E; ---gutter:28px;--card-pad:22px;--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:20px;--gap-tile:12px; ---sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--head:'Unbounded',sans-serif; ---top:60px;--bottom:52px} +:root{ + /* colour, dark */ + --obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E; + --ember-12:rgba(242,84,27,.12);--ember-40:rgba(242,84,27,.4);--molten-10:rgba(255,179,92,.1);--molten-40:rgba(255,179,92,.4);--rail-bg:#111114;--hover:rgba(255,255,255,.04);--top-bg:rgba(12,12,14,.86);--shadow:rgba(0,0,0,.6);--scrim:rgba(12,12,14,.72);--qr-bg:#F4F1EC; + /* type scale */ + --t-xs:11px;--t-sm:12px;--t-base:13px;--t-md:14px;--t-lg:15px;--t-xl:17px;--t-num:24px;--t-hero:34px;--t-h2:28px;--t-h1:44px; + /* spacing scale */ + --s-1:4px;--s-2:8px;--s-3:12px;--s-4:16px;--s-5:24px;--s-6:32px; + --gutter:var(--s-6);--card-pad:var(--s-5);--card-r:16px;--row-r:12px;--gap:var(--s-4); + --sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--head:'Unbounded',sans-serif; + --top:60px;--bottom:0px;--rail:196px} +@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){ + --obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF; + --ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--qr-bg:#FFFFFF}} +:root[data-theme="light"]{ + --obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF; + --ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--qr-bg:#FFFFFF} *{box-sizing:border-box} html,body{height:100%} -body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none} -.mono,code,pre{font-family:var(--mono)} +body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:var(--t-md);line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none;font-variant-numeric:tabular-nums} +.mono,code,pre{font-family:var(--mono);font-variant-numeric:tabular-nums} .dim{color:var(--ash)} h1,h2,h3{font-family:var(--head);margin:0;line-height:1.1;text-wrap:balance} -h1{font-weight:900;font-size:52px;letter-spacing:-.01em} -h2{font-weight:700;font-size:32px} -h3{font-weight:700;font-size:16px} +h1{font-weight:900;font-size:var(--t-h1);letter-spacing:-.01em} +h2{font-weight:700;font-size:var(--t-h2)} +h3{font-weight:700;font-size:var(--t-xl)} p{margin:0} a{color:inherit} button{font:inherit;color:inherit} -.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash);display:inline-flex;align-items:center;gap:8px} +input,textarea,select{font-variant-numeric:tabular-nums} +.eyebrow{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.16em;text-transform:uppercase;color:var(--ash);display:inline-flex;align-items:center;gap:var(--s-2);white-space:nowrap} .eyebrow.ember{color:var(--ember)} [hidden]{display:none !important} +::selection{background:rgba(242,84,27,.45)} /* buttons */ -.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:10px 20px;border-radius:10px;font-weight:600;font-size:15px;border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;transition:transform .15s ease,background .15s ease,border-color .15s ease,opacity .15s ease;white-space:nowrap} +.btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--s-2);min-height:40px;padding:8px 18px;border-radius:10px;font-weight:600;font-size:var(--t-md);border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;transition:transform .15s ease,background .15s ease,border-color .15s ease,opacity .15s ease,color .15s ease;white-space:nowrap} .btn:hover{transform:translateY(-1px);border-color:var(--ash)} .btn:active{transform:none} .btn:disabled{opacity:.4;cursor:default;transform:none} .btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)} -.btn.primary:hover{background:#FF6A2B;border-color:#FF6A2B} -.btn.big{min-height:52px;padding:12px 28px;font-size:16px} -.btn.small{min-height:34px;padding:6px 14px;font-size:13px;border-radius:8px} -.btn.tiny{min-height:26px;padding:2px 10px;font-size:12px;border-radius:7px;font-family:var(--mono);font-weight:500} +.btn.primary:hover{background:var(--ember-hi);border-color:var(--ember-hi)} +.btn.big{min-height:52px;padding:12px 28px;font-size:var(--t-xl)} +.btn.small{min-height:34px;padding:6px 14px;font-size:var(--t-base);border-radius:8px} +.btn.tiny{min-height:26px;padding:2px 10px;font-size:var(--t-sm);border-radius:7px;font-family:var(--mono);font-weight:500} .btn.ghost{border-color:transparent;color:var(--ink-2)} .btn.ghost:hover{border-color:var(--line-2);color:var(--bone)} .btn.danger:hover{color:var(--ember);border-color:var(--ember)} -.icon-btn{width:38px;height:38px;border-radius:10px;border:1px solid var(--line-2);background:transparent;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;color:var(--ink-2);font-size:20px;line-height:1} -.icon-btn:hover{color:var(--bone);border-color:var(--ash)} +.btn.fp svg{flex:0 0 auto} :focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px} +@media (prefers-reduced-motion:reduce){.btn{transition:none}} + +/* the rail */ +.rail{position:fixed;top:0;left:0;bottom:0;width:var(--rail);background:var(--rail-bg);border-right:1px solid var(--line);display:flex;flex-direction:column;z-index:22;padding:14px 12px 14px;-webkit-app-region:drag} +.rail button{-webkit-app-region:no-drag} +.rail-brand{display:flex;align-items:center;gap:10px;padding:6px 10px 18px;min-width:0} +body.mac .rail-brand{padding-top:30px} +.rail-brand .word{font-family:var(--head);font-weight:900;font-size:17px;letter-spacing:.06em} +.rail-nav{display:flex;flex-direction:column;gap:3px} +.nav{position:relative;display:flex;align-items:center;gap:12px;width:100%;min-height:42px;padding:8px 12px;border:1px solid transparent;border-radius:11px;background:transparent;color:var(--ink-2);font-weight:500;font-size:var(--t-md);text-align:left;cursor:pointer;transition:background .15s ease,color .15s ease,border-color .15s ease} +.nav svg{width:19px;height:19px;flex:0 0 19px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round;color:var(--ash);transition:color .15s ease} +.nav:hover{background:var(--hover);color:var(--bone)} +.nav:hover svg{color:var(--ink-2)} +.nav.on{background:var(--ember-12);border-color:var(--ember-40);color:var(--bone);font-weight:600} +.nav.on svg{color:var(--ember)} +.nav.on::before{content:"";position:absolute;left:-13px;top:10px;bottom:10px;width:3px;border-radius:0 3px 3px 0;background:var(--ember)} +.nav.small{min-height:36px;font-size:var(--t-base);color:var(--ash)} +.nav.small svg{width:16px;height:16px;flex-basis:16px} +.nav.danger:hover{color:var(--ember)} +.nav.danger:hover svg{color:var(--ember)} +.nav-dot{position:absolute;right:12px;top:50%;width:7px;height:7px;margin-top:-3px;border-radius:50%;background:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)} +.rail-foot{margin-top:auto;display:flex;flex-direction:column;gap:2px;padding-top:12px;border-top:1px solid var(--line)} +.rail-status{font-size:var(--t-xs);color:var(--ash);padding:0 12px 10px;line-height:1.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rail-status b{color:var(--ink-2);font-weight:500} +.rail-version{font-size:var(--t-xs);color:var(--ash);padding:8px 12px 0;letter-spacing:.06em} /* top bar */ -.top{position:fixed;top:0;left:0;right:0;height:var(--top);display:flex;align-items:center;justify-content:space-between;padding:0 var(--gutter);background:rgba(12,12,14,.86);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid rgba(42,42,48,.7);z-index:20;-webkit-app-region:drag} +.top{position:fixed;top:0;left:0;right:0;height:var(--top);display:flex;align-items:center;justify-content:space-between;gap:var(--s-4);padding:0 var(--gutter);background:var(--top-bg);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--line);z-index:20;-webkit-app-region:drag} .top button,.top .pill{-webkit-app-region:no-drag} -body.mac .top{padding-left:92px} -.brand{display:flex;align-items:center;gap:10px} +body.mac:not(.has-rail) .top{padding-left:92px} +body.has-rail .top{left:var(--rail)} +.brand{display:flex;align-items:center;gap:10px;min-width:0} .brand .word{font-family:var(--head);font-weight:900;font-size:20px;letter-spacing:.06em} -.brand .miner{font-family:var(--mono);font-size:11px;letter-spacing:.22em;color:var(--ash);margin-left:4px;padding-top:3px} -.top-right{display:flex;align-items:center;gap:12px} -.pill{display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:6px 12px 6px 10px;background:var(--graphite)} -.pill.on{color:var(--molten);border-color:rgba(255,179,92,.35)} +.brand .miner{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.22em;color:var(--ash);margin-left:var(--s-1);padding-top:3px} +.page-title{display:flex;align-items:baseline;gap:12px;min-width:0} +.page-title h1{font-size:19px;font-weight:700;letter-spacing:0;white-space:nowrap} +.page-sub{font-size:var(--t-base);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0} +.top-right{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto;min-width:0} +.narrow-only{display:none} +.pill{display:inline-flex;align-items:center;gap:var(--s-2);font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:6px 12px 6px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;min-width:112px;justify-content:center} +.pill.on{color:var(--molten);border-color:var(--molten-40)} .pill.warn{color:var(--ember)} .dot{width:8px;height:8px;border-radius:50%;background:var(--ash);display:inline-block;flex:0 0 8px} -.dot.small{width:7px;height:7px;flex-basis:7px} .on .dot,.dot.live{background:var(--molten);animation:pulse 2s ease-in-out infinite} -.warn .dot{background:var(--ember);animation:none} +.warn .dot,.dot.bad{background:var(--ember);animation:none} @keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 7px rgba(255,179,92,0)}} @media (prefers-reduced-motion:reduce){.on .dot,.dot.live{animation:none}} -/* update banner */ -.banner{position:fixed;top:var(--top);left:0;right:0;z-index:19;display:flex;align-items:center;justify-content:center;gap:14px;padding:10px var(--gutter);background:rgba(242,84,27,.12);border-bottom:1px solid rgba(242,84,27,.4);font-size:14px} +/* the status strip under the top bar (the update line): takes no room while empty */ +.notices{position:fixed;top:var(--top);left:0;right:0;z-index:19} +body.has-rail .notices{left:var(--rail)} +.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-4);padding:8px calc(var(--gutter) - 6px) 8px var(--gutter);background:var(--molten-10);border-bottom:1px solid var(--molten-40);font-size:var(--t-base);line-height:1.4;color:var(--bone)} +.notice.urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600} +.notice-text{flex:1 1 320px;min-width:0} +.notice-actions{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto} +.notice-actions:empty{display:none} +.notice-close{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--ash);font-size:20px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0} +.notice-close:hover{color:var(--bone);border-color:var(--line-2)} +.notice.urgent .notice-close{color:#fff} +.notice .prog{flex-basis:100%;height:3px;background:rgba(127,127,127,.2);border-radius:2px;overflow:hidden;margin-top:-3px} +.notice .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear} -.banner.clock{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5);flex-wrap:wrap} -.banner.clock.warn{background:rgba(255,179,92,.1);border-bottom-color:rgba(255,179,92,.4)} -.banner .hint{font-size:11px;color:var(--ash);flex-basis:100%;text-align:center} -.banner.update{flex-wrap:wrap;row-gap:8px} -.banner.update.urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600} -.banner .prog{flex-basis:100%;height:3px;background:rgba(255,255,255,.12);border-radius:2px;overflow:hidden;margin-top:-2px} -.banner .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear} -/* remote job strip (src/jobrun.rs): running, then the outcome */ -.banner.job{flex-wrap:wrap;row-gap:8px;background:rgba(255,179,92,.1);border-bottom-color:rgba(255,179,92,.4)} -.banner.job.failed{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5)} -.banner.job .job-results{flex-basis:100%;margin:0;font-size:11px;line-height:1.5;color:var(--ink-2);white-space:pre-wrap;word-break:break-word;max-height:120px;overflow:auto} -.job-history table{margin-top:8px} -.job-history th{text-align:left;font-weight:500;color:var(--ash);font-size:11px;padding:4px 6px 4px 0} -.job-history td{padding:5px 6px 5px 0;border-top:1px solid var(--line);vertical-align:top} -.job-history tr.failed td,.job-history tr.timeout td,.job-history tr.aborted td{color:var(--ember)} -.job-history tr.running td{color:var(--molten)} -.clock-card{margin-top:12px;border:1px solid rgba(242,84,27,.5);background:rgba(242,84,27,.08);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:8px} -.clock-card.warn{border-color:rgba(255,179,92,.4);background:rgba(255,179,92,.06)} -.clock-msg{font-size:14px;color:var(--bone);line-height:1.45} -.clock-card .note{margin-top:0} +/* a question in place of a dialog: the quit strip over the page, the inline asks inside a card */ +.ask-wrap{position:fixed;left:0;right:0;bottom:0;z-index:36;display:flex;justify-content:center;padding:0 var(--s-4) var(--s-4);pointer-events:none} +body.has-rail .ask-wrap{left:var(--rail)} +.ask{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;background:var(--graphite);border:1px solid var(--ember-40);border-radius:14px;padding:12px 16px;box-shadow:0 14px 40px var(--shadow);pointer-events:auto;max-width:720px;animation:rise .2s ease} +.ask-text{flex:1 1 260px;font-size:var(--t-md);min-width:0;line-height:1.45} +.ask.inline{box-shadow:none;background:var(--ember-12);margin-top:var(--s-3);animation:none;max-width:none} +.ask-foot{flex-basis:100%;font-size:var(--t-sm);color:var(--ink-2);min-height:0} +.ask-foot:empty{display:none} +.ask .short-pw{width:200px;flex:0 0 200px;margin-top:0} -/* screens */ -main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter)} -body.has-bottom main{bottom:var(--bottom)} -body.drawer-open main{bottom:calc(var(--bottom) + 260px)} +/* screens and pages */ +main{position:absolute;top:var(--top);bottom:var(--bottom);left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain;transition:top .15s ease} +@media (prefers-reduced-motion:reduce){main{transition:none}} +body.has-rail main{left:var(--rail)} .screen{display:none;max-width:1080px;margin:0 auto;animation:rise .45s ease} -body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block} +body[data-phase="welcome"] #screen-welcome,body[data-phase="create"] #screen-create,body[data-phase="import"] #screen-import,body[data-phase="unlock"] #screen-unlock,body[data-phase="home"] #screen-home{display:block} @keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}} -@media (prefers-reduced-motion:reduce){.screen{animation:none}} +@media (prefers-reduced-motion:reduce){.screen,.page{animation:none}} +#screen-home{padding:22px 0 32px} +.page{display:flex;flex-direction:column;gap:var(--gap);animation:rise .3s ease} /* welcome */ -.hero{min-height:calc(100vh - var(--top));display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:18px;padding:40px 0 48px} -.coin-wrap{position:relative;width:148px;height:148px;margin-bottom:6px} -.coin-wrap::before{content:"";position:absolute;inset:-40px;border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.28) 0,rgba(242,84,27,0) 65%);animation:breathe 4s ease-in-out infinite} -.coin{position:relative;display:block;border-radius:50%;filter:drop-shadow(0 10px 30px rgba(242,84,27,.35))} +.hero{min-height:calc(100vh - var(--top));display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:var(--s-4);padding:var(--s-6) 0 48px} +.mark-wrap{position:relative;width:120px;height:120px;border-radius:26px;background:#0C0C0E;display:flex;align-items:center;justify-content:center;margin-bottom:6px;box-shadow:0 20px 50px rgba(242,84,27,.25)} +.mark-wrap::before{content:"";position:absolute;inset:-40px;border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.28) 0,rgba(242,84,27,0) 65%);animation:breathe 4s ease-in-out infinite;z-index:-1} @keyframes breathe{0%,100%{opacity:.7;transform:scale(1)}50%{opacity:1;transform:scale(1.08)}} -.lead{font-size:18px;color:var(--ink-2);max-width:54ch} -.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--gap-tile);width:100%;max-width:860px;margin-top:10px;text-align:left} -.tile{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0} -.tile .k{font-family:var(--mono);font-size:11px;letter-spacing:.14em;color:var(--ember)} -.tile .t{font-family:var(--head);font-weight:700;font-size:15px;line-height:1.25} -.tile .s{font-size:13px;color:var(--ash);line-height:1.45} -.cta{display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:center;margin-top:10px} -.seedline{font-size:12px;color:var(--ash);letter-spacing:.04em} +@media (prefers-reduced-motion:reduce){.mark-wrap::before{animation:none}} +.lead{font-size:var(--t-xl);color:var(--ink-2);max-width:54ch} +.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s-3);width:100%;max-width:860px;margin-top:10px;text-align:left} +.tile{background:var(--graphite);border:1px solid var(--line);border-radius:14px;padding:18px 20px;display:flex;flex-direction:column;gap:6px;min-width:0} +.tile .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.14em;color:var(--ember)} +.tile .t{font-family:var(--head);font-weight:700;font-size:var(--t-lg);line-height:1.25} +.tile .s{font-size:var(--t-base);color:var(--ash);line-height:1.45} +.cta{display:flex;flex-wrap:wrap;gap:var(--s-3);align-items:center;justify-content:center;margin-top:10px} +.seedline{font-size:var(--t-sm);color:var(--ash);letter-spacing:.04em} -/* steps */ -.step{max-width:720px;margin:0 auto;padding:56px 0 48px;display:flex;flex-direction:column;gap:16px} -.step .sub{font-size:16px;color:var(--ink-2);max-width:60ch} -.step .cta{justify-content:flex-start;margin-top:8px} -.note{font-size:13px;color:var(--ash);line-height:1.5} -.note.small{font-size:12px;word-break:break-all} -.cards{display:flex;flex-direction:column;gap:12px;margin-top:8px} -.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0} -.card.detecting{display:flex;align-items:center;gap:18px} -.card.detecting .t{font-family:var(--head);font-weight:700;font-size:16px} -.card.detecting .s{font-size:12px;color:var(--ash);margin-top:4px} -.spinner{width:28px;height:28px;border-radius:50%;border:3px solid var(--line-2);border-top-color:var(--ember);animation:spin 1s linear infinite;flex:0 0 28px} -@keyframes spin{to{transform:rotate(360deg)}} -.gpu{display:flex;align-items:center;gap:18px} -.gpu .badge{width:52px;height:52px;border-radius:14px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;letter-spacing:.08em;flex:0 0 52px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)} -.gpu .badge.apple{color:var(--bone)} -.gpu .badge.nvidia{color:#8BE37A} -.gpu .badge.amd{color:var(--ember)} -.gpu .name{font-family:var(--head);font-weight:700;font-size:18px;line-height:1.2} -.gpu .meta{font-family:var(--mono);font-size:12px;color:var(--ash);margin-top:5px;display:flex;flex-wrap:wrap;gap:6px 14px} -.gpu .meta b{color:var(--ink-2);font-weight:500} -.gpu .tick{margin-left:auto;width:36px;height:36px;border-radius:50%;background:var(--ember);display:flex;align-items:center;justify-content:center;flex:0 0 36px} -.gpu.off .tick{background:var(--line-2)} -.gpu .tick svg path{stroke-dasharray:30;stroke-dashoffset:30;animation:draw .8s .2s ease forwards} -@keyframes draw{to{stroke-dashoffset:0}} -.gpu .msg{font-size:12px;color:var(--ember);margin-top:4px} - -/* GPU rows (first run and settings) */ -.gpu-row{display:flex;align-items:center;gap:16px;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:16px 20px;min-width:0} -.gpu-row.off{opacity:.72} -.gpu-row .badge{width:48px;height:48px;border-radius:13px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;letter-spacing:.08em;flex:0 0 48px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)} -.gpu-row .badge.apple{color:var(--bone)} -.gpu-row .badge.nvidia{color:#8BE37A} -.gpu-row .badge.amd{color:var(--ember)} -.gpu-row .info{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px} -.gpu-row .name{font-family:var(--head);font-weight:700;font-size:16px;line-height:1.2;display:flex;align-items:center;gap:10px;flex-wrap:wrap} -.kind{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;padding:2px 8px;border:1px solid var(--line-2);color:var(--ash);font-weight:500} -.kind.discrete,.kind.apple{color:var(--molten);border-color:rgba(255,179,92,.4)} -.kind.external{color:var(--bone)} -.gpu-row .meta{font-family:var(--mono);font-size:12px;color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 14px} -.gpu-row .meta b{color:var(--ink-2);font-weight:500} -.gpu-row .reason{font-size:12px;color:var(--ash)} -.gpu-row .msg{font-size:12px;color:var(--ember)} -.ids{display:flex;align-items:center;gap:6px;flex:0 0 auto} -.ids .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);margin-right:4px} -.ids button{width:30px;height:30px;border-radius:8px;border:1px solid var(--line-2);background:transparent;color:var(--bone);cursor:pointer;font-size:16px;line-height:1} -.ids button:hover{border-color:var(--ash)} -.ids input{width:44px;text-align:center;background:var(--obsidian);border:1px solid var(--line-2);border-radius:8px;padding:5px 4px;color:var(--bone);font-family:var(--mono);font-size:13px;user-select:text;-webkit-user-select:text} -.ids input:focus{outline:none;border-color:var(--ember)} -.ids input::-webkit-inner-spin-button,.ids input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0} -.gpu-row.off .ids{opacity:.4;pointer-events:none} -.gpu-row .switch{padding:0;flex:0 0 auto} -.cards.compact .gpu-row{padding:12px 14px;gap:12px;border-radius:14px} -.cards.compact .gpu-row .badge{width:38px;height:38px;flex-basis:38px;border-radius:10px} -.cards.compact .gpu-row .name{font-size:14px} -.cards.compact .ids .k{display:none} - -.power{display:flex;align-items:center;gap:8px;flex:0 0 auto} -.power .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} -.power input[type=range]{width:110px;accent-color:var(--ember)} -.power .pv{font-size:12px;color:var(--ink-2);min-width:84px} -.cards.compact .power .k{display:none} -.cards.compact .power input[type=range]{width:80px} -.gpu-tile .m.tele .warm,.gpu-tile .m.tele .warm b{color:var(--molten)} -.gpu-tile .m.tele .hot,.gpu-tile .m.tele .hot b{color:var(--ember)} -.gpu-tile .msg.ok,.gpu-row .msg.ok{color:var(--molten)} -.gpu-row .msg.hot,.gpu-tile .msg.hot{color:var(--ember)} -.gpu-tile .msg .btn.tiny,.gpu-row .msg .btn.tiny{margin-left:6px;vertical-align:middle} -.gpu-tile .msg.warm{color:var(--molten)} -.gpu-tile .msg.hot{color:var(--ember)} - -/* per-card tiles on the dashboard */ -.gpu-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--gap-tile)} -.gpu-tile{background:var(--obsidian);border:1px solid var(--line);border-radius:var(--tile-r);padding:14px 16px;display:flex;flex-direction:column;gap:6px;min-width:0} -.gpu-tile .n{font-family:var(--head);font-weight:700;font-size:14px;line-height:1.25;display:flex;align-items:center;gap:8px;flex-wrap:wrap} -.gpu-tile .h{font-family:var(--head);font-weight:700;font-size:24px;color:var(--ember);line-height:1.1;display:flex;align-items:baseline;gap:6px;font-variant-numeric:tabular-nums} -.gpu-tile .h .unit{font-family:var(--mono);font-size:11px;color:var(--ash);font-weight:500;letter-spacing:.08em} -.gpu-tile.idle .h{color:var(--ash)} -.gpu-tile .m{font-family:var(--mono);font-size:12px;color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 12px} -.gpu-tile .m b{color:var(--ink-2);font-weight:500} -.gpu-tile .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;color:var(--ash)} -.gpu-tile .st.mining{color:var(--molten)} -.gpu-tile .st.bad{color:var(--ember)} -.gpu-tile .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block} -.gpu-tile .msg{font-size:12px;color:var(--ash)} -.gpu-off{margin-top:12px;font-size:12px;color:var(--ash)} - -/* address options */ -.options{display:flex;flex-direction:column;gap:12px;margin-top:6px} -.option{display:flex;gap:16px;align-items:flex-start;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:20px 22px;cursor:pointer;transition:border-color .15s ease,background .15s ease} -.option:hover{border-color:var(--line-2)} -.option.on{border-color:var(--ember);background:#1A1614} -.option input[type=radio]{position:absolute;opacity:0;width:0;height:0} -.option .radio{width:20px;height:20px;border-radius:50%;border:2px solid var(--line-2);flex:0 0 20px;margin-top:2px;position:relative} -.option.on .radio{border-color:var(--ember)} -.option.on .radio::after{content:"";position:absolute;inset:4px;border-radius:50%;background:var(--ember)} -.option .body{display:flex;flex-direction:column;gap:6px;min-width:0;flex:1} -.option .t{font-family:var(--head);font-weight:700;font-size:16px;display:flex;align-items:center;gap:10px} -.option .s{font-size:14px;color:var(--ash);line-height:1.5} -.tag{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--molten);border:1px solid rgba(255,179,92,.4);border-radius:999px;padding:2px 8px} -.addr-input{width:100%;margin-top:8px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;color:var(--bone);font-size:14px;letter-spacing:.02em;user-select:text;-webkit-user-select:text} -.addr-input:focus{outline:none;border-color:var(--ember)} -.option:not(.on) .addr-input{display:none} -.err{font-size:13px;color:var(--ember)} - -/* dashboard */ -#screen-dashboard{padding:22px 0 28px;display:none;flex-direction:column;gap:var(--gap)} -body[data-phase="dashboard"] #screen-dashboard{display:flex} -.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)} -.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0} -.cell .k{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} -.cell .v{font-family:var(--head);font-weight:700;font-size:26px;line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:8px} -.cell.ember .v{color:var(--ember)} -.cell .v .unit{font-family:var(--mono);font-size:12px;color:var(--ash);font-weight:500;letter-spacing:.08em} -.cell .v.state{text-transform:capitalize;font-size:22px} -.cell .s{font-family:var(--mono);font-size:12px;color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.cell.ok .v.state{color:var(--molten)} -.cell.bad .v.state{color:var(--ember)} -.grid{display:grid;grid-template-columns:1.35fr .85fr;gap:var(--gap);align-items:start} -.col-main,.col-side{display:flex;flex-direction:column;gap:var(--gap);min-width:0} -.card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap} -.stats{display:flex;flex-wrap:wrap;gap:12px 16px;font-size:12px;color:var(--ash)} -.stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums} -#dag{display:block;width:100%;height:190px;border-radius:12px;background:var(--obsidian)} -.legend{display:flex;flex-wrap:wrap;gap:14px 18px;margin-top:12px;font-size:12px;color:var(--ink-2)} -.legend span{display:inline-flex;align-items:center;gap:8px} -.sw{width:12px;height:12px;border-radius:3px;display:inline-block;border:1px solid var(--line-2)} -.sw.ember{background:var(--ember);border-color:var(--ember)} -.sw.glow{border-color:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)} -.sw.line{width:18px;height:0;border:0;border-top:1px dashed var(--line-2);border-radius:0} -.tbl{overflow-x:auto} -table{border-collapse:collapse;width:100%;font-size:13px} -th,td{padding:9px 8px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap} -th{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500} -td.n,th.n{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--mono)} -tr:last-child td{border-bottom:0} -td .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;color:var(--ash)} -td .st.mining{color:var(--molten)} -td .st.bad{color:var(--ember)} -td .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block} -td .sub{display:block;font-family:var(--mono);font-size:11px;color:var(--ash);white-space:normal;max-width:280px} -.empty{color:var(--ash);font-size:13px;padding:10px 0} -.kv{display:flex;flex-direction:column} -.kv>div{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:7px 0;border-bottom:1px solid var(--line)} -.kv>div:last-child{border-bottom:0} -.kv .k{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--ash)} -.kv .v{font-size:14px;font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.card .note{margin-top:10px} -.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:12px;color:var(--ink-2);max-height:300px;overflow:auto} -.feed>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;align-items:baseline} -.feed>div:last-child{border-bottom:0} -.feed .t{color:var(--ash);flex:0 0 auto;font-size:11px} -.feed .k{color:var(--molten);margin-right:6px} -.feed .k.error{color:var(--ember)} -.feed .k.block{color:var(--ember)} -.feed .k.build{color:var(--ink-2)} - -/* sheet (the key) */ -.sheet-wrap,.panel-wrap{position:fixed;inset:0;z-index:30;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px} -.sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;display:flex;flex-direction:column;gap:14px;box-shadow:0 30px 80px rgba(0,0,0,.6);animation:rise .3s ease} -.sheet .sub{font-size:15px;color:var(--ink-2)} -.field{display:flex;flex-direction:column;gap:8px;margin-top:6px} -.field .k{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} -.box{display:flex;align-items:center;gap:10px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;font-size:13px;word-break:break-all;user-select:text;-webkit-user-select:text} +/* steps (create, import) */ +.step{max-width:720px;margin:0 auto;padding:56px 0 48px;display:flex;flex-direction:column;gap:var(--s-4)} +.step .sub{font-size:var(--t-xl);color:var(--ink-2);max-width:60ch} +.step .cta{justify-content:flex-start;margin-top:var(--s-2)} +.note{font-size:var(--t-base);color:var(--ash);line-height:1.5} +.note.small{font-size:var(--t-sm);word-break:break-all} +.help{font-size:var(--t-base);color:var(--ash);line-height:1.5;max-width:64ch} +.line-text{font-size:var(--t-md);color:var(--ink-2);line-height:1.5} +.line-text.ok{color:var(--molten)} +.line-text.bad{color:var(--ember)} +.err{font-size:var(--t-base);color:var(--ember);line-height:1.5} +.err:empty{display:none} +.top-gap{margin-top:var(--s-4)} +.indent{margin-left:52px} +.field{display:flex;flex-direction:column;gap:var(--s-2);margin-top:6px} +.field .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} +.field input,.field textarea,.addr-input{font:inherit;color:var(--bone);background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;min-height:42px;font-size:var(--t-md);user-select:text;-webkit-user-select:text} +.field textarea{font-family:var(--mono);font-size:var(--t-md);line-height:1.5;resize:vertical} +.field input.mono,.addr-input.mono{font-family:var(--mono)} +.field input:focus,.field textarea:focus,.addr-input:focus,.select:focus{outline:none;border-color:var(--ember)} +.addr-input{width:100%;min-width:0} +.row .addr-input{flex:1 1 180px;width:auto} +.box{display:flex;align-items:center;gap:10px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;font-size:var(--t-base);word-break:break-all;user-select:text;-webkit-user-select:text} .box span{flex:1;min-width:0} .box.key{color:var(--molten)} -.check{display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer;margin-top:4px} -.check.small{font-size:12px;color:var(--ash)} -.check input{width:18px;height:18px;accent-color:var(--ember)} -.sheet .cta{justify-content:flex-start} +.words{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin:8px 0 0;padding:0;list-style:none;counter-reset:w} +.words li{counter-increment:w;background:var(--graphite);border:1px solid var(--line);border-radius:10px;padding:8px 10px;font-family:var(--mono);font-size:var(--t-md);display:flex;gap:8px;align-items:baseline;user-select:text;-webkit-user-select:text} +.words li::before{content:counter(w);color:var(--ash);font-size:var(--t-xs);min-width:18px;text-align:right} +.words.small{grid-template-columns:repeat(6,minmax(0,1fr))} +.words.small li{font-size:var(--t-sm);padding:5px 8px;background:var(--obsidian)} +.checks{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px} +.checks label{display:flex;flex-direction:column;gap:6px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash)} +.checks input{font:inherit;font-family:var(--mono);font-size:var(--t-lg);color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;user-select:text;-webkit-user-select:text} +.checks input:focus{outline:none;border-color:var(--ember)} -/* settings panel */ -.panel-wrap{justify-content:flex-end;padding:0} -.panel{width:min(440px,100%);height:100%;background:var(--graphite);border-left:1px solid var(--line-2);display:flex;flex-direction:column;animation:slide .25s ease} -@keyframes slide{from{transform:translateX(30px);opacity:0}to{transform:none;opacity:1}} -.panel-head{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid var(--line)} -.panel-body{padding:14px 22px 28px;overflow:auto;display:flex;flex-direction:column;gap:18px} -.row{display:flex;gap:10px;align-items:center} -.row.between{justify-content:space-between} -.row .addr-input{margin-top:0} -.num{width:90px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:9px 12px;color:var(--bone);font-size:14px;user-select:text;-webkit-user-select:text} -.num:focus{outline:none;border-color:var(--ember)} -.switch{display:flex;align-items:center;gap:12px;font-size:14px;cursor:pointer;padding:6px 0} +/* cards, rows, disclosures, switches, the segmented control, the kv */ +.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0} +.card-head{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);margin-bottom:var(--s-3);flex-wrap:wrap} +.row{display:flex;gap:10px;align-items:center;min-width:0} +.row.wrap{flex-wrap:wrap} +.lead-row{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--s-4)} +.lead-text{min-width:0;display:flex;flex-direction:column;gap:6px} +.lead-text h3{font-size:var(--t-xl)} +.disclose{display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);width:100%;border:0;background:transparent;padding:0;cursor:pointer;text-align:left;color:var(--bone);font-size:var(--t-md);font-weight:500;min-height:32px} +.disclose .chev{color:var(--ash);flex:0 0 auto;margin-right:4px} +.disclose-right{display:flex;align-items:center;gap:var(--s-3);min-width:0} +.disclose-right .dim{font-size:var(--t-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;max-width:52ch} +.chev{width:9px;height:9px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg) translateY(-2px);transition:transform .15s ease;display:inline-block} +.open .chev,[aria-expanded="true"] .chev{transform:rotate(225deg) translateY(-2px)} +.card .disclose+.disclose,.card .details+.disclose,.card .srow+.disclose,.card .switch+.disclose,.card .err+.disclose{margin-top:var(--s-3);padding-top:var(--s-3);border-top:1px solid var(--line)} +.details{padding-top:var(--s-3);display:flex;flex-direction:column;gap:var(--s-3)} +.srow{display:flex;align-items:center;justify-content:space-between;gap:var(--s-4);padding:9px 0;min-width:0} +.srow+.srow,.switch+.srow,.srow+.switch{border-top:1px solid var(--line)} +.sw-text{min-width:0} +.sw-text b{font-weight:600} +.sw-text .dim{font-size:var(--t-base)} +.switch{display:flex;align-items:flex-start;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:9px 0;line-height:1.4} +.switch+.switch{border-top:1px solid var(--line)} .switch input{position:absolute;opacity:0;width:0;height:0} -.switch .track{width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;flex:0 0 40px;transition:background .15s ease} -.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:var(--bone);transition:transform .15s ease} +.switch .track{width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;flex:0 0 40px;transition:background .15s ease;margin-top:1px} +.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#F4F1EC;transition:transform .15s ease} .switch input:checked+.track{background:var(--ember)} .switch input:checked+.track::after{transform:translateX(18px)} +.switch input:focus-visible+.track{outline:2px solid var(--ember);outline-offset:3px} +.switch input:disabled+.track{opacity:.4} +.seg{display:inline-flex;background:var(--obsidian);border:1px solid var(--line);border-radius:10px;padding:3px;gap:2px} +.seg-b{border:0;background:transparent;color:var(--ash);font-size:var(--t-base);font-weight:500;padding:6px 14px;border-radius:8px;cursor:pointer;transition:background .15s ease,color .15s ease;white-space:nowrap} +.seg-b:hover{color:var(--bone)} +.seg-b.on{background:var(--graphite);color:var(--bone);box-shadow:0 1px 3px rgba(0,0,0,.25);font-weight:600} +.seg-b:disabled{opacity:.4;cursor:default} +.select{font:inherit;font-size:var(--t-md);color:var(--bone);background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:7px 12px;min-height:38px;cursor:pointer} +.kv{display:flex;flex-direction:column} +.kv>div{display:grid;grid-template-columns:140px auto 1fr;align-items:baseline;gap:var(--s-3);padding:7px 0;border-bottom:1px solid var(--line)} +.kv>div:last-child{border-bottom:0} +.kv .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.1em;text-transform:uppercase;color:var(--ash);white-space:nowrap} +.kv .v{font-size:var(--t-md);font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;user-select:text;-webkit-user-select:text} +.kv .v.ok{color:var(--molten)} +.kv .v.warn{color:var(--ember)} +.kv .v.dim{color:var(--ash)} +.kv .m{font-size:var(--t-sm);color:var(--ash);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.card.adv{padding:0} +.card.adv summary{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:var(--card-pad)} +.card.adv summary::-webkit-details-marker{display:none} +.card.adv summary::after{content:"+";font-family:var(--mono);color:var(--ash);font-size:18px;margin-left:auto} +.card.adv[open] summary::after{content:"\2212"} +.card.adv summary .eyebrow{margin-left:0} +.card.adv>:not(summary){margin-left:var(--card-pad);margin-right:var(--card-pad)} +.card.adv>:last-child{margin-bottom:var(--card-pad)} +.card.adv>.note+.note{margin-top:6px} +.empty{color:var(--ash);font-size:var(--t-base);padding:10px 0} -/* bottom bar */ -.bottom{position:fixed;left:0;right:0;bottom:0;height:var(--bottom);display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 var(--gutter);background:rgba(12,12,14,.92);border-top:1px solid var(--line);z-index:21} -.bottom .left,.bottom .right{display:flex;align-items:center;gap:8px} -.bottom .mid{font-size:12px;color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:center;flex:1} -.bottom .right .mono{font-size:12px} +/* Home: the balance first, its money line, the address, the two buttons */ +.hero-card{display:flex;flex-direction:column;gap:var(--s-3)} +.bal-row{display:flex;align-items:baseline;gap:var(--s-4);flex-wrap:wrap;min-width:0} +.bal{display:flex;align-items:baseline;gap:12px;min-width:0} +.bal .v{font-family:var(--head);font-weight:900;font-size:var(--t-h1);letter-spacing:-.01em;line-height:1;white-space:nowrap;user-select:text;-webkit-user-select:text} +.bal .unit{font-size:var(--t-md);color:var(--ash);letter-spacing:.12em} +.bal-line{color:var(--ash);font-size:var(--t-md)} +.bal-line:empty{display:none} +.money-line{font-size:var(--t-md);color:var(--ash);margin-top:-4px} +.money-line b{color:var(--bone);font-family:var(--head);font-weight:700;font-size:var(--t-num);margin-right:6px} +.addr-big{display:flex;align-items:center;gap:12px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:12px;padding:14px 16px;font-size:var(--t-lg);word-break:break-all;user-select:text;-webkit-user-select:text} +.addr-big span{flex:1;min-width:0;color:var(--molten)} +.actions{margin-top:var(--s-1)} +.actions .note{margin-left:var(--s-2)} +.node-line{display:inline-flex;align-items:center;gap:10px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.node-card.bad .node-line{color:var(--ember)} -/* log drawer */ -.drawer{position:fixed;left:0;right:0;bottom:var(--bottom);height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:20;transition:height .2s ease;display:flex;flex-direction:column} -body.drawer-open .drawer{height:260px} -.drawer-head{display:flex;justify-content:space-between;align-items:center;padding:8px var(--gutter);border-bottom:1px solid var(--line);flex:0 0 auto} -.chips{display:flex;gap:6px} -.chip{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;border:1px solid var(--line);border-radius:999px;padding:4px 10px;background:transparent;color:var(--ash);cursor:pointer} -.chip.on{color:var(--molten);border-color:rgba(255,179,92,.4)} -.log{margin:0;flex:1;overflow:auto;padding:10px var(--gutter);font-size:12px;line-height:1.55;color:var(--ink-2);white-space:pre-wrap;word-break:break-all;user-select:text;-webkit-user-select:text} -.log .src{color:var(--ash)} -.log .src.node{color:#7FA7C9} -.log .src.miner1,.log .src.miner2,.log .src.miner3,.log .src.miner4{color:var(--molten)} -.log .src.app{color:var(--ember)} -.log .e{color:var(--ember)} +/* the history row: kind and who, the amount, the state word with its reason, the chevron; the details under */ +.hist{display:flex;flex-direction:column;gap:var(--s-2)} +.hrow{border:1px solid var(--line);border-radius:var(--row-r);background:var(--row);min-width:0} +.hrow.open{border-color:var(--line-2)} +.hr-main{display:grid;grid-template-columns:26px minmax(160px,1.3fr) auto minmax(180px,1fr) 30px;align-items:center;gap:var(--s-4);padding:11px 14px;cursor:pointer} +.hr-main .glyph{width:26px;height:26px;border-radius:8px;border:1px solid var(--line-2);display:flex;align-items:center;justify-content:center;color:var(--ash);font-family:var(--mono);font-size:var(--t-sm)} +.hrow.in .glyph{color:var(--molten);border-color:var(--molten-40)} +.hr-who{min-width:0;display:flex;flex-direction:column;gap:2px} +.hr-who .kind{font-weight:600;font-size:var(--t-md);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.hr-who .sub{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.hr-amt{font-family:var(--head);font-weight:700;font-size:var(--t-lg);white-space:nowrap;text-align:right;justify-self:end} +.hrow.in .hr-amt{color:var(--molten)} +.hr-amt .unit{font-family:var(--mono);font-size:10px;color:var(--ash);font-weight:500;letter-spacing:.08em;margin-left:4px} +.hr-state{min-width:0;display:flex;flex-direction:column;gap:2px} +.hr-state .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap} +.hr-state .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block;flex:0 0 7px} +.hr-state .st.ink{color:var(--ink-2)} +.hr-state .st.ok{color:var(--molten)} +.hr-state .st.bad{color:var(--ember)} +.hr-state .why{font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.chev-btn{width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;color:var(--ash);justify-self:end} +.chev-btn:hover{border-color:var(--line-2);color:var(--bone)} +.hr-details{padding:12px 14px 14px 56px;border-top:1px solid var(--line);user-select:text;-webkit-user-select:text} +.hr-details .kv>div{grid-template-columns:110px 1fr} +.hr-details .kv .v{white-space:normal;word-break:break-all} +.hrow.sent-row .hr-main{cursor:default;grid-template-columns:26px minmax(160px,1.3fr) auto minmax(180px,1fr)} -.toast{position:fixed;left:50%;bottom:calc(var(--bottom) + 16px);transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:13px;z-index:40;box-shadow:0 10px 30px rgba(0,0,0,.5)} +/* Send: the fee row and the pending row */ +.send-form{display:flex;flex-direction:column;gap:var(--s-3)} +.unit-word{font-size:var(--t-sm);color:var(--ash);letter-spacing:.1em} +.fee-row{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;padding:8px 0 0} +.fee-row .k{font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} +.send-form[data-locked="1"] input{opacity:.6;pointer-events:none} +.sent{display:flex;flex-direction:column;gap:var(--s-3);margin-top:var(--s-3)} +.sent .cta{justify-content:flex-start;margin-top:0} -@media (max-width:860px){ - .strip{grid-template-columns:repeat(2,minmax(0,1fr))} - .grid{grid-template-columns:1fr} - .three{grid-template-columns:1fr} - h1{font-size:40px} -} - -/* ---- Igneum Wallet (added to the miner's tokens and base styles above) ---- */ -body[data-phase="welcome"] #screen-welcome,body[data-phase="create"] #screen-create,body[data-phase="import"] #screen-import,body[data-phase="unlock"] #screen-unlock,body[data-phase="home"] #screen-home{display:block} -.view{display:none} -body[data-view="overview"] #view-overview,body[data-view="send"] #view-send,body[data-view="receive"] #view-receive,body[data-view="tx"] #view-tx,body[data-view="settings"] #view-settings{display:block} -body{user-select:text;-webkit-user-select:text} -.field{display:flex;flex-direction:column;gap:6px;font-size:13px;color:var(--ash)} -.field input,.field textarea,.inline input{font:inherit;color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;min-height:44px} -.field textarea{font-family:var(--mono);font-size:14px;line-height:1.5;resize:vertical} -.field input.mono{font-family:var(--mono)} -.field input:focus,.field textarea:focus,.inline input:focus{outline:none;border-color:var(--ember)} -.err{color:var(--ember);font-size:13px;min-height:18px} -.err:empty{display:none} -.words{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin:8px 0 0;padding:0 0 0 0;list-style:none;counter-reset:w} -.words li{counter-increment:w;background:var(--graphite);border:1px solid var(--line);border-radius:10px;padding:8px 10px;font-family:var(--mono);font-size:14px;display:flex;gap:8px;align-items:baseline} -.words li::before{content:counter(w);color:var(--ash);font-size:11px;min-width:18px;text-align:right} -.words.small{grid-template-columns:repeat(6,minmax(0,1fr))} -.words.small li{font-size:12px;padding:5px 8px} -.checks{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px} -.checks label{display:flex;flex-direction:column;gap:6px;font-family:var(--mono);font-size:12px;color:var(--ash)} -.checks input{font:inherit;font-family:var(--mono);font-size:15px;color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px} -.segs{display:flex;gap:6px;background:var(--graphite);border:1px solid var(--line);border-radius:12px;padding:4px;width:max-content} -.seg{font:inherit;font-size:13px;font-weight:600;color:var(--ash);background:transparent;border:0;border-radius:9px;padding:8px 14px;cursor:pointer} -.seg.on{background:var(--obsidian);color:var(--bone)} -.seg:disabled{opacity:.4;cursor:default} -.unlock{display:flex;gap:10px;align-items:center;margin-top:6px} -.unlock input{font:inherit;color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;min-width:280px;min-height:52px} -.balance-card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:28px var(--card-pad);margin-top:28px;display:flex;flex-direction:column;gap:12px;align-items:flex-start} -.balance{display:flex;align-items:baseline;gap:12px;font-family:var(--head);font-weight:900;font-size:48px;letter-spacing:-.01em;line-height:1} -.balance .unit{font-family:var(--mono);font-size:14px;color:var(--ash);letter-spacing:.12em} -.addr-row{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--ink-2)} -.actions{display:flex;gap:12px;margin-top:6px} -.split{display:grid;grid-template-columns:1fr 1fr;gap:var(--gap);margin:var(--gap) 0} -.card+.card{margin-top:var(--gap)} -.card h3{margin-bottom:8px} -.kv{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;font-size:13px;margin-top:10px} -.kv span{color:var(--ash)} -.kv b{font-weight:500;font-family:var(--mono);word-break:break-all} -.kv b.ok{color:var(--molten)} -.kv b.warn{color:var(--ember)} -.history{margin-top:10px;display:flex;flex-direction:column} -.history .row{display:grid;grid-template-columns:90px 1fr max-content 110px;gap:14px;align-items:center;padding:10px 0;border-top:1px solid var(--line);cursor:pointer;font-size:13px} -.history .row:hover{background:rgba(255,255,255,.02)} -.history .row:first-child{border-top:0} -.history .kind{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--ash)} -.history .who{font-family:var(--mono);color:var(--ink-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.history .amt{font-family:var(--mono);font-weight:500} -.history .amt.in{color:var(--molten)} -.history .fin{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;text-align:right} -.fin.pending{color:var(--ash)}.fin.in_block{color:var(--ink-2)}.fin.final{color:var(--molten)}.fin.failed{color:var(--ember)} -.history .empty{color:var(--ash);font-size:13px;padding:8px 0} -.confirm{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);display:flex;flex-direction:column;gap:12px} -.confirm .row{display:flex;justify-content:space-between;gap:16px;font-size:14px;color:var(--ash)} -.confirm .row b{color:var(--bone);font-weight:500;text-align:right} -.confirm .row b.small{font-size:12px;word-break:break-all} -.confirm .row.total{border-top:1px solid var(--line);padding-top:12px;color:var(--ink-2)} -.qr{width:240px;height:240px;background:var(--bone);border-radius:14px;padding:8px} +/* Receive: the QR beside its line */ +.qr-row{display:flex;gap:var(--s-5);align-items:flex-start;flex-wrap:wrap;margin-top:var(--s-4)} +.qr{width:240px;height:240px;background:var(--qr-bg);border-radius:14px;padding:10px;flex:0 0 240px} .qr svg{width:100%;height:100%;display:block} -.addr-big{font-size:14px;word-break:break-all;color:var(--ink-2)} -.finality-line{font-family:var(--mono);font-size:13px;padding:12px 14px;border-radius:12px;border:1px solid var(--line);background:var(--graphite)} -.finality-line.final{border-color:rgba(255,179,92,.4);color:var(--molten)} -.finality-line.failed{border-color:rgba(242,84,27,.5);color:var(--ember)} -.inline{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:8px} -.inline input{min-width:200px} -.warn-box{margin-top:14px;border:1px solid rgba(242,84,27,.4);background:rgba(242,84,27,.06);border-radius:12px;padding:12px 14px} -.warn-box b{font-size:14px} -.danger-card{border-color:rgba(242,84,27,.35)} -.switch{display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer} -/* the miner's switch hides its input behind a .track span the wallet does not render: the box itself shows here - (fixed 5 October 2026: the three wallet switches were invisible, only their words could be clicked) */ -.switch input{position:static;opacity:1;width:18px;height:18px;margin:0;accent-color:var(--ember);flex:0 0 18px} -.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bone);color:var(--obsidian);font-family:var(--mono);font-size:13px;padding:10px 16px;border-radius:999px;z-index:30} -.step .card{margin-top:12px} -#view-settings .step{max-width:760px} +.qr-row .help{flex:1 1 240px;padding-top:var(--s-2)} -/* 5 October 2026: the version in the brand band, the coin on the balance card, Touch ID / Windows Hello controls */ -.brand .ver{font-size:11px;letter-spacing:.08em;color:var(--ash);margin-left:10px;align-self:center} -.balance-row{display:flex;align-items:center;gap:18px} -.coin.small{width:56px;height:56px;filter:drop-shadow(0 6px 18px rgba(242,84,27,.35))} -.reading{font-size:12px;color:var(--ash);letter-spacing:.04em;margin-top:8px} -.version-row{font-size:12px;color:var(--ash);letter-spacing:.06em;margin-top:-6px} -.version-row b{color:var(--ink-2);font-weight:500} -.btn.fp svg{flex:0 0 auto} -.bio-row{display:flex;flex-direction:column;align-items:center;gap:10px;margin-top:6px} -.bio-row .note{text-align:center;max-width:46ch} -.unlock-dim .unlock{opacity:.7} -#send-go .fp-ico[hidden]{display:none} -.bio-state{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;letter-spacing:.04em;color:var(--ember);min-height:18px} -.bio-state.ok{color:var(--molten)} -.bio-state.wait{color:var(--ash)} -.fp-glyph{flex:0 0 auto;color:var(--ember)} -.err .bio-state{font-family:var(--mono)} - -/* the update card (update-card.js, app.js renderUpdateCard; the miner carries the same block): the mark with its - ring, the name, one line, up to three note lines, the size, Install now and Later. Over every screen. */ -.upd-wrap{position:fixed;inset:0;z-index:35;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px;animation:fade .25s ease} +/* the update card (update-card.js, app.js renderUpdateCard; the miner carries the same block) */ +.upd-wrap{position:fixed;inset:0;z-index:35;background:var(--scrim);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px;animation:fade .25s ease} @keyframes fade{from{opacity:0}to{opacity:1}} -.upd-card{position:relative;width:100%;max-width:500px;max-height:calc(100vh - 48px);overflow:auto;background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:34px 32px 28px;display:flex;flex-direction:column;align-items:center;text-align:center;gap:8px;box-shadow:0 30px 80px rgba(0,0,0,.6),0 0 0 1px rgba(242,84,27,.08);animation:rise .3s ease;outline:none;user-select:none;-webkit-user-select:none} +.upd-card{position:relative;width:100%;max-width:500px;max-height:calc(100vh - 48px);overflow:auto;background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:34px 32px 28px;display:flex;flex-direction:column;align-items:center;text-align:center;gap:var(--s-2);box-shadow:0 30px 80px var(--shadow),0 0 0 1px rgba(242,84,27,.08);animation:rise .3s ease;outline:none} .upd-card::before{content:"";position:absolute;left:50%;top:-80px;width:360px;height:260px;transform:translateX(-50%);border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.22) 0,rgba(242,84,27,0) 62%);pointer-events:none} -.upd-mark{position:relative;width:96px;height:96px;display:flex;align-items:center;justify-content:center;margin-bottom:12px} +.upd-mark{position:relative;width:96px;height:96px;display:flex;align-items:center;justify-content:center;margin-bottom:var(--s-3)} .upd-mark img{position:relative;display:block;filter:drop-shadow(0 6px 18px rgba(242,84,27,.35))} .upd-ring{position:absolute;inset:0;transform:rotate(-90deg)} .upd-ring .track{fill:none;stroke:var(--line-2);stroke-width:3} @@ -436,63 +325,153 @@ body{user-select:text;-webkit-user-select:text} .upd-mark.none .track{stroke:var(--line)} .upd-mark.full .arc{stroke:var(--molten);stroke-dashoffset:0} .upd-mark.busy .arc{stroke-dasharray:69 207.5;stroke-dashoffset:0;animation:spin 1.1s linear infinite;transform-origin:50% 50%} +@keyframes spin{to{transform:rotate(360deg)}} .upd-mark.failed .arc{stroke:var(--ember);stroke-dashoffset:0;opacity:.55} -.upd-pct{position:absolute;left:50%;bottom:-11px;transform:translateX(-50%);font-size:11px;font-weight:500;letter-spacing:.06em;color:var(--molten);background:var(--obsidian);border:1px solid var(--line-2);border-radius:999px;padding:2px 8px;font-variant-numeric:tabular-nums} +.upd-pct{position:absolute;left:50%;bottom:-11px;transform:translateX(-50%);font-size:var(--t-xs);font-weight:500;letter-spacing:.06em;color:var(--molten);background:var(--obsidian);border:1px solid var(--line-2);border-radius:999px;padding:2px 8px;font-variant-numeric:tabular-nums} .upd-name{font-size:24px;font-weight:700;letter-spacing:-.01em;position:relative} -.upd-line{font-size:16px;color:var(--ink-2);line-height:1.4;position:relative} -.upd-cause{font-size:12px;color:var(--ember);line-height:1.5;max-width:40ch;word-break:break-word} -.upd-notes{list-style:none;margin:8px auto 0;padding:0;width:max-content;max-width:100%;display:flex;flex-direction:column;gap:6px;align-items:flex-start;font-size:14px;color:var(--bone);line-height:1.4} +.upd-line{font-size:var(--t-xl);color:var(--ink-2);line-height:1.4;position:relative} +.upd-cause{font-size:var(--t-sm);color:var(--ember);line-height:1.5;max-width:40ch;word-break:break-word} +.upd-notes{list-style:none;margin:var(--s-2) auto 0;padding:0;width:max-content;max-width:100%;display:flex;flex-direction:column;gap:6px;align-items:flex-start;font-size:var(--t-md);color:var(--bone);line-height:1.4} .upd-notes:empty{display:none} .upd-notes li{display:flex;align-items:baseline;gap:10px;text-align:left} .upd-notes li::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--ember);flex:0 0 6px;position:relative;top:-2px} -.upd-more{font:inherit;font-size:12px;font-family:var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);background:transparent;border:0;padding:4px 8px;cursor:pointer;border-radius:6px;margin-top:2px} +.upd-more{font:inherit;font-size:var(--t-sm);font-family:var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);background:transparent;border:0;padding:4px 8px;cursor:pointer;border-radius:6px;margin-top:2px} .upd-more:hover{color:var(--bone)} -.upd-all{list-style:none;margin:0;padding:10px 14px;width:100%;max-height:150px;overflow:auto;text-align:left;font-size:13px;color:var(--ink-2);line-height:1.5;background:var(--obsidian);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;gap:4px;user-select:text;-webkit-user-select:text} -.upd-meta{font-size:12px;color:var(--ash);letter-spacing:.04em;margin-top:8px;min-height:18px;line-height:1.5;max-width:44ch} +.upd-all{list-style:none;margin:0;padding:10px 14px;width:100%;max-height:150px;overflow:auto;text-align:left;font-size:var(--t-base);color:var(--ink-2);line-height:1.5;background:var(--obsidian);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;gap:4px;user-select:text;-webkit-user-select:text} +.upd-meta{font-size:var(--t-sm);color:var(--ash);letter-spacing:.04em;margin-top:var(--s-2);min-height:18px;line-height:1.5;max-width:44ch} .upd-meta:empty{display:none} -.upd-actions{display:flex;gap:12px;align-items:center;justify-content:center;flex-wrap:wrap;margin-top:16px;position:relative} +.upd-actions{display:flex;gap:var(--s-3);align-items:center;justify-content:center;flex-wrap:wrap;margin-top:var(--s-4);position:relative} .upd-actions:empty{display:none} .upd-actions .btn.primary{min-width:160px} @media (prefers-reduced-motion:reduce){.upd-wrap,.upd-card{animation:none}.upd-ring .arc{transition:none}.upd-mark.busy .arc{animation:none;stroke-dasharray:207.5 69}} -@media (max-height:620px){.upd-card{padding:24px 24px 20px}.upd-mark{width:72px;height:72px;margin-bottom:8px}.upd-mark img{width:32px;height:32px}.upd-name{font-size:20px}} +@media (max-height:620px){.upd-card{padding:24px 24px 20px}.upd-mark{width:72px;height:72px;margin-bottom:var(--s-2)}.upd-mark img{width:32px;height:32px}.upd-name{font-size:20px}} -/* ---- the lock screen (0.1.4, 5 October 2026; ui/lock-screen.js, index.html #screen-unlock) ---- - The coin large and centred on the obsidian ground with the ember glow, the name in Unbounded, the short address - in mono, one primary control. It lies over the main area (absolute, the header stays), enters in ENTER_MS (250 ms) - and the home screen leaves beneath it in the same 250 ms, so locking is a transition, not a cut. The glow breathes - slowly; reduced motion stops it. */ +/* the lock screen (0.1.4, kept; ui/lock-screen.js, index.html #screen-unlock): the mark on the ember glow, the name, + the short address, one primary control. It lies over the main area (the header stays), enters in ENTER_MS (250 ms) + and the home screen leaves beneath it in the same 250 ms. The glow breathes slowly; reduced motion stops it. */ #screen-unlock{position:absolute;inset:0;z-index:5;max-width:none;margin:0;padding:0 var(--gutter);background:var(--obsidian);overflow:auto;animation:lockin .25s ease both} body.locking #screen-unlock{display:block} body.locking #screen-home{animation:lockout .25s ease both;pointer-events:none} @keyframes lockin{from{opacity:0;transform:scale(1.015)}to{opacity:1;transform:none}} @keyframes lockout{to{opacity:0;transform:scale(.985);filter:blur(4px)}} .lock-body{min-height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:10px;padding:28px 0 36px} -.lock-coin{position:relative;width:220px;height:220px;margin-bottom:22px;flex:0 0 auto} +.lock-coin{position:relative;width:180px;height:180px;margin-bottom:22px;flex:0 0 auto;display:flex;align-items:center;justify-content:center} .lock-coin::before{content:"";position:absolute;inset:-190px;border-radius:50%;background:radial-gradient(circle,rgba(255,179,92,.10) 0,rgba(242,84,27,.05) 40%,rgba(242,84,27,0) 68%);pointer-events:none} .lock-glow{position:absolute;inset:-96px;border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.38) 0,rgba(242,84,27,.14) 36%,rgba(242,84,27,0) 66%);animation:lockbreathe 6s ease-in-out infinite;pointer-events:none} @keyframes lockbreathe{0%,100%{opacity:.75;transform:scale(1)}50%{opacity:1;transform:scale(1.06)}} -.lock-coin .coin{position:relative;width:220px;height:220px;filter:drop-shadow(0 18px 48px rgba(242,84,27,.45))} +.lock-mark{width:148px;height:148px;border-radius:32px;margin:0;box-shadow:0 18px 48px rgba(242,84,27,.45)} +.lock-mark::before{display:none} .lock-title{font-family:var(--head);font-weight:700;font-size:30px;letter-spacing:-.01em;line-height:1.1;margin-top:4px} -.lock-address{font-size:14px;color:var(--ash);letter-spacing:.06em;margin-top:2px} -/* the control area keeps one height, so "Use password" reveals the field in place and nothing above it moves */ +.lock-address{font-size:var(--t-md);color:var(--ash);letter-spacing:.06em;margin-top:2px} .lock-controls{display:flex;flex-direction:column;align-items:center;gap:10px;margin-top:22px;min-height:120px} .lock-controls.touch{min-height:200px;justify-content:flex-start} .lock-touch{display:flex;flex-direction:column;align-items:center;gap:10px} .lock-btn{min-width:272px;gap:10px} -.lock-line{min-height:20px;font-family:var(--mono);font-size:12px;letter-spacing:.04em;color:var(--ash);text-align:center;max-width:60ch;line-height:1.5;text-wrap:balance} +.lock-line{min-height:20px;font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.04em;color:var(--ash);text-align:center;max-width:60ch;line-height:1.5;text-wrap:balance} .lock-line .bio-state{display:inline} .lock-line .fp-glyph{display:inline-block;vertical-align:-3px;margin-right:6px} -.lock-link{font:inherit;font-size:13px;color:var(--ash);background:transparent;border:0;cursor:pointer;padding:6px 10px;border-radius:6px;text-decoration:underline;text-underline-offset:4px;text-decoration-color:var(--line-2);transition:color .15s ease} +.lock-link{font:inherit;font-size:var(--t-base);color:var(--ash);background:transparent;border:0;cursor:pointer;padding:6px 10px;border-radius:6px;text-decoration:underline;text-underline-offset:4px;text-decoration-color:var(--line-2);transition:color .15s ease} .lock-link:hover{color:var(--bone);text-decoration-color:var(--ash)} +.unlock{display:flex;gap:10px;align-items:center;margin-top:6px} +.unlock input{font:inherit;color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;min-width:280px;min-height:52px;user-select:text;-webkit-user-select:text} +.unlock input:focus{outline:none;border-color:var(--ember)} .lock-form{margin-top:0;animation:rise .2s ease} .lock-form input{min-width:260px} .lock-err{min-height:0} .lock-foot{margin-top:18px;opacity:.85} -@media (max-height:720px){.lock-coin,.lock-coin .coin{width:176px;height:176px}.lock-coin{margin-bottom:14px}.lock-glow{inset:-70px}.lock-coin::before{inset:-150px}.lock-title{font-size:26px}.lock-controls{margin-top:14px}.lock-body{padding:16px 0 24px}} +@media (max-height:720px){.lock-coin{width:140px;height:140px;margin-bottom:14px}.lock-mark{width:112px;height:112px;border-radius:26px}.lock-glow{inset:-70px}.lock-coin::before{inset:-150px}.lock-title{font-size:26px}.lock-controls{margin-top:14px}.lock-body{padding:16px 0 24px}} @media (prefers-reduced-motion:reduce){#screen-unlock,body.locking #screen-home,.lock-form{animation:none}.lock-glow{animation:none}} -/* Settings > Touch ID: the idle lock's choices and "Ask for Touch ID when the wallet opens" */ -.select{font:inherit;font-size:14px;color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:7px 12px;min-height:38px;cursor:pointer} -.select:focus{outline:none;border-color:var(--ember)} -.idle-row{margin-top:4px} -.idle-label{font-size:14px} +/* Touch ID lines */ +.bio-state{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.04em;color:var(--ember);min-height:18px} +.bio-state.ok{color:var(--molten)} +.bio-state.wait{color:var(--ash)} +.fp-glyph{flex:0 0 auto;color:var(--ember)} +#send-go .fp-ico[hidden]{display:none} + +.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:var(--t-base);z-index:40;box-shadow:0 10px 30px var(--shadow);max-width:min(90vw,520px);text-align:center} +body.has-rail .toast{left:calc(50% + var(--rail) / 2)} + +/* narrow windows: 1000 the rail folds to icons; 900 the row's reason goes under the word; 720 a bottom tab bar */ +@media (max-width:1000px){ + :root{--rail:76px;--gutter:var(--s-5)} + .rail{padding:12px 8px} + .rail-brand{justify-content:center;padding:6px 0 14px} + .rail-brand .word{display:none} + .nav{flex-direction:column;gap:4px;padding:8px 4px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;font-family:var(--mono);font-weight:500;text-align:center;min-height:52px;justify-content:center} + .nav.on{font-weight:500} + .nav.on::before{left:-9px} + .nav.small{min-height:44px} + .nav-dot{right:8px;top:8px;margin:0} + .rail-status{display:none} + .rail-version{text-align:center;padding:8px 0 0;font-size:10px;white-space:nowrap} + .page-sub{display:none} + .kv>div{grid-template-columns:120px auto 1fr} + .hr-main{grid-template-columns:26px minmax(140px,1fr) auto 30px} + .hr-state{grid-column:2 / 4;flex-direction:row;align-items:baseline;gap:var(--s-3)} +} +@media (max-width:860px){ + .three{grid-template-columns:1fr} + h1{font-size:40px} + .bal .v{font-size:var(--t-hero)} + .words{grid-template-columns:repeat(3,minmax(0,1fr))} + .words.small{grid-template-columns:repeat(4,minmax(0,1fr))} + .disclose-right .dim{max-width:30ch} +} +@media (max-width:720px){ + :root{--rail:0px;--gutter:var(--s-4);--bottom:64px} + body.has-rail .rail{top:auto;bottom:0;left:0;right:0;width:auto;height:64px;flex-direction:row;align-items:center;border-right:0;border-top:1px solid var(--line);padding:0 var(--s-2)} + .rail-brand{display:none} + .rail-nav{flex-direction:row;flex:1;gap:0;justify-content:space-around} + .nav{min-height:56px;padding:6px 2px;font-size:10px;border-radius:10px;flex:1;max-width:120px} + .nav.on::before{display:none} + .rail-foot{display:none} + body.has-rail .narrow-only{display:inline-flex} + body.has-rail .top,body.has-rail .notices,body.has-rail main,body.has-rail .ask-wrap{left:0} + body.has-rail main{bottom:var(--bottom)} + body.has-rail .toast{left:50%;bottom:calc(var(--bottom) + 12px)} + .ask-wrap{bottom:var(--bottom)} + .top{padding:0 var(--s-4)} + .page-title h1{font-size:17px} + .pill{min-width:0} + .bal-row{flex-direction:column;gap:4px;align-items:flex-start} + .bal .v{font-size:var(--t-h2)} + .actions .btn.big{flex:1 1 40%} + .actions .note{flex-basis:100%;margin-left:0} + .addr-big{font-size:var(--t-base)} + .hr-main{grid-template-columns:26px 1fr 30px;gap:var(--s-2) var(--s-3);padding:12px} + .hr-amt{grid-column:1 / 3;grid-row:2;justify-self:start;text-align:left} + .hr-state{grid-column:1 / -1;grid-row:3;flex-direction:column;align-items:flex-start;gap:2px} + .hr-state .why{white-space:normal} + .hr-details{padding-left:12px;padding-right:12px} + .hrow.sent-row .hr-main{grid-template-columns:26px 1fr} + .kv>div{grid-template-columns:96px 1fr;gap:4px var(--s-3)} + .kv .m{grid-column:1 / -1;white-space:normal} + .disclose-right .dim{display:none} + .node-line{white-space:normal;line-height:1.4} + .lead-row{flex-direction:column} + .srow{flex-direction:column;align-items:flex-start;gap:var(--s-2)} + .indent{margin-left:0} + .seg{width:100%} + .seg-b{flex:1;padding:6px 8px} + .step{padding:32px 0} + .words{grid-template-columns:repeat(2,minmax(0,1fr))} + .words.small{grid-template-columns:repeat(3,minmax(0,1fr))} + .checks{grid-template-columns:1fr} + .qr{width:200px;height:200px;flex-basis:200px} + .ask .short-pw{flex:1 1 160px;width:auto} + .lock-btn{min-width:0;width:100%} + .unlock input,.lock-form input{min-width:0;flex:1} + .unlock{width:100%} +} +/* short windows (the 620 px floor): tighter paddings, so the hero and the rows fit */ +@media (max-height:700px){ + :root{--card-pad:18px;--gap:var(--s-3)} + #screen-home{padding:16px 0 20px} + .hero{gap:var(--s-3);padding:var(--s-5) 0 var(--s-6)} + .mark-wrap{width:88px;height:88px} + .mark-wrap img{width:56px;height:56px} + h1{font-size:40px} + .lead{font-size:var(--t-lg)} + .step{padding:32px 0 32px} +} diff --git a/app/igneum-wallet/ui/app.js b/app/igneum-wallet/ui/app.js index 1fdb9660f..c8e1c5ef7 100644 --- a/app/igneum-wallet/ui/app.js +++ b/app/igneum-wallet/ui/app.js @@ -1,611 +1,938 @@ -// Igneum Wallet window. Talks to the engine on the same origin (the token is in the path); polls /api/state every -// 2 s; never holds a key: words and keys only pass through when the engine shows them once. +// Igneum Wallet window (wallet-ui-3, on the miner's system). Talks to the engine on the same origin (the token is in +// the path); polls /api/state every 2 s; never holds a key: words and keys only pass through when the engine shows +// them once. The first block, View, is pure (the words every page shows for a state) and is what view.test.mjs loads: +// the file is run with `module` defined and no `document`, so only View executes there. The DOM block follows. 'use strict'; -const $ = id => document.getElementById(id); -const base = location.pathname.replace(/\/$/, ''); -const api = async (path, body) => { - const r = await fetch(base + path, body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - const j = await r.json().catch(() => ({ ok: false, error: 'bad answer' })); - if (!r.ok || j.ok === false) throw new Error(j.error || ('http ' + r.status)); - return j; -}; -const post = (path, body = {}) => api(path, body); -let state = null, quote = null, sentHash = null, txHash = null, lastPhase = null; -if (location.search.includes('host=mac')) document.body.classList.add('mac'); -// ---- Touch ID / Windows Hello: the window host's bridge (app/mac/Biometric.swift; app/windows/wallet-host.cpp) ---- -// The page posts {id, op, ...}; the host answers window.__igneumBiometric(id, {ok, code, message}). The secret never -// comes this way: the host posts the password to the engine itself, and confirmations are nonces the engine issued. -const bio = (() => { - const pending = new Map(); let seq = 0; - const mac = !!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.biometric); - const win = !!(window.chrome && window.chrome.webview); - window.__igneumBiometric = (id, r) => { const p = pending.get(id); if (p) { pending.delete(id); p(r || { ok: false, code: 'bad', message: 'no answer' }); } }; - if (win) window.chrome.webview.addEventListener('message', ev => { let d = ev.data; if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) { return; } } if (d && d.id != null) window.__igneumBiometric(d.id, d); }); - return { - host: mac ? 'mac' : win ? 'windows' : null, - busy: false, - call(op, params = {}) { - return new Promise(res => { - if (!this.host) return res({ ok: false, code: 'nohost', message: what() + ' needs the Igneum Wallet app window.' }); - const id = ++seq; pending.set(id, res); this.busy = true; - const m = Object.assign({ id, op }, params); - if (mac) window.webkit.messageHandlers.biometric.postMessage(m); else window.chrome.webview.postMessage(JSON.stringify(m)); - setTimeout(() => { if (pending.has(id)) { pending.delete(id); res({ ok: false, code: 'timeout', message: what() + ' did not answer.' }); } }, 180000); - }).then(r => { this.busy = false; return r; }); +var View = (function () { + var PAGES = [ + { id: 'home', title: 'Home', sub: 'your balance, your address, the node' }, + { id: 'send', title: 'Send', sub: 'one payment, confirmed here' }, + { id: 'receive', title: 'Receive', sub: 'your address, two ways' }, + { id: 'history', title: 'History', sub: "every transaction, with the node's word" }, + { id: 'settings', title: 'Settings', sub: 'security, backup, this machine, updates, the node' } + ]; + function page(id) { for (var i = 0; i < PAGES.length; i++) if (PAGES[i].id === id) return PAGES[i]; return PAGES[0]; } + function withCommas(n) { return (Number(n) || 0).toLocaleString('en-GB'); } + function shortHex(a, n, m) { n = n || 6; m = m || 4; return a && a.length > n + m + 2 ? a.slice(0, 2 + n) + '…' + a.slice(-m) : (a || ''); } + // wei (a decimal string) to IGN with up to `places` decimals, trailing zeros cut; never scientific + function ign(wei, places) { + places = places == null ? 6 : places; + var w; try { w = BigInt(String(wei || '0').trim() || '0'); } catch (e) { return '0'; } + var neg = w < 0n; if (neg) w = -w; + var whole = w / 1000000000000000000n, frac = (w % 1000000000000000000n).toString().padStart(18, '0').slice(0, places).replace(/0+$/, ''); + return (neg ? '-' : '') + (frac ? whole + '.' + frac : String(whole)); + } + function ago(s) { s = Math.max(0, Math.round(s || 0)); return s < 60 ? s + ' s ago' : s < 3600 ? Math.floor(s / 60) + ' min ago' : s < 86400 ? Math.floor(s / 3600) + ' h ago' : Math.floor(s / 86400) + ' d ago'; } + function timeWord(t, now) { + if (!t) return ''; + var d = new Date(t * 1000), n = new Date((now || Date.now() / 1000) * 1000); + var hm = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); + var same = d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate(); + return same ? hm : d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + ' ' + hm; + } + + // ---------- the balance and its money line ---------- + // the hero shows the number only when it is known; otherwise one line says why (never a 0 for an unknown) + function balanceLine(s) { + var n = s.node || {}; + if (s.balance_known) return { known: true, text: '' }; + if (s.scanning) return { known: false, text: 'reading the chain, ' + withCommas(s.scanned_to || 0) + ' of ' + withCommas(n.block || 0) + ' blocks' }; + if (n.state === 'ok') return { known: false, text: 'reading the balance' }; + if (n.state === 'starting' || n.state === 'connecting') return { known: false, text: 'waiting for the node to start' }; + return { known: false, text: 'waiting for a node' }; + } + // money first when a market price exists (price_gbp_per_ign on the state, owed by the engine); else one grey line + function moneyLine(s) { + var p = Number(s.price_gbp_per_ign || 0); + if (p > 0 && s.balance_known) { + var v = parseFloat(s.balance || '0') * p; + return { money: '£' + v.toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 }), text: "at today's price" }; } - }; + var net = (s.settings && s.settings.network) || 'devnet'; + return { money: '', text: net === 'devnet' ? 'no market price on devnet: coins have no value' : 'no market price yet' }; + } + + // ---------- the node line ---------- + function nodeWords(n) { + switch (n.state) { + case 'ok': return { word: 'synced', tone: 'ok' }; + case 'starting': return { word: 'starting', tone: '' }; + case 'connecting': return { word: 'connecting', tone: '' }; + case 'lost': return { word: 'lost', tone: 'bad' }; + } + return { word: n.source === 'none' || !n.source ? 'not found' : 'off', tone: 'bad' }; + } + function sourceWords(src) { + switch (src) { + case 'miner': return "the miner app's node"; + case 'own': return 'the bundled node'; + case 'public': return 'the public RPC'; + case 'external': return 'the node named by the environment'; + } + return 'no node'; + } + function verifyWords(s) { + var f = s.finality || {}, n = s.node || {}; + if (f.verified_index > 0) return 'verified to checkpoint ' + withCommas(f.verified_index); + if (n.source === 'public') return 'certificates cannot be verified without a node'; + if (n.state !== 'ok') return ''; + return 'waiting for the first certificate'; + } + // "Node synced · the miner app's node · block 140,286 · verified to checkpoint 4674" + function nodeLine(s) { + var n = s.node || {}, w = nodeWords(n), parts = ['Node ' + w.word]; + if (n.source && n.source !== 'none') parts.push(sourceWords(n.source)); + if (n.block > 0) parts.push('block ' + withCommas(n.block)); + var v = verifyWords(s); if (v) parts.push(v); + var sub = n.state === 'ok' ? '' : n.state === 'lost' ? 'the node stopped answering; trying again' : (n.message || ''); + return { text: parts.join(' · '), sub: sub, tone: w.tone }; + } + // the old Node and Finality cards as rows with one-line meanings: [key, value, meaning, class] + function nodeDetails(s, now) { + var n = s.node || {}, f = s.finality || {}, rows = []; + rows.push(['source', sourceWords(n.source), n.message || '', n.state === 'ok' ? 'ok' : 'warn']); + rows.push(['chain', n.chain || (n.chain_id ? 'chain id ' + n.chain_id : 'not read yet'), n.chain_id ? 'chain id ' + n.chain_id : 'the chain the node follows', n.chain ? '' : 'dim']); + rows.push(['block', n.block > 0 ? withCommas(n.block) : 'none yet', 'the newest block this node holds', n.block > 0 ? '' : 'dim']); + rows.push(['endpoint', n.evm || 'none yet', 'where this wallet reads balances and sends', n.evm ? '' : 'dim']); + rows.push(['finality on the node', n.source === 'public' ? 'not checkable without a node' : n.finality_active ? 'active, locked ' + withCommas(n.latest_locked) : 'paused', n.source === 'public' ? 'the public RPC carries no certificates' : 'what the node says; the wallet checks for itself below', n.finality_active ? 'ok' : 'dim']); + if (f.verified_index > 0) { + rows.push(['verified here', 'checkpoint ' + withCommas(f.verified_index), 'the newest certificate this wallet checked itself', 'ok']); + rows.push(['signed by', f.signers + ' of ' + f.voters + ' voters', ((f.fraction_total || 0) * 100).toFixed(1) + '% of all weight; two thirds is the rule', '']); + rows.push(['checkpoint height', f.chain_number == null ? 'pending' : withCommas(f.chain_number), 'blocks at or under it are final', f.chain_number == null ? 'dim' : '']); + rows.push(['weights', f.weights_exact ? 'taken at the checkpoint' : 'the latest table', 'the voter weights the signature was checked against', '']); + rows.push(['checked', f.verified_at ? ago((now || Date.now() / 1000) - f.verified_at) : 'not yet', '', '']); + } else { + rows.push(['verified here', 'nothing yet', f.message || 'waiting', 'dim']); + } + return rows; + } + // the pill: the wallet's own words + function pillWords(s) { + if (s.quitting) return { text: 'stopping', cls: '' }; + if (s.phase === 'unlock') return { text: 'locked', cls: '' }; + var n = s.node || {}; + if (n.state === 'ok') return { text: n.source === 'public' ? 'public rpc' : 'synced', cls: 'on' }; + if (n.state === 'starting' || n.state === 'connecting') return { text: n.state, cls: '' }; + if (n.state === 'lost') return { text: 'node lost', cls: 'warn' }; + return { text: 'no node', cls: 'warn' }; + } + + // ---------- the history row ---------- + function kindWord(e) { return { sent: 'Sent', received: 'Received', self: 'To yourself', reward: 'Reward', proving: 'Proving payout' }[e.kind] || e.kind; } + function incoming(e) { return e.kind === 'received' || e.kind === 'reward' || e.kind === 'proving'; } + function synthetic(e) { return /^(reward|proving)-/.test(e.hash || ''); } + function whoLine(e) { + if (e.kind === 'reward') return 'block reward'; + if (e.kind === 'proving') return 'shard payout'; + if (e.kind === 'self') return 'to yourself'; + return incoming(e) ? 'from ' + shortHex(e.from) : 'to ' + shortHex(e.to); + } + // ONE state word per row, never stronger than the chain's own (ledger P17): the wallet's own verified "final" wins + // as finalised; failed from the receipt; else the node's word (igneum_getTransactionStatus); else the wallet's label + function stateWord(e, nodeState) { + var blk = e.block > 0 ? 'block ' + withCommas(e.block) : 'a block'; + if (e.finality === 'final') return { word: 'finalised', tone: 'ok', why: 'under checkpoint ' + withCommas(e.checkpoint || 0) + ', verified here' }; + if (e.finality === 'failed') return { word: 'failed', tone: 'bad', why: 'the execution failed; the fee was still paid' }; + if (synthetic(e)) return e.finality === 'in_block' ? { word: 'executed', tone: 'ink', why: 'in ' + blk } : { word: 'pending', tone: '', why: 'waiting for a block' }; + switch (nodeState) { + case 'pending': return { word: 'pending', tone: '', why: 'waiting for a block' }; + case 'included': return { word: 'included', tone: '', why: 'in ' + blk + ', not executed yet' }; + case 'executed': return { word: 'executed', tone: 'ink', why: 'in ' + blk }; + case 'proven': return { word: 'proven', tone: 'ink', why: 'in ' + blk + ', proof paid' }; + case 'finalised': return { word: 'finalised', tone: 'ink', why: 'under a locked checkpoint, not yet verified here' }; + case 'finality not active': return { word: 'finality not active', tone: 'ink', why: 'under a locked checkpoint; finality is paused on the network' }; + case 'unknown': return { word: 'pending', tone: '', why: 'not in any block the node holds' }; + } + if (e.finality === 'in_block') return { word: 'included', tone: '', why: 'in ' + blk }; + return { word: 'pending', tone: '', why: 'waiting for a block' }; + } + function rowModel(e, nodeState, now) { + var inn = incoming(e), st = stateWord(e, nodeState); + return { hash: e.hash, kind: kindWord(e), who: whoLine(e), when: timeWord(e.time, now), amount: (inn ? '+' : '−') + ign(e.value, 6), in: inn, state: st, synthetic: synthetic(e), nodeWord: nodeState || '' }; + } + // the rows the page asks the node about: not final, not failed, not a reward; newest first, at most `max` + function needsNodeWord(list, max) { + var out = []; + for (var i = 0; i < (list || []).length && out.length < (max || 12); i++) { + var e = list[i]; + if (e.finality === 'final' || e.finality === 'failed' || synthetic(e)) continue; + out.push(e.hash); + } + return out; + } + + // ---------- send ---------- + function gwei(weiPerGas) { var v = Number(weiPerGas || 0) / 1e9; return (v >= 10 ? Math.round(v) : +v.toFixed(v >= 1 ? 1 : 3)) + ' gwei'; } + function feeWords(q) { + if (!q) return { line: 'shown when you review', detail: '' }; + return { + line: 'at most ' + q.fee_max_ign + ' IGN', + detail: 'Gas ' + withCommas(q.gas) + ' at a base fee of ' + q.base_fee_gwei + ' gwei (burned) plus a ' + q.tip_gwei + ' gwei tip (to the miner), capped at ' + gwei(q.max_fee) + '; what is not used comes back.' + }; + } + function sendAsk(q, touch, bioName) { + return { + text: 'Send ' + q.value_ign + ' IGN to ' + shortHex(q.display_to || q.to) + '? Fee at most ' + q.fee_max_ign + ' IGN; ' + q.total_max_ign + ' IGN leaves the wallet at most.', + button: touch ? 'Confirm with ' + (bioName || 'Touch ID') : 'Send now' + }; + } + + // ---------- settings ---------- + function bioSentence(b, what, host) { + b = b || {}; + if (b.enrolled) return what + ' is on. It unlocks the wallet, confirms each send and shows the backup; the password still works everywhere.'; + if (!host) return what + ' needs the Igneum Wallet app window; this page is open in a browser.'; + if (!b.available) return b.message || (what + (what === 'Touch ID' ? ' is not set up on this Mac.' : ' is not set up on this PC.')); + return 'Unlock, confirm each send and show the backup with ' + what + '. The password still works everywhere.'; + } + function idleSentence(min, what) { return Number(min) > 0 ? 'The key is cleared after ' + LockScreenIdle(min) + ' without you; ' + what + ' or the password opens it again.' : 'The wallet stays open until you lock it.'; } + function LockScreenIdle(min) { var m = Number(min) || 0; return m <= 0 ? 'never' : m === 60 ? '1 hour' : m % 60 === 0 ? (m / 60) + ' hours' : m === 1 ? '1 minute' : m + ' minutes'; } + + // ---------- updates: the strip line and the card's note ---------- + function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; } + function updateLine(u) { + var v = 'Igneum Wallet ' + u.version; + if (u.urgent && u.urgent_text) return { text: u.urgent_text + (u.status === 'downloading' ? ' Downloading.' : ''), urgent: true, prog: u.status === 'downloading' }; + switch (u.status) { + case 'available': return { text: v + ' is available. Downloading it.' }; + case 'downloading': return { text: 'Downloading ' + v + (u.size ? ' (' + Math.round(u.size / 1e6) + ' MB)' : '') + ': ' + Math.round((u.progress || 0) * 100) + '%', prog: true }; + case 'staging': return { text: v + ' downloaded and verified. Preparing it.' }; + case 'ready': return { text: v + ' is ready. ' + (u.wait ? cap(u.wait) + '.' : 'It installs as soon as nothing is being sent.'), install: true }; + case 'applying': return { text: 'Installing ' + v + ': ' + (u.wait ? u.wait + '.' : 'the app closes and opens again by itself.') }; + case 'deferred': return { text: v + ' is downloaded. Windows asked for permission and nobody answered; it installs the next time someone is at this PC.', install: true }; + case 'manual': return { text: v + ' is downloaded. ' + cap(u.wait || 'open the download and drag the app over the old one.'), open: true }; + case 'error': return { text: 'Update: ' + (u.error || 'failed') + '.', install: !!(u.ready || u.downloaded) }; + } + return null; + } + // the update card on Settings: "up to date, checked 13 min ago" + function updateNote(u, now) { + var l = updateLine(u), parts = []; + if (u.updated_from) parts.push('Updated from ' + u.updated_from + '.'); + if (u.rolled_back) parts.push('Rolled back: ' + u.rolled_back + '.'); + if (u.status === 'off') parts.push('Updates are off in this build.'); + else if (l && !(u.rolled_back && u.status === 'error')) parts.push(l.text); + else if (u.status === 'checking') parts.push('Checking.'); + else if (u.status === 'current') parts.push('Up to date' + (u.checked_at ? ', checked ' + ago((now || Date.now() / 1000) - u.checked_at) : '') + '.'); + else if (u.error) parts.push(u.error); + else parts.push('Not checked yet.'); + return parts.join(' '); + } + + return { PAGES: PAGES, page: page, withCommas: withCommas, shortHex: shortHex, ign: ign, ago: ago, timeWord: timeWord, balanceLine: balanceLine, moneyLine: moneyLine, nodeWords: nodeWords, sourceWords: sourceWords, verifyWords: verifyWords, nodeLine: nodeLine, nodeDetails: nodeDetails, pillWords: pillWords, kindWord: kindWord, whoLine: whoLine, stateWord: stateWord, rowModel: rowModel, needsNodeWord: needsNodeWord, gwei: gwei, feeWords: feeWords, sendAsk: sendAsk, bioSentence: bioSentence, idleSentence: idleSentence, updateLine: updateLine, updateNote: updateNote, cap: cap }; })(); -function what() { const k = state && state.biometric && state.biometric.kind; return k === 'hello' || (!k && /Win/.test(navigator.platform)) ? 'Windows Hello' : 'Touch ID'; } -// the page's own line after the system prompt: the glyph in ember and what happened, in our words -const FP = ''; -function bioLine(r, okText) { - if (!r) return ''; - if (r.ok) return `${FP}${esc(what() + ' ' + (okText || 'confirmed'))}`; - const w = what(), c = r.code; - let t; - if (c === 'cancelled' || c === 'fallback') t = w + ' cancelled, enter your password'; - else if (c === 'failed') t = w + ' did not match, try again or enter your password'; - else if (c === 'locked') t = w + ' is locked, enter your password'; - else if (c === 'invalidated') t = w + ' was turned off (a fingerprint changed), enter your password and turn it on again in Settings'; - else if (c === 'not_set_up' || c === 'unavailable') t = r.message || (w + ' is not set up on this Mac'); - else if (c === 'nohost') t = w + ' needs the Igneum Wallet app window'; - else t = r.message || (w + ' did not succeed'); - return `${FP}${esc(t)}`; -} -function setLine(el, html) { el.innerHTML = html; } -// ?bio=touch: the enrolled look without a host (screenshots); a tap then gets the "needs the app window" line -const forcedBio = new URLSearchParams(location.search).get('bio') === 'touch'; -function hostHere() { return !!bio.host || forcedBio; } +if (typeof module === 'object' && module && module.exports) { module.exports = { View: View }; } -// ---- the lock screen (ui/lock-screen.js): the sheet on a tap, once by itself on the first arrival ---- -// firstPhase: the first phase this page saw ('unlock' means the app opened locked); autoMem.used: the one chance went -let firstPhase = null, locking = false, autoTimer = 0, pwRevealed = false; -const autoMem = { used: false }; -const reducedMotion = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); -function lockLayout() { - const s = forcedBio && state ? Object.assign({}, state, { biometric: Object.assign({}, state.biometric, { enrolled: true, available: true, kind: 'touchid' }) }) : state; - return LockScreen.layout(s, hostHere()); -} -function renderLock() { - const lay = lockLayout(); - $('unlock-address').textContent = lay.address; - $('unlock-touch-text').textContent = 'Unlock with ' + what(); - $('unlock-bio').hidden = !lay.touch; - document.querySelector('.lock-controls').classList.toggle('touch', lay.touch); - const showForm = !lay.touch || pwRevealed; - $('unlock-form').hidden = !showForm; - $('unlock-use-pw').hidden = !lay.touch || pwRevealed; -} -function showPasswordField() { - pwRevealed = true; renderLock(); - $('unlock-pw').focus({ preventScroll: true }); -} -function hidePasswordField() { - if (!lockLayout().touch) return; - pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; renderLock(); - $('unlock-touch').focus({ preventScroll: true }); -} -function focusLock() { - const lay = lockLayout(); - if (lay.touch && !pwRevealed) $('unlock-touch').focus({ preventScroll: true }); else $('unlock-pw').focus({ preventScroll: true }); -} -// the home screen leaves and the lock screen enters together, ENTER_MS (250 ms); then the phase flips -function lockTransition() { - locking = true; document.body.classList.add('locking'); $('main').scrollTop = 0; - pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; setLine($('unlock-bio-note'), ''); renderLock(); - setTimeout(() => { locking = false; document.body.classList.remove('locking'); setPhase('unlock'); onLockScreen('lock'); }, reducedMotion() ? 0 : LockScreen.ENTER_MS); -} -// the lock screen is up: reason is arrival (the page opened on it), lock (idle or by hand) or focus -function onLockScreen(reason) { - clearTimeout(autoTimer); - if (reason !== 'lock') { pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; setLine($('unlock-bio-note'), ''); } - renderLock(); focusLock(); - const lay = lockLayout(); - const d = LockScreen.autoPrompt({ reason, phase: 'unlock', touch: lay.touch, askOnOpen: !!(state.settings && state.settings.ask_on_open), hidden: document.hidden, busy: bio.busy, firstPhase, updatedFrom: state.update && state.update.updated_from }, autoMem); - if (reason === 'arrival') autoMem.used = true; - if (d.prompt) autoTimer = setTimeout(() => { if (state && state.phase === 'unlock' && !document.hidden && !bio.busy && !pwRevealed) unlockWithTouch(); }, (reducedMotion() ? 0 : LockScreen.ENTER_MS) + d.delay); -} -async function unlockWithTouch() { - if (bio.busy) return; - clearTimeout(autoTimer); - $('unlock-touch').disabled = true; setLine($('unlock-bio-note'), `${FP}${esc('Waiting for ' + what())}`); - const r = await bio.call('unlock'); - $('unlock-touch').disabled = false; - const l = LockScreen.line(r, what()); - setLine($('unlock-bio-note'), l.text ? `${FP}${esc(l.text)}` : ''); - if (r.ok) { await poll(); return; } - // a cancel or a miss: the button again, never another sheet by itself; the password when the sheet cannot help - if (l.password) showPasswordField(); else $('unlock-touch').focus({ preventScroll: true }); -} -$('unlock-use-pw').onclick = showPasswordField; -$('unlock-pw').addEventListener('keydown', e => { if (e.key === 'Escape') { e.preventDefault(); hidePasswordField(); } }); -// the idle lock: the window tells the engine a person is here, every 20 s while there is input -let active = false; -['mousemove', 'keydown', 'mousedown', 'wheel', 'touchstart'].forEach(e => document.addEventListener(e, () => { active = true; }, { passive: true })); -setInterval(() => { if (active && state && state.phase === 'home') { active = false; post('/api/activity').catch(() => {}); } }, 20000); - -function toast(text) { const t = $('toast'); t.textContent = text; t.hidden = false; clearTimeout(t._h); t._h = setTimeout(() => { t.hidden = true; }, 1800); } -function copy(text, what) { navigator.clipboard.writeText(text).then(() => toast((what || 'copied') + ' to the clipboard'), () => toast('could not copy')); } -function ign(wei, places = 6) { - const w = BigInt(wei || 0); const whole = w / 10n ** 18n; let frac = (w % 10n ** 18n).toString().padStart(18, '0').slice(0, places).replace(/0+$/, ''); - return frac ? `${whole}.${frac}` : `${whole}`; -} -function short(a) { return a ? a.slice(0, 8) + '…' + a.slice(-6) : ''; } -function when(t) { if (!t) return ''; const d = new Date(t * 1000); return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); } -function setPhase(p) { document.body.dataset.phase = p; } -function setView(v) { document.body.dataset.view = v; window.scrollTo(0, 0); $('main').scrollTop = 0; } -function kv(el, rows) { el.innerHTML = rows.map(([k, v, cls]) => `${k}${v}`).join(''); } -const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); - -// ---- state ---- -async function poll() { - try { state = await api('/api/state'); render(); } catch (e) { $('pill-text').textContent = 'engine gone'; $('pill').className = 'pill warn'; } -} -function render() { - const s = state; - const phase = s.phase; - const inFlow = ['create', 'import'].includes(document.body.dataset.phase); - if (firstPhase === null) firstPhase = phase; - if ((!inFlow || phase === 'home') && lastPhase !== phase && !locking) { - if (lastPhase === 'home' && phase === 'unlock') lockTransition(); - else { setPhase(phase); if (phase === 'home') setView('overview'); if (phase === 'unlock') onLockScreen(lastPhase === null ? 'arrival' : 'lock'); } +if (typeof document !== 'undefined') (function () { + var $ = function (id) { return document.getElementById(id); }; + var base = location.pathname.replace(/\/$/, ''); + var api = async function (path, body) { + var r = await fetch(base + path, body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + var j = await r.json().catch(function () { return { ok: false, error: 'bad answer' }; }); + if (!r.ok || j.ok === false) throw new Error(j.error || ('http ' + r.status)); + return j; + }; + var post = function (path, body) { return api(path, body || {}); }; + var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; + var state = null, quote = null, sentHash = null, lastPhase = null, page = 'home', firstHome = true; + var params = new URLSearchParams(location.search); + if (params.get('host') === 'mac') document.body.classList.add('mac'); + function stored(k) { try { return localStorage.getItem(k); } catch (e) { return null; } } + function store(k, v) { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch (e) { /* private mode */ } } + function setText(id, t) { var el = $(id); if (el && el.textContent !== t) el.textContent = t; } + function toast(text) { var t = $('toast'); t.textContent = text; t.hidden = false; clearTimeout(t._h); t._h = setTimeout(function () { t.hidden = true; }, 1800); } + function copyText(text, what) { + if (!text) { toast('Nothing to copy yet'); return; } + var done = function () { toast((what || 'Copied') + (what ? ' copied' : '')); }; + if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, function () { fallbackCopy(text, done); }); else fallbackCopy(text, done); } - lastPhase = phase; - $('ver').textContent = 'v' + s.version; - $('btn-settings').hidden = phase !== 'home'; - $('btn-lock').hidden = phase !== 'home'; - // pill - const n = s.node; - let pillText = 'no node', pillCls = 'pill warn'; - if (n.state === 'ok') { pillText = `${n.source} node · block ${n.block.toLocaleString()}`; pillCls = 'pill on'; } - else if (n.state === 'starting' || n.state === 'connecting') { pillText = n.state; pillCls = 'pill'; } - else if (n.source === 'public') { pillText = 'public rpc'; pillCls = 'pill'; } - if (s.quitting) { pillText = 'stopping'; pillCls = 'pill'; } - $('pill-text').textContent = pillText; $('pill').className = pillCls; - $('welcome-eyebrow').textContent = `${s.settings.network === 'devnet' ? 'devnet v4' : s.settings.network} · nothing is bought or sold`; - if (forcedUpdate) s.update = sampleUpdate(forcedUpdate); - renderUpdate(s); - renderUpdateCard(s); - // the lock screen: the fingerprint button when enrolled and the app window is the host, else the password field - if (phase === 'unlock' || locking) renderLock(); - // home - $('balance').textContent = s.balance_known ? s.balance : '0'; - $('balance').classList.toggle('dim', !s.balance_known); - const note = $('balance-note'); - if (s.scanning) { note.textContent = `reading the chain, ${(s.scanned_to == null ? 0 : s.scanned_to).toLocaleString()} of ${n.block.toLocaleString()} blocks`; note.hidden = false; } - else if (!s.balance_known) { note.textContent = n.state === 'ok' ? 'reading the balance' : 'waiting for a node'; note.hidden = false; } - else note.hidden = true; - $('home-address').textContent = s.display; - $('backup-note').hidden = s.backed_up; - kv($('node-kv'), [ - ['source', esc(n.source === 'miner' ? 'the miner app' : n.source === 'own' ? 'bundled node' : n.source === 'public' ? 'public RPC' : n.source), n.state === 'ok' ? 'ok' : 'warn'], - ['chain', esc(n.chain || (n.chain_id ? 'id ' + n.chain_id : '…'))], - ['block', n.block.toLocaleString()], - ['finality', n.finality_active ? `active, locked ${n.latest_locked}` : (n.source === 'public' ? 'not checkable without a node' : 'paused')], - ['note', esc(n.message)], - ]); - const f = s.finality; - kv($('fin-kv'), f.verified_index ? [ - ['verified here', `checkpoint ${f.verified_index}`, 'ok'], - ['signed', `${f.signers} of ${f.voters} voters, ${(f.fraction_total * 100).toFixed(1)}% of weight`], - ['chain height', f.chain_number == null ? 'pending' : f.chain_number.toLocaleString()], - ['weights', f.weights_exact ? 'at the checkpoint' : 'latest table'], - ['checked', when(f.verified_at)], - ] : [['status', esc(f.message || 'waiting'), 'warn']]); - $('scan-note').textContent = s.scanning ? `· reading blocks (${s.scanned_to == null ? 0 : s.scanned_to.toLocaleString()} of ${n.block.toLocaleString()})` : ''; - renderHistory(s.history); - // settings - $('start-login').checked = !!s.settings.start_at_login; - renderBio(s); - kv($('settings-node-kv'), [['source', esc(n.source)], ['ethereum rpc', esc(n.evm || 'none')], ['grpc', esc(n.grpc || 'none')], ['chain id', n.chain_id || '…'], ['network', esc(s.settings.network)], ['public rpc', esc(s.public_rpc || 'none in this build')]]); - kv($('machine-kv'), [['machine', esc(s.machine_id.slice(0, 8))], ['version', esc(s.version)], ['logs', esc(s.log_dir)], ['miner key file', s.miner_wallet_present ? 'present' : 'none']]); - $('seg-miner').disabled = !s.miner_wallet_present; - if (document.body.dataset.view === 'tx' && txHash) renderTx(); -} -function renderHistory(list) { - const el = $('history'); - if (!list || !list.length) { el.innerHTML = `
${state.scanning ? 'Reading the chain.' : 'Nothing yet. Receive IGN, or point the miner app at this address.'}
`; return; } - el.innerHTML = list.slice(0, 60).map(e => { - const incoming = e.kind === 'received' || e.kind === 'reward' || e.kind === 'proving'; - const who = e.kind === 'reward' ? 'block reward' : e.kind === 'proving' ? 'shard payout' : e.kind === 'self' ? 'to yourself' : incoming ? 'from ' + short(e.from) : 'to ' + short(e.to); - const fin = e.finality === 'final' ? `final · cp ${e.checkpoint}` : e.finality === 'in_block' ? `in block ${e.block}` : e.finality; - return `
${esc(e.kind)}${esc(who)} · ${when(e.time)}${incoming ? '+' : '−'}${ign(e.value)} IGN${esc(fin)}
`; - }).join(''); - el.querySelectorAll('.row').forEach(r => r.addEventListener('click', () => openTx(r.dataset.hash))); -} + function fallbackCopy(text, done) { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); done(); } catch (e) { toast('Select and copy it by hand'); } document.body.removeChild(ta); } + document.addEventListener('click', function (e) { + var b = e.target.closest('[data-copy],[data-copy-text]'); if (!b) return; + if (b.dataset.copyText != null) { copyText(b.dataset.copyText, b.dataset.copyWhat || ''); return; } + var el = $(b.getAttribute('data-copy')), t = el ? el.textContent : ''; + if (!t || /^(not set|none yet|not read)/.test(t)) { toast('Nothing to copy yet'); return; } + copyText(t, b.dataset.copyWhat || ''); + }); -// ---- create ---- -$('go-create').onclick = () => { setPhase('create'); $('create-1').hidden = false; $('create-2').hidden = true; $('create-3').hidden = true; $('create-pw').focus(); }; -$('create-back').onclick = () => setPhase('welcome'); -let words = []; -$('create-next').onclick = async () => { - $('create-err').textContent = ''; - const a = $('create-pw').value, b = $('create-pw2').value; - if (a.length < 8) return $('create-err').textContent = 'at least 8 characters'; - if (a !== b) return $('create-err').textContent = 'the two passwords differ'; - try { - const r = await post('/api/create', { password: a }); - words = r.words; - $('words').innerHTML = words.map(w => `
  • ${esc(w)}
  • `).join(''); - $('create-address').textContent = r.display; - $('create-1').hidden = true; $('create-2').hidden = false; - } catch (e) { $('create-err').textContent = e.message; } -}; -$('create-written').onclick = () => { - const picks = []; while (picks.length < 3) { const p = 1 + Math.floor(Math.random() * 24); if (!picks.includes(p)) picks.push(p); } - picks.sort((a, b) => a - b); - $('checks').innerHTML = picks.map(p => ``).join(''); - $('words').innerHTML = ''; words = []; - $('create-2').hidden = true; $('create-3').hidden = false; - $('checks').querySelector('input').focus(); -}; -$('create-again').onclick = () => { setPhase('create'); $('create-3').hidden = true; $('create-1').hidden = false; $('confirm-err').textContent = 'start again: the words are made fresh each time'; }; -$('create-confirm').onclick = async () => { - $('confirm-err').textContent = ''; - const checks = [...$('checks').querySelectorAll('input')].map(i => ({ position: Number(i.dataset.pos), word: i.value.trim() })); - try { await post('/api/create/confirm', { checks }); $('checks').innerHTML = ''; await poll(); setPhase('home'); setView('overview'); toast('wallet ready'); } - catch (e) { $('confirm-err').textContent = e.message; } -}; + // ---------- Touch ID / Windows Hello: the window host's bridge (unchanged from 0.1.4) ---------- + var bio = (function () { + var pending = new Map(), seq = 0; + var mac = !!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.biometric); + var win = !!(window.chrome && window.chrome.webview); + window.__igneumBiometric = function (id, r) { var p = pending.get(id); if (p) { pending.delete(id); p(r || { ok: false, code: 'bad', message: 'no answer' }); } }; + if (win) window.chrome.webview.addEventListener('message', function (ev) { var d = ev.data; if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) { return; } } if (d && d.id != null) window.__igneumBiometric(d.id, d); }); + return { + host: mac ? 'mac' : win ? 'windows' : null, + busy: false, + call: function (op, p) { + var self = this; + return new Promise(function (res) { + if (!self.host) return res({ ok: false, code: 'nohost', message: what() + ' needs the Igneum Wallet app window.' }); + var id = ++seq; pending.set(id, res); self.busy = true; + var m = Object.assign({ id: id, op: op }, p || {}); + if (mac) window.webkit.messageHandlers.biometric.postMessage(m); else window.chrome.webview.postMessage(JSON.stringify(m)); + setTimeout(function () { if (pending.has(id)) { pending.delete(id); res({ ok: false, code: 'timeout', message: what() + ' did not answer.' }); } }, 180000); + }).then(function (r) { self.busy = false; return r; }); + } + }; + })(); + function what() { var k = state && state.biometric && state.biometric.kind; return k === 'hello' || (!k && /Win/.test(navigator.platform)) ? 'Windows Hello' : 'Touch ID'; } + var FP = ''; + function bioLine(r, okText) { + if (!r) return ''; + if (r.ok) return '' + FP + esc(what() + ' ' + (okText || 'confirmed')) + ''; + var w = what(), c = r.code, t; + if (c === 'cancelled' || c === 'fallback') t = w + ' cancelled, enter your password'; + else if (c === 'failed') t = w + ' did not match, try again or enter your password'; + else if (c === 'locked') t = w + ' is locked, enter your password'; + else if (c === 'invalidated') t = w + ' was turned off (a fingerprint changed), enter your password and turn it on again in Settings'; + else if (c === 'not_set_up' || c === 'unavailable') t = r.message || (w + ' is not set up on this Mac'); + else if (c === 'nohost') t = w + ' needs the Igneum Wallet app window'; + else t = r.message || (w + ' did not succeed'); + return '' + FP + esc(t) + ''; + } + function setLine(el, html) { el.innerHTML = html; } + var forcedBio = params.get('bio') === 'touch'; + function hostHere() { return !!bio.host || forcedBio; } -// ---- import ---- -let importMode = 'seed'; -$('go-import').onclick = () => { setPhase('import'); }; -$('import-back').onclick = () => setPhase('welcome'); -$('import-mode').querySelectorAll('.seg').forEach(b => b.onclick = () => { - importMode = b.dataset.mode; - $('import-mode').querySelectorAll('.seg').forEach(x => x.classList.toggle('on', x === b)); - $('import-data-field').hidden = importMode === 'miner'; - $('import-miner-note').hidden = importMode !== 'miner'; - $('import-data-label').textContent = importMode === 'seed' ? 'The words, in order' : 'The private key, 64 hex characters'; -}); -$('import-go').onclick = async () => { - $('import-err').textContent = ''; - const a = $('import-pw').value, b = $('import-pw2').value; - if (a.length < 8) return $('import-err').textContent = 'at least 8 characters'; - if (a !== b) return $('import-err').textContent = 'the two passwords differ'; - try { await post('/api/import', { mode: importMode, data: $('import-data').value, password: a }); $('import-data').value = ''; await poll(); setPhase('home'); setView('overview'); toast('imported'); } - catch (e) { $('import-err').textContent = e.message; } -}; + // ---------- the lock screen (ui/lock-screen.js, 0.1.4, unchanged) ---------- + var firstPhase = null, locking = false, autoTimer = 0, pwRevealed = false; + var autoMem = { used: false }; + var reducedMotion = function () { return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); }; + function lockLayout() { + var s = forcedBio && state ? Object.assign({}, state, { biometric: Object.assign({}, state.biometric, { enrolled: true, available: true, kind: 'touchid' }) }) : state; + return LockScreen.layout(s, hostHere()); + } + function renderLock() { + var lay = lockLayout(); + $('unlock-address').textContent = lay.address; + $('unlock-touch-text').textContent = 'Unlock with ' + what(); + $('unlock-bio').hidden = !lay.touch; + document.querySelector('.lock-controls').classList.toggle('touch', lay.touch); + var showForm = !lay.touch || pwRevealed; + $('unlock-form').hidden = !showForm; + $('unlock-use-pw').hidden = !lay.touch || pwRevealed; + } + function showPasswordField() { pwRevealed = true; renderLock(); $('unlock-pw').focus({ preventScroll: true }); } + function hidePasswordField() { if (!lockLayout().touch) return; pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; renderLock(); $('unlock-touch').focus({ preventScroll: true }); } + function focusLock() { var lay = lockLayout(); if (lay.touch && !pwRevealed) $('unlock-touch').focus({ preventScroll: true }); else $('unlock-pw').focus({ preventScroll: true }); } + function lockTransition() { + locking = true; document.body.classList.add('locking'); $('main').scrollTop = 0; + pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; setLine($('unlock-bio-note'), ''); renderLock(); + setTimeout(function () { locking = false; document.body.classList.remove('locking'); setPhase('unlock'); onLockScreen('lock'); }, reducedMotion() ? 0 : LockScreen.ENTER_MS); + } + function onLockScreen(reason) { + clearTimeout(autoTimer); + if (reason !== 'lock') { pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; setLine($('unlock-bio-note'), ''); } + renderLock(); focusLock(); + var lay = lockLayout(); + var d = LockScreen.autoPrompt({ reason: reason, phase: 'unlock', touch: lay.touch, askOnOpen: !!(state.settings && state.settings.ask_on_open), hidden: document.hidden, busy: bio.busy, firstPhase: firstPhase, updatedFrom: state.update && state.update.updated_from }, autoMem); + if (reason === 'arrival') autoMem.used = true; + if (d.prompt) autoTimer = setTimeout(function () { if (state && state.phase === 'unlock' && !document.hidden && !bio.busy && !pwRevealed) unlockWithTouch(); }, (reducedMotion() ? 0 : LockScreen.ENTER_MS) + d.delay); + } + async function unlockWithTouch() { + if (bio.busy) return; + clearTimeout(autoTimer); + $('unlock-touch').disabled = true; setLine($('unlock-bio-note'), '' + FP + esc('Waiting for ' + what()) + ''); + var r = await bio.call('unlock'); + $('unlock-touch').disabled = false; + var l = LockScreen.line(r, what()); + setLine($('unlock-bio-note'), l.text ? '' + FP + esc(l.text) + '' : ''); + if (r.ok) { await poll(); return; } + if (l.password) showPasswordField(); else $('unlock-touch').focus({ preventScroll: true }); + } + $('unlock-use-pw').onclick = showPasswordField; + $('unlock-pw').addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); hidePasswordField(); } }); + $('unlock-touch').onclick = function () { unlockWithTouch(); }; + $('unlock-form').onsubmit = async function (ev) { + ev.preventDefault(); $('unlock-err').textContent = ''; + try { await post('/api/unlock', { password: $('unlock-pw').value }); $('unlock-pw').value = ''; await poll(); } + catch (e) { $('unlock-err').textContent = e.message; } + }; + // the idle lock: the window tells the engine a person is here, every 20 s while there is input + var active = false; + ['mousemove', 'keydown', 'mousedown', 'wheel', 'touchstart'].forEach(function (e) { document.addEventListener(e, function () { active = true; }, { passive: true }); }); + setInterval(function () { if (active && state && state.phase === 'home') { active = false; post('/api/activity').catch(function () {}); } }, 20000); -// ---- unlock / lock ---- -$('unlock-form').onsubmit = async ev => { - ev.preventDefault(); $('unlock-err').textContent = ''; - try { await post('/api/unlock', { password: $('unlock-pw').value }); $('unlock-pw').value = ''; await poll(); } - catch (e) { $('unlock-err').textContent = e.message; } -}; -$('btn-lock').onclick = async () => { await post('/api/lock'); await poll(); }; -$('btn-settings').onclick = () => setView('settings'); -$('settings-back').onclick = () => setView('overview'); -$('copy-address').onclick = () => copy(state.display, 'address'); -$('backup-link').onclick = ev => { ev.preventDefault(); setView('settings'); $('backup-pw').focus(); }; + // ---------- appearance: system, light or dark (the page's own storage) ---------- + function applyTheme(t, keep) { + t = t === 'light' || t === 'dark' ? t : 'system'; + if (t === 'system') document.documentElement.removeAttribute('data-theme'); else document.documentElement.setAttribute('data-theme', t); + document.querySelectorAll('#theme-seg .seg-b').forEach(function (b) { var on = b.dataset.theme === t; b.classList.toggle('on', on); b.setAttribute('aria-checked', on ? 'true' : 'false'); }); + if (keep) store('igneum.wallet.theme', t === 'system' ? null : t); + } + // ?theme=light|dark shows a scheme without storing it (screenshots); otherwise the page's own stored choice + applyTheme(params.get('theme') || stored('igneum.wallet.theme') || 'system', false); + $('theme-seg').addEventListener('click', function (e) { var b = e.target.closest('.seg-b'); if (b) applyTheme(b.dataset.theme, true); }); -// ---- send ---- -$('go-send').onclick = () => { setView('send'); $('send-form').hidden = false; $('send-confirm').hidden = true; $('send-done').hidden = true; $('send-err').textContent = ''; $('send-to').focus(); }; -$('send-cancel').onclick = () => setView('overview'); -$('send-edit').onclick = () => { $('send-form').hidden = false; $('send-confirm').hidden = true; }; -$('send-quote').onclick = async () => { - $('send-err').textContent = ''; - try { - quote = await post('/api/send/quote', { to: $('send-to').value, amount: $('send-amount').value }); - $('c-amount').textContent = `${quote.value_ign} IGN`; - $('c-to').textContent = quote.display_to; - $('c-fee').textContent = `${quote.fee_max_ign} IGN`; - $('c-total').textContent = `${quote.total_max_ign} IGN`; - $('c-fee-note').textContent = `Fee = gas × price. Gas ${quote.gas.toLocaleString()}. Price = base fee ${quote.base_fee_gwei} gwei (burned by the network) + tip ${quote.tip_gwei} gwei (to the miner), capped at ${ign(quote.max_fee, 0) === '0' ? (Number(quote.max_fee) / 1e9).toFixed(3) + ' gwei' : ign(quote.max_fee) + ' IGN'} per gas; what is not used comes back.`; - const needs = !!quote.confirm_needed && !!bio.host; - $('send-go-text').textContent = needs ? 'Confirm with ' + what() : 'Send now'; - $('send-go').querySelector('.fp-ico').hidden = !needs; - $('send-form').hidden = true; $('send-confirm').hidden = false; $('confirm-send-err').textContent = ''; - if (quote.confirm_needed && !bio.host) $('confirm-send-err').textContent = what() + ' is on for this wallet, and only the Igneum Wallet app window can show it.'; - } catch (e) { $('send-err').textContent = e.message; } -}; -$('send-go').onclick = async () => { - $('confirm-send-err').textContent = ''; $('send-go').disabled = true; - try { - const { ok, value_ign, fee_max_ign, total_max_ign, base_fee_gwei, tip_gwei, display_to, confirm_nonce, confirm_reason, confirm_needed, ...q } = quote; - if (confirm_needed) { - // the prompt shows the engine's own line (amount and address); the host confirms the nonce to the engine - setLine($('send-bio-line'), `${FP}${esc('Waiting for ' + what())}`); - const c = await bio.call('confirm', { nonce: confirm_nonce }); - setLine($('send-bio-line'), bioLine(c, 'confirmed, sending')); - if (!c.ok) { $('send-go').disabled = false; return; } + // ---------- phases (welcome, create, import, unlock, home) and the pages behind the rail ---------- + function setPhase(p) { + document.body.dataset.phase = p; + var home = p === 'home'; + document.body.classList.toggle('has-rail', home); + $('rail').hidden = !home; + $('top-brand').hidden = home; + $('page-title').hidden = !home; + $('btn-lock-top').hidden = !home; + if (home) showPage(page); + } + function showPage(id) { + var meta = View.page(id); + page = meta.id; + document.body.dataset.page = page; + document.querySelectorAll('.page').forEach(function (el) { el.hidden = el.dataset.page !== page; }); + document.querySelectorAll('#rail-nav .nav').forEach(function (b) { + var on = b.dataset.page === page; + b.classList.toggle('on', on); + if (on) b.setAttribute('aria-current', 'page'); else b.removeAttribute('aria-current'); + }); + setText('page-title-text', meta.title); setText('page-sub', meta.sub); + $('main').scrollTop = 0; + if (page === 'receive') loadReceive(); + if (page === 'send' && !$('send-form').dataset.locked && $('send-done').hidden) $('send-to').focus({ preventScroll: true }); + if (state) renderPage(state); + } + $('rail-nav').addEventListener('click', function (e) { var b = e.target.closest('.nav[data-page]'); if (b) showPage(b.dataset.page); }); + $('rail-nav').addEventListener('keydown', function (e) { + var items = Array.prototype.slice.call(document.querySelectorAll('#rail-nav .nav')), i = items.indexOf(document.activeElement); + if (i < 0) return; + var j = e.key === 'ArrowDown' || e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowUp' || e.key === 'ArrowLeft' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : -1; + if (j < 0 || j >= items.length) return; + e.preventDefault(); items[j].focus(); showPage(items[j].dataset.page); + }); + function lockNow() { post('/api/lock').then(poll).catch(function (e) { toast(e.message); }); } + $('btn-lock').onclick = lockNow; + $('btn-lock-top').onclick = lockNow; + // quit: a strip in place of a dialog + $('btn-quit').onclick = function () { $('ask-quit').hidden = false; $('ask-quit-yes').focus(); }; + $('ask-quit-no').onclick = function () { $('ask-quit').hidden = true; }; + $('ask-quit-yes').onclick = function () { $('ask-quit').hidden = true; post('/api/quit').catch(function () {}); toast('Quitting'); }; + document.addEventListener('keydown', function (e) { + if (e.key !== 'Escape') return; + if (!$('upd').hidden) { e.preventDefault(); cardLater(); return; } + if (!$('ask-quit').hidden) { $('ask-quit').hidden = true; return; } + if (!$('ask-send').hidden) { cancelAsk(); return; } + if (!$('ask-remove').hidden) { $('ask-remove').hidden = true; } + }); + // disclosures: aria-expanded flips, the target shows + document.addEventListener('click', function (e) { + var b = e.target.closest('.disclose[aria-controls]'); if (!b) return; + var open = b.getAttribute('aria-expanded') !== 'true'; b.setAttribute('aria-expanded', open ? 'true' : 'false'); + var t = $(b.getAttribute('aria-controls')); if (t) t.hidden = !open; + if (!open && b.id === 'backup-toggle') resetBackup(); + if (!open && b.id === 'export-toggle') resetExport(); + }); + + // ---------- state ---------- + async function poll() { + try { state = await api('/api/state'); render(); } catch (e) { setText('pill-text', 'engine gone'); $('pill').className = 'pill warn'; } + } + function render() { + var s = state, phase = s.phase; + var inFlow = ['create', 'import'].indexOf(document.body.dataset.phase) >= 0; + if (firstPhase === null) firstPhase = phase; + if ((!inFlow || phase === 'home') && lastPhase !== phase && !locking) { + if (lastPhase === 'home' && phase === 'unlock') lockTransition(); + else { setPhase(phase); if (phase === 'unlock') onLockScreen(lastPhase === null ? 'arrival' : 'lock'); } + } + lastPhase = phase; + if (phase === 'home' && firstHome) { firstHome = false; if (params.get('page')) showPage(params.get('page')); } + var pw = View.pillWords(s); + setText('pill-text', pw.text); $('pill').className = 'pill ' + pw.cls; + setText('welcome-eyebrow', (s.settings.network === 'devnet' ? 'devnet v4' : s.settings.network) + ' · nothing is bought or sold'); + if (forcedUpdate) s.update = sampleUpdate(forcedUpdate); + renderNotices(s); + renderUpdateCard(s); + if (phase === 'unlock' || locking) renderLock(); + $('seg-miner').disabled = !s.miner_wallet_present; + if (phase === 'home') { + $('rail-status').innerHTML = '' + esc(View.shortHex(s.display, 6, 4)) + '
    ' + esc(s.settings.network === 'devnet' ? 'devnet v4' : s.settings.network); + setText('foot-version', 'v' + s.version); + $('nav-updates-dot').hidden = !(s.update && (s.update.status === 'ready' || s.update.status === 'deferred' || s.update.status === 'manual')); + renderPage(s); } - const r = await post('/api/send', { quote: q, nonce: confirm_nonce }); - sentHash = r.hash; $('sent-hash').textContent = r.hash; - $('send-confirm').hidden = true; $('send-done').hidden = false; $('send-to').value = ''; $('send-amount').value = ''; - await poll(); - } catch (e) { $('confirm-send-err').textContent = e.message; } - $('send-go').disabled = false; -}; -$('sent-home').onclick = () => setView('overview'); -$('sent-view').onclick = () => openTx(sentHash); - -// ---- receive ---- -$('go-receive').onclick = async () => { - try { const r = await post('/api/receive'); $('qr').innerHTML = r.svg; $('receive-address').textContent = r.display; setView('receive'); } - catch (e) { toast(e.message); } -}; -$('receive-copy').onclick = () => copy(state.display, 'address'); -$('receive-back').onclick = () => setView('overview'); - -// ---- transaction detail ---- -async function openTx(hash) { txHash = hash; setView('tx'); await renderTx(); } -async function renderTx() { - const e = (state.history || []).find(x => x.hash.toLowerCase() === txHash.toLowerCase()); - if (!e) return; - const incoming = ['received', 'reward', 'proving'].includes(e.kind); - $('tx-title').textContent = e.kind === 'reward' ? 'Block reward' : e.kind === 'proving' ? 'Shard payout' : e.kind === 'sent' ? 'Sent' : e.kind === 'self' ? 'To yourself' : 'Received'; - const fl = $('tx-finality'); fl.className = 'finality-line ' + e.finality; - fl.textContent = e.finality === 'final' ? `FINAL · under checkpoint ${e.checkpoint}, certificate verified by this wallet` : e.finality === 'in_block' ? `IN A BLOCK · chain block ${e.block.toLocaleString()}; final once a verified checkpoint covers it` : e.finality === 'failed' ? 'FAILED · the execution failed; the fee was still paid' : 'PENDING · waiting for a block'; - const rows = [['amount', `${incoming ? '+' : '−'}${ign(e.value, 18)} IGN`], ['when', when(e.time)]]; - if (e.kind !== 'reward' && e.kind !== 'proving') rows.push(['from', esc(e.from)], ['to', esc(e.to)], ['fee paid', e.fee ? `${ign(e.fee, 9)} IGN` : 'pending'], ['hash', esc(e.hash)]); - rows.push(['block', e.block ? `${e.block.toLocaleString()} · ${short(e.block_hash)}` : 'none yet']); - if (e.note) rows.push(['note', esc(e.note)]); - const f = state.finality; - rows.push(['verified checkpoint', f.verified_index ? `${f.verified_index} at chain height ${f.chain_number == null ? '…' : f.chain_number.toLocaleString()}` : 'none yet']); - kv($('tx-kv'), rows); -} -$('tx-back').onclick = () => setView('overview'); - -// ---- settings ---- -$('backup-show').onclick = async () => { - $('backup-err').textContent = ''; - try { - const r = await post('/api/reveal', { password: $('backup-pw').value }); $('backup-pw').value = ''; - $('backup-words').innerHTML = (r.words || []).map(w => `
  • ${esc(w)}
  • `).join(''); - $('backup-key').textContent = r.private_key; $('backup-out').hidden = false; - if (!state.backed_up) await post('/api/backed-up'); - } catch (e) { $('backup-err').textContent = e.message; } -}; -$('backup-hide').onclick = () => { $('backup-out').hidden = true; $('backup-words').innerHTML = ''; $('backup-key').textContent = ''; }; -// the backup and the export after a confirmation: the words come from the unlocked key, no password typed -async function revealWithTouch(errEl) { - errEl.textContent = ''; - const c = await post('/api/biometric/challenge', { purpose: 'reveal' }); - setLine(errEl, `${FP}${esc('Waiting for ' + what())}`); - const h = await bio.call('confirm', { nonce: c.nonce }); - setLine(errEl, bioLine(h, 'confirmed')); - if (!h.ok) return null; - return post('/api/reveal', { nonce: c.nonce }); -} -$('backup-touch').onclick = async () => { - try { - const r = await revealWithTouch($('backup-err')); - if (!r) return; - $('backup-words').innerHTML = (r.words || []).map(w => `
  • ${esc(w)}
  • `).join(''); - $('backup-key').textContent = r.private_key; $('backup-out').hidden = false; - if (!state.backed_up) await post('/api/backed-up'); - } catch (e) { $('backup-err').textContent = e.message; } -}; -$('export-touch').onclick = async () => { - try { const r = await revealWithTouch($('export-err')); if (!r) return; $('export-key').textContent = r.private_key; $('export-out').hidden = false; } - catch (e) { $('export-err').textContent = e.message; } -}; -// ---- Touch ID / Windows Hello in Settings: enrol (password once, then the prompt), the idle lock, turn off ---- -function versionLine(s) { - const u = s.update || {}, v = 'Igneum Wallet ' + s.version; - let tail; - switch (u.status) { - case 'current': tail = 'up to date'; break; - case 'available': case 'downloading': tail = u.version + ' downloading'; break; - case 'staging': case 'ready': case 'deferred': case 'manual': tail = u.version + ' downloaded'; break; - case 'applying': tail = 'installing ' + u.version; break; - case 'checking': tail = 'checking for updates'; break; - case 'off': tail = 'updates off in this build'; break; - case 'error': tail = 'update check failed'; break; - default: tail = 'not checked yet'; } - return `${esc(v)} · ${esc(tail)}`; -} -function renderBio(s) { - const b = s.biometric || {}, w = what(); - $('version-row').innerHTML = versionLine(s); - $('bio-title').textContent = w; - $('bio-enrol-text').textContent = 'Turn on ' + w; - $('ask-on-open-text').textContent = `Ask for ${w} when the wallet opens`; - const idleMin = s.settings && s.settings.idle_lock_min != null ? s.settings.idle_lock_min : (b.idle_lock ? (b.idle_lock_min || 5) : 0); - if (document.activeElement !== $('idle-lock')) $('idle-lock').value = String(LockScreen.IDLE_CHOICES.includes(Number(idleMin)) ? idleMin : LockScreen.IDLE_DEFAULT_MIN); - $('idle-lock-note').textContent = Number(idleMin) > 0 ? `The key is cleared after ${LockScreen.idleLabel(idleMin)} without you; ${w} or the password opens it again.` : 'The wallet stays open until you lock it.'; - if (document.activeElement !== $('ask-on-open')) $('ask-on-open').checked = !!(s.settings && s.settings.ask_on_open); - $('backup-touch').hidden = !(b.enrolled && bio.host); $('backup-touch').querySelector('span').textContent = 'Show with ' + w; - $('export-touch').hidden = !(b.enrolled && bio.host); $('export-touch').querySelector('span').textContent = 'Show with ' + w; - $('backup-note-text').textContent = b.enrolled ? `Show the 24 words (or the key) again, with ${w} or the password.` : 'Show the 24 words (or the key) again. Needs the password.'; - $('bio-on').hidden = !b.enrolled; $('bio-off').hidden = b.enrolled; - let off = ''; - if (!bio.host) off = w + ' needs the Igneum Wallet app window; this page is open in a browser.'; - else if (!b.available) off = b.message || (w === 'Touch ID' ? 'Touch ID is not set up on this Mac.' : 'Windows Hello is not set up on this PC.'); - $('bio-off-note').textContent = off; - $('bio-enrol').disabled = !!off; $('bio-pw').disabled = !!off; - $('bio-on-note').textContent = b.needs_enrol ? '' : (b.last_result && b.last_result !== 'ok' && b.last_op ? `last ${b.last_op}: ${b.last_result}` : ''); - if (b.needs_enrol && !$('bio-err').textContent) $('bio-err').textContent = `The password changed, so ${w} was turned off. Turn it on again here.`; -} -$('bio-enrol').onclick = async () => { - $('bio-err').textContent = ''; - const pw = $('bio-pw').value; - if (!pw) return $('bio-err').textContent = 'type the password first'; - $('bio-enrol').disabled = true; - try { - const r = await post('/api/biometric/enrol/begin', { password: pw }); - setLine($('bio-err'), `${FP}${esc('Waiting for ' + what())}`); - const h = await bio.call('enrol', { token: r.token }); - setLine($('bio-err'), bioLine(h, 'is on')); - if (!h.ok) { post('/api/biometric/enrol/cancel').catch(() => {}); $('bio-enrol').disabled = false; return; } - $('bio-pw').value = ''; toast(what() + ' is on'); await poll(); - } catch (e) { $('bio-err').textContent = e.message; } - $('bio-enrol').disabled = false; -}; -$('bio-remove').onclick = async () => { try { await post('/api/biometric/remove'); $('bio-err').textContent = ''; toast(what() + ' is off'); await poll(); } catch (e) { $('bio-err').textContent = e.message; } }; -$('idle-lock').onchange = async ev => { try { await post('/api/settings', { idle_lock_min: Number(ev.target.value) }); await poll(); } catch (e) { toast(e.message); } }; -$('ask-on-open').onchange = async ev => { try { await post('/api/settings', { ask_on_open: ev.target.checked }); } catch (e) { toast(e.message); } }; -$('unlock-touch').onclick = () => unlockWithTouch(); -$('pw-change').onclick = async () => { - $('pw-err').textContent = ''; - try { await post('/api/password', { old: $('pw-old').value, new: $('pw-new').value }); $('pw-old').value = ''; $('pw-new').value = ''; toast('password changed'); } - catch (e) { $('pw-err').textContent = e.message; } -}; -async function network() { return api('/api/network'); } -async function renderNetwork() { - try { - const n = await network(); - kv($('net-kv'), [['network name', esc(n.chain_name)], ['chain id', `${n.chain_id} (${n.chain_id_hex})`], ['rpc url', esc(n.rpc_url || 'none yet')], ['symbol', 'IGN'], ['decimals', '18']]); - $('net-add').disabled = !n.add_network_page; - $('net-add').title = n.add_network_page ? '' : 'the site page is not set in this build; copy the settings instead'; - } catch (e) { kv($('net-kv'), [['network', esc(e.message)]]); } -} -$('net-add').onclick = async () => { const n = await network(); if (n.add_network_page) post('/api/open', { url: n.add_network_page }); }; -$('net-copy').onclick = async () => { const n = await network(); copy(`Network name: ${n.chain_name}\nRPC URL: ${n.rpc_url}\nChain ID: ${n.chain_id}\nCurrency symbol: IGN\nDecimals: 18`, 'network settings'); }; -$('export-show').onclick = async () => { - $('export-err').textContent = ''; - try { const r = await post('/api/reveal', { password: $('export-pw').value }); $('export-pw').value = ''; $('export-key').textContent = r.private_key; $('export-out').hidden = false; } - catch (e) { $('export-err').textContent = e.message; } -}; -$('export-copy').onclick = () => copy($('export-key').textContent, 'private key'); -$('export-hide').onclick = () => { $('export-out').hidden = true; $('export-key').textContent = ''; }; -$('start-login').onchange = async ev => { try { await post('/api/settings', { start_at_login: ev.target.checked }); } catch (e) { toast(e.message); } }; + function renderPage(s) { + if (page === 'home') renderHome(s); + else if (page === 'send') renderSend(s); + else if (page === 'receive') renderReceive(s); + else if (page === 'history') renderRows($('history'), s.history, 0, s); + else if (page === 'settings') renderSettings(s); + refreshNodeWords(s); + } -// ---- over-the-air updates (src/updater.rs): one banner, the settings line ---- -function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; } -function updateLine(u) { - const v = 'Igneum Wallet ' + u.version; - if (u.urgent && u.urgent_text) return { text: u.urgent_text + (u.status === 'downloading' ? ' Downloading.' : ''), urgent: true, prog: u.status === 'downloading' }; - switch (u.status) { - case 'available': return { text: v + ' is available. Downloading it.' }; - case 'downloading': return { text: 'Downloading ' + v + (u.size ? ' (' + Math.round(u.size / 1e6) + ' MB)' : '') + ': ' + Math.round((u.progress || 0) * 100) + '%', prog: true }; - case 'staging': return { text: v + ' downloaded and verified. Preparing it.' }; - case 'ready': return { text: v + ' is ready. ' + (u.wait ? cap(u.wait) + '.' : 'It installs as soon as nothing is being sent.'), install: true }; - case 'applying': return { text: 'Installing ' + v + ': ' + (u.wait ? u.wait + '.' : 'the app closes and opens again by itself.') }; - case 'deferred': return { text: v + ' is downloaded. Windows asked for permission and nobody answered; it installs the next time someone is at this PC.', install: true }; - case 'manual': return { text: v + ' is downloaded. ' + cap(u.wait || 'open the download and drag the app over the old one.'), open: true }; - case 'error': return { text: 'Update: ' + (u.error || 'failed') + '.', install: !!(u.ready || u.downloaded) }; - default: return null; + // ---------- Home ---------- + function renderHome(s) { + var bl = View.balanceLine(s); + $('h-bal').hidden = !bl.known; + setText('h-balance', bl.known ? s.balance : ''); + setText('h-balance-line', bl.text); + var ml = View.moneyLine(s); + $('h-money').innerHTML = (ml.money ? '' + esc(ml.money) + '' : '') + esc(ml.text); + setText('home-address', s.display || 'not set'); + $('backup-note').hidden = !!s.backed_up; + var nl = View.nodeLine(s); + setText('node-line-text', nl.text); setText('node-sub', nl.sub); + $('node-dot').className = 'dot' + (nl.tone === 'ok' ? ' live' : nl.tone === 'bad' ? ' bad' : ''); + $('node-card').classList.toggle('bad', nl.tone === 'bad'); + renderKv($('node-kv'), View.nodeDetails(s, s.now)); + renderRows($('recent'), s.history, 5, s); + setText('hist-eyebrow', ''); } -} -function updateNote(u) { - const l = updateLine(u), parts = []; - if (u.updated_from) parts.push('Updated from ' + u.updated_from + '.'); - if (u.rolled_back) parts.push('Rolled back: ' + u.rolled_back + '.'); - if (u.status === 'off') parts.push('Updates are off in this build.'); - else if (l && !(u.rolled_back && u.status === 'error')) parts.push(l.text); - else if (u.status === 'checking') parts.push('Checking.'); - else if (u.status === 'current') parts.push('This is the latest version.'); - else if (u.error) parts.push(u.error); - else parts.push('Not checked yet.'); - return parts.join(' '); -} -function renderUpdate(s) { - const u = s.update, b = $('update-banner'), l = updateLine(u); - const key = u.status + ':' + u.version; - const show = !!l && (u.urgent || u.applying || sessionStorage.getItem('update-later') !== key); - if (show) { - $('update-text').textContent = l.text; - b.classList.toggle('urgent', !!l.urgent); - $('update-install').hidden = !l.install || u.applying; - $('update-open').hidden = !l.open; - $('update-later').hidden = !!l.urgent || !!u.applying; - $('update-prog').hidden = !l.prog; - $('update-prog').firstElementChild.style.width = Math.round((u.progress || 0) * 100) + '%'; + function renderKv(el, rows) { + el.innerHTML = rows.map(function (r) { return '
    ' + esc(r[0]) + '' + esc(r[1]) + '' + esc(r[2] || '') + '
    '; }).join(''); } - b.hidden = !show; - $('update-note').textContent = updateNote(u); - $('update-install-s').hidden = !(l && l.install) || u.applying; - if (document.activeElement !== $('auto-update')) $('auto-update').checked = !!s.settings.auto_update; -} -$('update-check').onclick = () => { post('/api/update/check'); toast('checking for updates'); }; -$('update-open').onclick = () => post('/api/update/open'); -$('update-install').onclick = () => { post('/api/update/install'); toast('installing now'); }; -$('update-install-s').onclick = () => { post('/api/update/install'); toast('installing now'); }; -$('update-later').onclick = () => { sessionStorage.setItem('update-later', state.update.status + ':' + state.update.version); $('update-banner').hidden = true; }; + $('go-send').onclick = function () { resetSend(); showPage('send'); }; + $('go-receive').onclick = function () { showPage('receive'); }; + $('recent-all').onclick = function () { showPage('history'); }; + $('backup-link').onclick = function (ev) { ev.preventDefault(); showPage('settings'); var t = $('backup-toggle'); if (t.getAttribute('aria-expanded') !== 'true') t.click(); t.scrollIntoView({ block: 'center' }); }; -// ---- the update card (update-card.js): one update, centred, over every screen; the banner above stays the small strip ---- -// mem: open (the card is up), later (the key Later was pressed on), seen (the version it was shown for); -// asked: Install now was pressed on this card and the engine has not flipped to applying yet -const updMem = { open: false, later: '', seen: '' }; -let updSig = '', updAsked = '', updMore = false; -// ?update=[&auto=0][&card=1]: the card and the banner for each state, without an engine (screenshots) -const forcedUpdate = new URLSearchParams(location.search).get('update'); -if (forcedUpdate && new URLSearchParams(location.search).get('card') === '1') updMem.open = true; -function sampleUpdate(kind) { - const u = { status: 'ready', version: '0.1.3', url: '', notes: 'The update card: one centred card with Install now and Later. Touch ID confirms every send on the Mac; Windows Hello is written but untested. The coin and the chain line sit on the balance card. The version shows in the header and in Settings.', file: '', error: '', checked_at: Date.now() / 1000 - 40, available: true, downloaded: true, ready: true, applying: false, progress: 1, size: 19479807, auto: new URLSearchParams(location.search).get('auto') !== '0', wait: 'installs as soon as nothing is being sent', urgent: false, urgent_text: '', unsupported: false, min_supported: '', updated_from: '', rolled_back: '' }; - if (!u.auto) u.wait = 'waiting for Install now (automatic updates are off)'; - if (kind === 'available') { u.status = 'available'; u.downloaded = false; u.ready = false; u.progress = 0; } - if (kind === 'downloading') { u.status = 'downloading'; u.downloaded = false; u.ready = false; u.progress = Math.min(0.97, 0.43 + ((Date.now() / 1000) % 60) / 110); } - if (kind === 'staging') { u.status = 'staging'; u.ready = false; } - if (kind === 'applying') { u.status = 'applying'; u.applying = true; } - if (kind === 'deferred') { u.status = 'deferred'; u.wait = 'waits for the next time someone is at this PC (Windows asks for permission)'; } - if (kind === 'urgent') { u.status = 'downloading'; u.ready = false; u.downloaded = false; u.progress = 0.27; u.urgent = true; u.urgent_text = 'Igneum Wallet 0.1.2 is no longer supported by the network. Installing 0.1.3 now.'; } - if (kind === 'manual') { u.status = 'manual'; u.ready = false; u.wait = '/Applications is not writable; open the downloaded disk image and drag the app over the old one'; } - if (kind === 'error') { u.status = 'error'; u.error = 'sha256 mismatch: the file is not what the manifest signed'; u.downloaded = false; u.ready = false; } - return u; -} -function cardCtx(s) { - const bioLine = [...document.querySelectorAll('.bio-state')].some(e => e.offsetParent !== null); - return { phase: document.body.dataset.phase, view: document.body.dataset.view, bioBusy: bio.busy, bioLine, bioName: what(), quitting: !!s.quitting }; -} -function renderUpdateCard(s) { - const m = UpdateCard.model(s.update, s), d = UpdateCard.decide(m, cardCtx(s), updMem), wrap = $('upd'); - if (!d.show) { - if (updMem.open || !wrap.hidden) { updMem.open = false; wrap.hidden = true; updSig = ''; } - return; + // ---------- the history rows (Home shows five, History every one) ---------- + var nodeWord = {}, openRows = {}, fetching = false; + function renderRows(el, list, limit, s) { + list = list || []; + if (limit) list = list.slice(0, limit); + if (!list.length) { el.innerHTML = '
    ' + esc(s.scanning ? 'Reading the chain.' : 'Nothing yet. Receive IGN, or point the miner app at this address.') + '
    '; return; } + el.innerHTML = list.map(function (e) { + var nw = nodeWord[e.hash.toLowerCase()], m = View.rowModel(e, nw && nw.word, s.now), open = !!openRows[e.hash.toLowerCase()]; + return '
    ' + + '
    ' + + '' + esc(m.kind) + '' + esc(m.who) + (m.when ? ' · ' + esc(m.when) : '') + '' + + '' + esc(m.amount) + 'IGN' + + '' + esc(m.state.word) + '' + esc(m.state.why) + '' + + '
    ' + + (open ? rowDetails(e, m) : '') + '
    '; + }).join(''); } - if (!updMem.open) { updMem.open = true; updMem.seen = m.version; updMore = false; if (updAsked !== m.key) updAsked = ''; } - const asked = updAsked === m.key && m.stage !== 'installing' && m.stage !== 'failed'; - const sig = [m.key, m.stage, m.line, m.note, m.cause, m.ring, asked ? 'asked' : '', m.lines.join('|')].join('\u0001'); - if (sig !== updSig) { - updSig = sig; - $('upd-name').textContent = m.name; - $('upd-line').textContent = m.line; - $('upd-cause').textContent = m.cause; $('upd-cause').hidden = !m.cause; - $('upd-notes').innerHTML = m.lines.map(l => `
  • ${esc(l)}
  • `).join(''); - $('upd-all').innerHTML = m.all.map(l => `
  • ${esc(l)}
  • `).join(''); - $('upd-more').hidden = !m.more; if (!m.more) updMore = false; - $('upd-more').textContent = updMore ? 'Less' : 'What changed'; $('upd-more').setAttribute('aria-expanded', updMore ? 'true' : 'false'); - $('upd-all').hidden = !updMore; - $('upd-mark').className = 'upd-mark ' + m.ring; - const acts = asked ? [{ act: 'install', label: m.stage === 'ready' || m.stage === 'deferred' ? 'Installing' : 'Installs when ready', primary: true, disabled: true }, { act: 'later', label: 'Later' }] : m.actions; - $('upd-actions').innerHTML = acts.map(a => ``).join(''); - wrap.dataset.dismiss = m.dismissable ? '1' : ''; + function rowDetails(e, m) { + var rows = []; + if (!m.synthetic) rows.push(['hash', e.hash, 'hash']); + if (e.from) rows.push(['from', e.from, 'address']); + if (e.to) rows.push(['to', e.to, 'address']); + if (!m.synthetic) rows.push(['fee paid', e.fee && e.fee !== '0' ? View.ign(e.fee, 9) + ' IGN' : e.block ? 'none' : 'pending', '']); + rows.push(['block', e.block ? View.withCommas(e.block) + (e.block_hash ? ' · ' + View.shortHex(e.block_hash, 6, 4) : '') : 'none yet', '']); + if (e.checkpoint) rows.push(['checkpoint', View.withCommas(e.checkpoint), 'verified by this wallet']); + if (e.note) rows.push(['note', e.note, '']); + rows.push(['the node says', m.nodeWord || (m.synthetic ? 'a block reward has no transaction' : 'not asked yet'), 'igneum_getTransactionStatus, one word']); + return '
    ' + rows.map(function (r) { + var copy = r[2] === 'hash' || r[2] === 'address' ? ' ' : ''; + return '
    ' + esc(r[0]) + '' + esc(r[1]) + copy + '' + (copy ? '' : '' + esc(r[2] || '') + '') + '
    '; + }).join('') + '
    '; } - // the ring and the percent move every poll; the words above do not - if (m.ring === 'progress') { $('upd-arc').style.strokeDashoffset = (276.5 * (1 - m.pct / 100)).toFixed(1); $('upd-pct').textContent = m.pct + '%'; $('upd-pct').hidden = m.stage !== 'downloading'; } - else { $('upd-arc').style.strokeDashoffset = ''; $('upd-pct').hidden = true; } - const meta = []; - if (m.stage === 'downloading' && m.size) meta.push(m.pct + '% of ' + m.size); - else if (m.size && m.stage === 'available') meta.push(m.size + ' download'); - else if (m.size && (m.stage === 'ready' || m.stage === 'manual' || m.stage === 'deferred' || m.stage === 'staging')) meta.push(m.size + ', downloaded'); - if (m.note) meta.push(m.note); - $('upd-meta').textContent = meta.join(' · '); - if (wrap.hidden) { wrap.hidden = false; $('upd-card').focus({ preventScroll: true }); } -} -function cardLater() { - if (!updMem.open || !$('upd').dataset.dismiss) return; - const m = UpdateCard.model(state && state.update, state || {}); - updMem.later = m ? m.key : updMem.later; updMem.open = false; $('upd').hidden = true; updSig = ''; -} -$('upd').addEventListener('click', e => { - if (e.target === $('upd')) { cardLater(); return; } - const b = e.target.closest('[data-act]'); if (!b || b.disabled) return; - const act = b.dataset.act, m = UpdateCard.model(state && state.update, state || {}); - if (act === 'later') cardLater(); - else if (act === 'install' || act === 'retry') { post('/api/update/install').catch(() => {}); updAsked = m ? m.key : ''; updSig = ''; toast(act === 'retry' ? 'trying the update again' : m && m.stage === 'ready' ? 'installing now' : 'installs as soon as it is downloaded'); if (state) renderUpdateCard(state); } - else if (act === 'open') post('/api/update/open').catch(() => {}); -}); -$('upd-more').onclick = function () { updMore = !updMore; $('upd-all').hidden = !updMore; this.textContent = updMore ? 'Less' : 'What changed'; this.setAttribute('aria-expanded', updMore ? 'true' : 'false'); }; -document.addEventListener('keydown', e => { if (e.key === 'Escape' && !$('upd').hidden) { e.preventDefault(); cardLater(); } }); -$('auto-update').onchange = async ev => { try { await post('/api/update/auto', { on: ev.target.checked }); } catch (e) { toast(e.message); } }; -$('remove-go').onclick = async () => { - $('remove-err').textContent = ''; - if (!confirm('Remove this wallet from this machine? Only your 24 words or the key bring it back.')) return; - try { await post('/api/remove', { password: $('remove-pw').value }); $('remove-pw').value = ''; await poll(); } - catch (e) { $('remove-err').textContent = e.message; } -}; -document.addEventListener('visibilitychange', () => { - if (document.hidden) return; + document.addEventListener('click', function (e) { + if (e.target.closest('[data-copy-text]')) return; + var main = e.target.closest('.hrow:not(.sent-row) .hr-main'); if (!main) return; + var h = main.parentNode.dataset.hash.toLowerCase(); + openRows[h] = !openRows[h]; + if (state) renderPage(state); + }); + // the node's one word per transaction, from GET /api/tx/ (node_status.state), for the rows that are not final + async function refreshNodeWords(s) { + if (fetching) return; + var want = View.needsNodeWord(s.history, 12), now = Date.now() / 1000; + var due = want.filter(function (h) { var w = nodeWord[h.toLowerCase()]; return !w || now - w.at > (w.word ? 4 : 30); }); + if (!due.length) return; + fetching = true; + try { + for (var i = 0; i < due.length; i++) { + var h = due[i].toLowerCase(), word = ''; + try { var r = await api('/api/tx/' + due[i]); word = (r.node_status && r.node_status.state) || ''; } catch (e) { word = ''; } + nodeWord[h] = { word: word, at: Date.now() / 1000 }; + } + } finally { fetching = false; } + if (state) renderRowsOnly(state); + } + function renderRowsOnly(s) { + if (page === 'home') renderRows($('recent'), s.history, 5, s); + else if (page === 'history') renderRows($('history'), s.history, 0, s); + else if (page === 'send') renderSentRow(s); + } + + // ---------- Send: the form, the question in place, the pending row ---------- + function renderSend(s) { + var bl = View.balanceLine(s); + setText('send-balance', bl.known ? 'of ' + s.balance + ' IGN' : ''); + setText('send-eyebrow', s.node && s.node.state === 'ok' ? '' : 'needs a node'); + renderSentRow(s); + } + function resetSend() { + quote = null; sentHash = null; + $('send-form').hidden = false; $('send-form').dataset.locked = ''; + $('send-to').value = ''; $('send-amount').value = ''; + $('send-err').textContent = ''; $('confirm-send-err').textContent = ''; setLine($('send-bio-line'), ''); + setText('fee-line', View.feeWords(null).line); $('fee-toggle').hidden = true; $('fee-details').hidden = true; $('fee-toggle').setAttribute('aria-expanded', 'false'); + $('send-cta').hidden = false; $('ask-send').hidden = true; $('send-done').hidden = true; + } + function cancelAsk() { $('ask-send').hidden = true; $('send-cta').hidden = false; $('send-form').dataset.locked = ''; quote = null; $('send-to').focus({ preventScroll: true }); } + $('ask-send-no').onclick = cancelAsk; + $('send-quote').onclick = async function () { + $('send-err').textContent = ''; + try { + quote = await post('/api/send/quote', { to: $('send-to').value.trim(), amount: $('send-amount').value.trim() }); + var fw = View.feeWords(quote); + setText('fee-line', fw.line); setText('fee-details', fw.detail); $('fee-toggle').hidden = false; + var needs = !!quote.confirm_needed && !!bio.host; + var ask = View.sendAsk(quote, needs, what()); + setText('ask-send-text', ask.text); setText('send-go-text', ask.button); + $('send-go').querySelector('.fp-ico').hidden = !needs; + $('confirm-send-err').textContent = quote.confirm_needed && !bio.host ? what() + ' is on for this wallet, and only the Igneum Wallet app window can show it.' : ''; + setLine($('send-bio-line'), ''); + $('send-form').dataset.locked = '1'; $('send-cta').hidden = true; $('ask-send').hidden = false; $('send-go').disabled = false; + $('send-go').focus({ preventScroll: true }); + } catch (e) { $('send-err').textContent = e.message; } + }; + $('fee-toggle').onclick = function () { var open = this.getAttribute('aria-expanded') !== 'true'; this.setAttribute('aria-expanded', open ? 'true' : 'false'); $('fee-details').hidden = !open; this.textContent = open ? 'Less' : 'Details'; }; + ['send-to', 'send-amount'].forEach(function (id) { $(id).addEventListener('keydown', function (e) { if (e.key === 'Enter' && !$('send-form').dataset.locked) { e.preventDefault(); $('send-quote').click(); } }); }); + $('send-go').onclick = async function () { + if (!quote) return; + $('confirm-send-err').textContent = ''; $('send-go').disabled = true; + try { + var q = Object.assign({}, quote); + ['ok', 'value_ign', 'fee_max_ign', 'total_max_ign', 'base_fee_gwei', 'tip_gwei', 'display_to', 'confirm_nonce', 'confirm_reason', 'confirm_needed'].forEach(function (k) { delete q[k]; }); + if (quote.confirm_needed) { + setLine($('send-bio-line'), '' + FP + esc('Waiting for ' + what()) + ''); + var c = await bio.call('confirm', { nonce: quote.confirm_nonce }); + setLine($('send-bio-line'), bioLine(c, 'confirmed, sending')); + if (!c.ok) { $('send-go').disabled = false; return; } + } + var r = await post('/api/send', { quote: q, nonce: quote.confirm_nonce }); + sentHash = r.hash; setText('sent-hash', r.hash); + $('ask-send').hidden = true; $('send-form').hidden = true; $('send-done').hidden = false; + $('send-to').value = ''; $('send-amount').value = ''; $('send-form').dataset.locked = ''; quote = null; + await poll(); + } catch (e) { $('confirm-send-err').textContent = e.message; } + $('send-go').disabled = false; + }; + function renderSentRow(s) { + if (!sentHash || $('send-done').hidden) return; + var e = (s.history || []).filter(function (x) { return x.hash.toLowerCase() === sentHash.toLowerCase(); })[0]; + var el = $('sent-row'); + if (!e) { el.innerHTML = '
    Senthanded to the nodependingwaiting for a block
    '; return; } + var nw = nodeWord[e.hash.toLowerCase()], m = View.rowModel(e, nw && nw.word, s.now); + el.className = 'hrow sent-row' + (m.in ? ' in' : ''); + el.innerHTML = '
    ' + esc(m.kind) + '' + esc(m.who) + (m.when ? ' · ' + esc(m.when) : '') + '' + esc(m.amount) + 'IGN' + esc(m.state.word) + '' + esc(m.state.why) + '
    '; + } + $('sent-view').onclick = function () { if (sentHash) openRows[sentHash.toLowerCase()] = true; showPage('history'); }; + $('sent-again').onclick = function () { resetSend(); $('send-to').focus({ preventScroll: true }); }; + + // ---------- Receive ---------- + var receiveFor = ''; + function renderReceive(s) { setText('receive-address', s.display || 'not set'); } + async function loadReceive() { + if (!state || !state.display || receiveFor === state.display) return; + try { var r = await post('/api/receive'); $('qr').innerHTML = r.svg; receiveFor = state.display; } + catch (e) { toast(e.message); } + } + + // ---------- create ---------- + $('go-create').onclick = function () { setPhase('create'); $('create-1').hidden = false; $('create-2').hidden = true; $('create-3').hidden = true; $('create-pw').focus(); }; + $('create-back').onclick = function () { setPhase('welcome'); }; + var words = []; + $('create-next').onclick = async function () { + $('create-err').textContent = ''; + var a = $('create-pw').value, b = $('create-pw2').value; + if (a.length < 8) { $('create-err').textContent = 'at least 8 characters'; return; } + if (a !== b) { $('create-err').textContent = 'the two passwords differ'; return; } + try { + var r = await post('/api/create', { password: a }); + words = r.words; + $('words').innerHTML = words.map(function (w) { return '
  • ' + esc(w) + '
  • '; }).join(''); + $('create-address').textContent = r.display; + $('create-1').hidden = true; $('create-2').hidden = false; + } catch (e) { $('create-err').textContent = e.message; } + }; + $('create-written').onclick = function () { + var picks = []; while (picks.length < 3) { var p = 1 + Math.floor(Math.random() * 24); if (picks.indexOf(p) < 0) picks.push(p); } + picks.sort(function (a, b) { return a - b; }); + $('checks').innerHTML = picks.map(function (p) { return ''; }).join(''); + $('words').innerHTML = ''; words = []; + $('create-2').hidden = true; $('create-3').hidden = false; + $('checks').querySelector('input').focus(); + }; + $('create-again').onclick = function () { $('create-3').hidden = true; $('create-1').hidden = false; $('confirm-err').textContent = ''; $('create-err').textContent = 'start again: the words are made fresh each time'; }; + $('create-confirm').onclick = async function () { + $('confirm-err').textContent = ''; + var checks = Array.prototype.map.call($('checks').querySelectorAll('input'), function (i) { return { position: Number(i.dataset.pos), word: i.value.trim() }; }); + try { await post('/api/create/confirm', { checks: checks }); $('checks').innerHTML = ''; await poll(); setPhase('home'); showPage('home'); toast('Wallet ready'); } + catch (e) { $('confirm-err').textContent = e.message; } + }; + + // ---------- import ---------- + var importMode = 'seed'; + $('go-import').onclick = function () { setPhase('import'); }; + $('import-back').onclick = function () { setPhase('welcome'); }; + $('import-mode').addEventListener('click', function (e) { + var b = e.target.closest('.seg-b'); if (!b || b.disabled) return; + importMode = b.dataset.mode; + document.querySelectorAll('#import-mode .seg-b').forEach(function (x) { var on = x === b; x.classList.toggle('on', on); x.setAttribute('aria-checked', on ? 'true' : 'false'); }); + $('import-data-field').hidden = importMode === 'miner'; + $('import-miner-note').hidden = importMode !== 'miner'; + setText('import-data-label', importMode === 'seed' ? 'The words, in order' : 'The private key, 64 hex characters'); + }); + $('import-go').onclick = async function () { + $('import-err').textContent = ''; + var a = $('import-pw').value, b = $('import-pw2').value; + if (a.length < 8) { $('import-err').textContent = 'at least 8 characters'; return; } + if (a !== b) { $('import-err').textContent = 'the two passwords differ'; return; } + try { await post('/api/import', { mode: importMode, data: $('import-data').value, password: a }); $('import-data').value = ''; await poll(); setPhase('home'); showPage('home'); toast('Imported'); } + catch (e) { $('import-err').textContent = e.message; } + }; + + // ---------- Settings ---------- + function renderSettings(s) { + var b = s.biometric || {}, w = what(), host = hostHere(); + setText('bio-title', w); + setText('bio-sentence', View.bioSentence(b, w, host)); + setText('bio-toggle-btn', b.enrolled ? 'Turn off' : 'Turn on'); + $('bio-toggle-btn').disabled = !b.enrolled && (!host || !b.available); + $('bio-toggle-btn').classList.toggle('danger', !!b.enrolled); + if (b.enrolled) $('bio-enrol').hidden = true; + $('bio-off-note').hidden = true; + setText('bio-enrol-text', 'Turn on ' + w); + $('ask-on-open-row').hidden = !b.enrolled; $('idle-row').hidden = !b.enrolled; + setText('ask-on-open-text', 'Ask for ' + w + ' when the wallet opens'); + var idleMin = s.settings && s.settings.idle_lock_min != null ? s.settings.idle_lock_min : (b.idle_lock ? (b.idle_lock_min || 5) : 0); + if (document.activeElement !== $('idle-lock')) $('idle-lock').value = String(LockScreen.IDLE_CHOICES.indexOf(Number(idleMin)) >= 0 ? idleMin : LockScreen.IDLE_DEFAULT_MIN); + setText('idle-lock-note', View.idleSentence(idleMin, w)); + if (document.activeElement !== $('ask-on-open')) $('ask-on-open').checked = !!(s.settings && s.settings.ask_on_open); + if (b.needs_enrol && !$('bio-err').textContent) $('bio-err').textContent = 'The password changed, so ' + w + ' was turned off. Turn it on again here.'; + var touch = !!(b.enrolled && bio.host); + $('backup-touch').hidden = !touch; $('backup-touch').querySelector('span').textContent = 'Show with ' + w; + $('export-touch').hidden = !touch; $('export-touch').querySelector('span').textContent = 'Show with ' + w; + setText('backup-eyebrow', s.backed_up ? 'written down' : 'not written down yet'); + if (document.activeElement !== $('start-login')) $('start-login').checked = !!s.settings.start_at_login; + // the update card + var u = s.update || {}; + setText('s-version', 'Igneum Wallet ' + s.version); + setText('s-update-note', View.updateNote(u, s.now)); + var ul = View.updateLine(u); + $('s-install').hidden = !(ul && ul.install) || u.applying; + if (document.activeElement !== $('auto-update')) $('auto-update').checked = !!s.settings.auto_update; + // the node + var n = s.node || {}; + setText('node-endpoint', n.evm || 'none yet'); + setText('node-source-line', View.cap(n.message || '')); + setText('node-eyebrow', View.sourceWords(n.source)); + setText('node-ids', 'chain id ' + (n.chain_id || '…') + ' · gRPC ' + (n.grpc || 'none') + ' · public RPC ' + (s.public_rpc || 'none in this build')); + // advanced + setText('s-mid', 'machine id ' + (s.machine_id || '').slice(0, 8)); + setText('s-dirs', 'logs ' + s.log_dir + ' · wallet ' + s.app_dir); + setText('s-miner-file', s.miner_wallet_present ? "The miner app's key file is on this machine; Import can read it." : "No miner key file on this machine."); + } + $('bio-toggle-btn').onclick = async function () { + var b = (state && state.biometric) || {}; + if (b.enrolled) { try { await post('/api/biometric/remove'); $('bio-err').textContent = ''; toast(what() + ' is off'); await poll(); } catch (e) { $('bio-err').textContent = e.message; } return; } + $('bio-enrol').hidden = false; $('bio-pw').focus({ preventScroll: true }); + }; + $('bio-enrol-cancel').onclick = function () { $('bio-enrol').hidden = true; $('bio-pw').value = ''; $('bio-err').textContent = ''; }; + $('bio-enrol-go').onclick = async function () { + $('bio-err').textContent = ''; + var pw = $('bio-pw').value; + if (!pw) { $('bio-err').textContent = 'type the password first'; return; } + $('bio-enrol-go').disabled = true; + try { + var r = await post('/api/biometric/enrol/begin', { password: pw }); + setLine($('bio-err'), '' + FP + esc('Waiting for ' + what()) + ''); + var h = await bio.call('enrol', { token: r.token }); + setLine($('bio-err'), bioLine(h, 'is on')); + if (!h.ok) { post('/api/biometric/enrol/cancel').catch(function () {}); $('bio-enrol-go').disabled = false; return; } + $('bio-pw').value = ''; $('bio-enrol').hidden = true; toast(what() + ' is on'); await poll(); + } catch (e) { $('bio-err').textContent = e.message; } + $('bio-enrol-go').disabled = false; + }; + $('idle-lock').onchange = async function (ev) { try { await post('/api/settings', { idle_lock_min: Number(ev.target.value) }); await poll(); } catch (e) { toast(e.message); } }; + $('ask-on-open').onchange = async function (ev) { try { await post('/api/settings', { ask_on_open: ev.target.checked }); } catch (e) { toast(e.message); } }; + $('pw-change').onclick = async function () { + $('pw-err').textContent = ''; + try { await post('/api/password', { old: $('pw-old').value, new: $('pw-new').value }); $('pw-old').value = ''; $('pw-new').value = ''; toast('Password changed'); await poll(); } + catch (e) { $('pw-err').textContent = e.message; } + }; + // backup and export: the ask in place, then the password field or the prompt + function resetBackup() { $('backup-pw-row').hidden = true; $('backup-pw').value = ''; $('backup-err').textContent = ''; $('backup-out').hidden = true; $('backup-words').innerHTML = ''; $('backup-key').textContent = ''; $('ask-backup').hidden = false; } + function resetExport() { $('export-pw-row').hidden = true; $('export-pw').value = ''; $('export-err').textContent = ''; $('export-out').hidden = true; $('export-key').textContent = ''; $('ask-export').hidden = false; } + function closeDisclosure(id) { var t = $(id); t.setAttribute('aria-expanded', 'false'); $(t.getAttribute('aria-controls')).hidden = true; } + $('backup-cancel').onclick = function () { resetBackup(); closeDisclosure('backup-toggle'); }; + $('export-cancel').onclick = function () { resetExport(); closeDisclosure('export-toggle'); }; + $('backup-use-pw').onclick = function () { $('backup-pw-row').hidden = false; $('backup-pw').focus({ preventScroll: true }); }; + $('export-use-pw').onclick = function () { $('export-pw-row').hidden = false; $('export-pw').focus({ preventScroll: true }); }; + function showBackup(r) { + $('backup-words').innerHTML = (r.words || []).map(function (w) { return '
  • ' + esc(w) + '
  • '; }).join(''); + $('backup-key').textContent = r.private_key; $('backup-out').hidden = false; $('ask-backup').hidden = true; $('backup-pw-row').hidden = true; + if (state && !state.backed_up) post('/api/backed-up').then(poll).catch(function () {}); + } + $('backup-show').onclick = async function () { + $('backup-err').textContent = ''; + try { var r = await post('/api/reveal', { password: $('backup-pw').value }); $('backup-pw').value = ''; showBackup(r); } + catch (e) { $('backup-err').textContent = e.message; } + }; + $('backup-pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); $('backup-show').click(); } }); + $('export-pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); $('export-show').click(); } }); + async function revealWithTouch(errEl) { + errEl.textContent = ''; + var c = await post('/api/biometric/challenge', { purpose: 'reveal' }); + setLine(errEl, '' + FP + esc('Waiting for ' + what()) + ''); + var h = await bio.call('confirm', { nonce: c.nonce }); + setLine(errEl, bioLine(h, 'confirmed')); + if (!h.ok) return null; + var r = await post('/api/reveal', { nonce: c.nonce }); + errEl.textContent = ''; + return r; + } + $('backup-touch').onclick = async function () { try { var r = await revealWithTouch($('backup-err')); if (r) showBackup(r); } catch (e) { $('backup-err').textContent = e.message; } }; + $('export-touch').onclick = async function () { try { var r = await revealWithTouch($('export-err')); if (r) { $('export-key').textContent = r.private_key; $('export-out').hidden = false; $('ask-export').hidden = true; } } catch (e) { $('export-err').textContent = e.message; } }; + $('export-show').onclick = async function () { + $('export-err').textContent = ''; + try { var r = await post('/api/reveal', { password: $('export-pw').value }); $('export-pw').value = ''; $('export-key').textContent = r.private_key; $('export-out').hidden = false; $('ask-export').hidden = true; $('export-pw-row').hidden = true; } + catch (e) { $('export-err').textContent = e.message; } + }; + $('backup-hide').onclick = function () { resetBackup(); }; + $('export-hide').onclick = function () { resetExport(); }; + // MetaMask + var netCache = null; + async function network() { if (!netCache) netCache = await api('/api/network'); return netCache; } + async function renderNetwork() { + try { + var n = await network(); + setText('net-line', n.chain_name + ' · chain id ' + n.chain_id + ' · ' + (n.rpc_url || 'no RPC yet')); + $('net-add').disabled = !n.add_network_page; + $('net-add').title = n.add_network_page ? '' : 'the site page is not set in this build; copy the settings instead'; + } catch (e) { setText('net-line', e.message); } + } + $('net-add').onclick = async function () { var n = await network(); if (n.add_network_page) post('/api/open', { url: n.add_network_page }); }; + $('net-copy').onclick = async function () { var n = await network(); copyText('Network name: ' + n.chain_name + '\nRPC URL: ' + n.rpc_url + '\nChain ID: ' + n.chain_id + '\nCurrency symbol: IGN\nDecimals: 18', 'Network settings'); }; + $('start-login').onchange = async function (ev) { try { await post('/api/settings', { start_at_login: ev.target.checked }); } catch (e) { toast(e.message); } }; + $('auto-update').onchange = async function (ev) { try { await post('/api/update/auto', { on: ev.target.checked }); } catch (e) { toast(e.message); } }; + $('s-update').onclick = function () { post('/api/update/check').catch(function () {}); toast('Checking for updates'); }; + $('s-install').onclick = function () { post('/api/update/install').catch(function () {}); toast('Installing now'); }; + // remove: the ask in place, never a dialog + $('remove-start').onclick = function () { $('ask-remove').hidden = false; $('remove-pw').focus({ preventScroll: true }); }; + $('ask-remove-no').onclick = function () { $('ask-remove').hidden = true; $('remove-pw').value = ''; $('remove-err').textContent = ''; }; + $('remove-go').onclick = async function () { + $('remove-err').textContent = ''; + try { await post('/api/remove', { password: $('remove-pw').value }); $('remove-pw').value = ''; $('ask-remove').hidden = true; await poll(); } + catch (e) { $('remove-err').textContent = e.message; } + }; + $('remove-pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); $('remove-go').click(); } }); + new MutationObserver(function () { if (document.body.dataset.page === 'settings' && document.body.dataset.phase === 'home') renderNetwork(); }).observe(document.body, { attributes: true, attributeFilter: ['data-page', 'data-phase'] }); + + // ---------- the update strip (one notice) and the update card ---------- + var stripH = 0, noticeSig = ''; + function renderNotices(s) { + var u = s.update, l = View.updateLine(u), strip = $('notices'); + var key = u.status + ':' + u.version; + var show = !!l && (u.urgent || u.applying || sessionStorage.getItem('update-later') !== key); + if (!show) { if (!strip.hidden) { strip.hidden = true; noticeSig = ''; layoutStrip(); } return; } + var acts = []; + if (l.install && !u.applying) acts.push({ act: 'install', label: 'Install now', primary: true }); + if (l.open) acts.push({ act: 'open', label: 'Open the download', primary: true }); + var sig = [key, l.text, l.urgent ? 'u' : '', l.prog ? 'p' : '', JSON.stringify(acts)].join('|'); + if (sig !== noticeSig) { + noticeSig = sig; + $('notice').className = 'notice' + (l.urgent ? ' urgent' : ''); + $('notice').dataset.key = key; + setText('notice-text', l.text); + $('notice-actions').innerHTML = acts.map(function (a) { return ''; }).join(''); + $('notice-close').hidden = !!l.urgent || !!u.applying; + $('notice-prog').hidden = !l.prog; + strip.hidden = false; + } + if (l.prog) $('notice-prog').firstElementChild.style.width = Math.round((u.progress || 0) * 100) + '%'; + layoutStrip(); + } + function layoutStrip() { + var strip = $('notices'), h = strip.hidden ? 0 : strip.offsetHeight; + if (h === stripH) return; + stripH = h; + var top = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--top'), 10) || 60; + $('main').style.top = h ? (top + h) + 'px' : ''; + } + window.addEventListener('resize', layoutStrip); + $('notice').addEventListener('click', function (e) { + var b = e.target.closest('[data-act]'); if (!b) return; + var act = b.dataset.act; + if (act === 'install') { post('/api/update/install').catch(function () {}); toast('Installing now'); } + else if (act === 'open') post('/api/update/open').catch(function () {}); + }); + $('notice-close').onclick = function () { sessionStorage.setItem('update-later', $('notice').dataset.key || ''); $('notices').hidden = true; noticeSig = ''; layoutStrip(); }; + + // the update card (update-card.js): one update, centred, over every screen; mem: open, later, seen; asked: Install + // now was pressed on this card and the engine has not flipped to applying yet + var updMem = { open: false, later: '', seen: '' }; + var updSig = '', updAsked = '', updMore = false; + var forcedUpdate = params.get('update'); + if (forcedUpdate && params.get('card') === '1') updMem.open = true; + function sampleUpdate(kind) { + var u = { status: 'ready', version: '0.1.6', url: '', notes: "The wallet on the miner's design: money first, one row per transaction with the node's state word, an Appearance setting, a bottom tab bar on a narrow window. Touch ID unchanged.", file: '', error: '', checked_at: Date.now() / 1000 - 40, available: true, downloaded: true, ready: true, applying: false, progress: 1, size: 20080717, auto: params.get('auto') !== '0', wait: 'installs as soon as nothing is being sent', urgent: false, urgent_text: '', unsupported: false, min_supported: '', updated_from: '', rolled_back: '' }; + if (!u.auto) u.wait = 'waiting for Install now (automatic updates are off)'; + if (kind === 'available') { u.status = 'available'; u.downloaded = false; u.ready = false; u.progress = 0; } + if (kind === 'downloading') { u.status = 'downloading'; u.downloaded = false; u.ready = false; u.progress = Math.min(0.97, 0.43 + ((Date.now() / 1000) % 60) / 110); } + if (kind === 'staging') { u.status = 'staging'; u.ready = false; } + if (kind === 'applying') { u.status = 'applying'; u.applying = true; } + if (kind === 'deferred') { u.status = 'deferred'; u.wait = 'waits for the next time someone is at this PC (Windows asks for permission)'; } + if (kind === 'urgent') { u.status = 'downloading'; u.ready = false; u.downloaded = false; u.progress = 0.27; u.urgent = true; u.urgent_text = 'Igneum Wallet 0.1.4 is no longer supported by the network. Installing 0.1.6 now.'; } + if (kind === 'manual') { u.status = 'manual'; u.ready = false; u.wait = '/Applications is not writable; open the downloaded disk image and drag the app over the old one'; } + if (kind === 'error') { u.status = 'error'; u.error = 'sha256 mismatch: the file is not what the manifest signed'; u.downloaded = false; u.ready = false; } + return u; + } + function cardCtx(s) { + var line = Array.prototype.some.call(document.querySelectorAll('.bio-state'), function (e) { return e.offsetParent !== null; }); + return { phase: document.body.dataset.phase, view: document.body.dataset.phase === 'home' ? page : '', bioBusy: bio.busy, bioLine: line, bioName: what(), quitting: !!s.quitting }; + } + function renderUpdateCard(s) { + var m = UpdateCard.model(s.update, s), d = UpdateCard.decide(m, cardCtx(s), updMem), wrap = $('upd'); + if (!d.show) { if (updMem.open || !wrap.hidden) { updMem.open = false; wrap.hidden = true; updSig = ''; } return; } + if (!updMem.open) { updMem.open = true; updMem.seen = m.version; updMore = false; if (updAsked !== m.key) updAsked = ''; } + var asked = updAsked === m.key && m.stage !== 'installing' && m.stage !== 'failed'; + var sig = [m.key, m.stage, m.line, m.note, m.cause, m.ring, asked ? 'asked' : '', m.lines.join('|')].join('\u0001'); + if (sig !== updSig) { + updSig = sig; + $('upd-name').textContent = m.name; + $('upd-line').textContent = m.line; + $('upd-cause').textContent = m.cause; $('upd-cause').hidden = !m.cause; + $('upd-notes').innerHTML = m.lines.map(function (l) { return '
  • ' + esc(l) + '
  • '; }).join(''); + $('upd-all').innerHTML = m.all.map(function (l) { return '
  • ' + esc(l) + '
  • '; }).join(''); + $('upd-more').hidden = !m.more; if (!m.more) updMore = false; + $('upd-more').textContent = updMore ? 'Less' : 'What changed'; $('upd-more').setAttribute('aria-expanded', updMore ? 'true' : 'false'); + $('upd-all').hidden = !updMore; + $('upd-mark').className = 'upd-mark ' + m.ring; + var acts = asked ? [{ act: 'install', label: m.stage === 'ready' || m.stage === 'deferred' ? 'Installing' : 'Installs when ready', primary: true, disabled: true }, { act: 'later', label: 'Later' }] : m.actions; + $('upd-actions').innerHTML = acts.map(function (a) { return ''; }).join(''); + wrap.dataset.dismiss = m.dismissable ? '1' : ''; + } + if (m.ring === 'progress') { $('upd-arc').style.strokeDashoffset = (276.5 * (1 - m.pct / 100)).toFixed(1); $('upd-pct').textContent = m.pct + '%'; $('upd-pct').hidden = m.stage !== 'downloading'; } + else { $('upd-arc').style.strokeDashoffset = ''; $('upd-pct').hidden = true; } + var meta = []; + if (m.stage === 'downloading' && m.size) meta.push(m.pct + '% of ' + m.size); + else if (m.size && m.stage === 'available') meta.push(m.size + ' download'); + else if (m.size && (m.stage === 'ready' || m.stage === 'manual' || m.stage === 'deferred' || m.stage === 'staging')) meta.push(m.size + ', downloaded'); + if (m.note) meta.push(m.note); + $('upd-meta').textContent = meta.join(' · '); + if (wrap.hidden) { wrap.hidden = false; $('upd-card').focus({ preventScroll: true }); } + } + function cardLater() { + if (!updMem.open || !$('upd').dataset.dismiss) return; + var m = UpdateCard.model(state && state.update, state || {}); + updMem.later = m ? m.key : updMem.later; updMem.open = false; $('upd').hidden = true; updSig = ''; + } + $('upd').addEventListener('click', function (e) { + if (e.target === $('upd')) { cardLater(); return; } + var b = e.target.closest('[data-act]'); if (!b || b.disabled) return; + var act = b.dataset.act, m = UpdateCard.model(state && state.update, state || {}); + if (act === 'later') cardLater(); + else if (act === 'install' || act === 'retry') { post('/api/update/install').catch(function () {}); updAsked = m ? m.key : ''; updSig = ''; toast(act === 'retry' ? 'Trying the update again' : m && m.stage === 'ready' ? 'Installing now' : 'Installs as soon as it is downloaded'); if (state) renderUpdateCard(state); } + else if (act === 'open') post('/api/update/open').catch(function () {}); + }); + $('upd-more').onclick = function () { updMore = !updMore; $('upd-all').hidden = !updMore; this.textContent = updMore ? 'Less' : 'What changed'; this.setAttribute('aria-expanded', updMore ? 'true' : 'false'); }; + + document.addEventListener('visibilitychange', function () { + if (document.hidden) return; + poll(); + if (state && state.phase === 'unlock' && !locking) focusLock(); + }); + + resetSend(); poll(); - // the window came back (menu bar, Dock): the button gets the focus so Return unlocks; never a sheet by itself - if (state && state.phase === 'unlock' && !locking) focusLock(); -}); -new MutationObserver(() => { if (document.body.dataset.view === 'settings') renderNetwork(); }).observe(document.body, { attributes: true, attributeFilter: ['data-view'] }); - -poll(); -setInterval(poll, 2000); + setInterval(poll, 2000); +})(); diff --git a/app/igneum-wallet/ui/index.html b/app/igneum-wallet/ui/index.html index c931301cc..281779d17 100644 --- a/app/igneum-wallet/ui/index.html +++ b/app/igneum-wallet/ui/index.html @@ -4,33 +4,67 @@ Igneum Wallet - + - + + + +
    -
    +
    - IGNEUMWALLET + IGNEUMWALLET +
    +
    starting
    - - +
    -
    -
    step 1 of 3
    +
    step 1 of 3

    Choose a password

    It locks the key on this machine. Nobody can reset it for you. At least 8 characters.

    - - + +