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/<hash>. 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 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 18:38:49 +00:00
parent 31ee0166d0
commit 39bad4a3e5
69 changed files with 2455 additions and 1190 deletions

View file

@ -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}
}

File diff suppressed because it is too large Load diff

View file

@ -4,33 +4,67 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Igneum Wallet</title>
<meta name="color-scheme" content="dark">
<meta name="color-scheme" content="dark light">
<link rel="icon" href="mark.svg" type="image/svg+xml">
<link rel="stylesheet" href="app.css">
</head>
<body data-phase="welcome" data-view="overview">
<body data-phase="welcome" data-page="home">
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><symbol id="i-fp" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4.8 7.7A9.2 9.2 0 0 1 12 4.1c1.5 0 2.9.3 4.1.9"/><path d="M5.2 16.6A12.5 12.5 0 0 1 4.6 12a7.4 7.4 0 0 1 14.8 0v4"/><path d="M8.5 20a9 9 0 0 1-1.3-4.7V12a4.8 4.8 0 0 1 9.6 0v1.5"/><path d="M12 11a1 1 0 0 1 1 1v2a6 6 0 0 1-1 3.3"/><path d="M15.6 20a10 10 0 0 0 .9-2.6"/></g></symbol></svg>
<!-- the rail: five sections (wallet-ui-3: Home, Send, Receive, History, Settings), Lock and Quit in the foot. Under
720 px it is a bottom tab bar. The welcome, create, import and lock screens have no rail. -->
<aside class="rail" id="rail" hidden>
<div class="rail-brand">
<img src="mark.svg" width="28" height="28" alt="">
<span class="word">IGNEUM</span>
</div>
<nav class="rail-nav" id="rail-nav" aria-label="Sections">
<button class="nav on" data-page="home" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 11 12 3l9 8"/><path d="M5 10v10h14V10"/></svg><span>Home</span></button>
<button class="nav" data-page="send"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5"/><path d="m5 12 7-7 7 7"/></svg><span>Send</span></button>
<button class="nav" data-page="receive"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></svg><span>Receive</span></button>
<button class="nav" data-page="history"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>History</span></button>
<button class="nav" data-page="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg><span>Settings</span><i class="nav-dot" id="nav-updates-dot" hidden></i></button>
</nav>
<div class="rail-foot">
<div class="rail-status mono" id="rail-status"></div>
<button class="nav small" id="btn-lock"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg><span>Lock</span></button>
<button class="nav small danger" id="btn-quit"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v9"/><path d="M6.5 6.5a8 8 0 1 0 11 0"/></svg><span>Quit</span></button>
<div class="rail-version mono" id="foot-version"></div>
</div>
</aside>
<header class="top">
<div class="brand">
<div class="brand" id="top-brand">
<img src="mark.svg" width="30" height="30" alt="">
<span class="word">IGNEUM</span><span class="miner">WALLET</span><span class="ver mono" id="ver"></span>
<span class="word">IGNEUM</span><span class="miner">WALLET</span>
</div>
<div class="page-title" id="page-title" hidden>
<h1 id="page-title-text">Home</h1>
<span class="page-sub" id="page-sub"></span>
</div>
<div class="top-right">
<div class="pill" id="pill"><span class="dot"></span><span id="pill-text">starting</span></div>
<button class="btn small ghost" id="btn-lock" hidden>Lock</button>
<button class="icon-btn" id="btn-settings" title="Settings" aria-label="Settings" hidden>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"></path></svg>
</button>
<button class="btn small ghost narrow-only" id="btn-lock-top" hidden>Lock</button>
</div>
</header>
<div class="banner update" id="update-banner" hidden>
<span id="update-text">A new version of Igneum Wallet is ready.</span>
<button class="btn small primary" id="update-install" hidden>Install now</button>
<button class="btn small primary" id="update-open" hidden>Open the download</button>
<button class="btn small ghost" id="update-later">Later</button>
<div class="prog" id="update-prog" hidden><i></i></div>
<!-- the status strip: the update line, one notice at a time -->
<div class="notices" id="notices" hidden>
<div class="notice" id="notice" role="status" aria-live="polite">
<span class="notice-text" id="notice-text"></span>
<span class="notice-actions" id="notice-actions"></span>
<button class="notice-close" id="notice-close" title="Close" aria-label="Close">&times;</button>
<span class="prog" id="notice-prog" hidden><i></i></span>
</div>
</div>
<!-- the quit question, in place of a dialog -->
<div class="ask-wrap" id="ask-quit" hidden>
<div class="ask">
<span class="ask-text">Quit Igneum Wallet? The bundled node stops with it.</span>
<button class="btn small primary" id="ask-quit-yes">Quit</button>
<button class="btn small ghost" id="ask-quit-no">Cancel</button>
</div>
</div>
<main id="main">
@ -38,12 +72,12 @@
<!-- welcome -->
<section class="screen" id="screen-welcome">
<div class="hero">
<div class="coin-wrap"><img class="coin" src="coin.png" width="148" height="148" alt=""></div>
<div class="mark-wrap"><img src="mark.svg" width="72" height="72" alt=""></div>
<div class="eyebrow ember" id="welcome-eyebrow">devnet v4 &middot; nothing is bought or sold</div>
<h1>Igneum Wallet</h1>
<p class="lead">Your IGN, your key, on this machine. The key is made here and never leaves this app.</p>
<p class="lead">Your IGN and your key, on this machine. The key is made here and never leaves this app.</p>
<div class="three">
<div class="tile"><div class="k">01</div><div class="t">Your key stays here</div><div class="s">Encrypted with a password you choose. Signing happens inside the app.</div></div>
<div class="tile"><div class="k">01</div><div class="t">Your key stays here</div><div class="s">Sealed with a password you choose. Signing happens inside the app.</div></div>
<div class="tile"><div class="k">02</div><div class="t">Final means verified</div><div class="s">A payment is final when this wallet has checked the network's certificate itself.</div></div>
<div class="tile"><div class="k">03</div><div class="t">Works with the miner</div><div class="s">Uses the miner app's node when it runs here. Imports the miner's key in one tap.</div></div>
</div>
@ -51,31 +85,31 @@
<button class="btn primary big" id="go-create">Create a wallet</button>
<button class="btn big" id="go-import">Import</button>
</div>
<div class="seedline">Nobody from Igneum will ever ask for your words or your key.</div>
<p class="seedline mono">Nobody from Igneum will ever ask for your words or your key.</p>
</div>
</section>
<!-- create -->
<section class="screen" id="screen-create">
<div class="step" id="create-1">
<div class="eyebrow ember">step 1 of 3</div>
<div class="eyebrow">step 1 of 3</div>
<h2>Choose a password</h2>
<p class="sub">It locks the key on this machine. Nobody can reset it for you. At least 8 characters.</p>
<label class="field"><span>Password</span><input type="password" id="create-pw" autocomplete="new-password"></label>
<label class="field"><span>Again</span><input type="password" id="create-pw2" autocomplete="new-password"></label>
<label class="field"><span class="k">Password</span><input type="password" id="create-pw" autocomplete="new-password"></label>
<label class="field"><span class="k">Again</span><input type="password" id="create-pw2" autocomplete="new-password"></label>
<div class="err" id="create-err"></div>
<div class="cta"><button class="btn primary big" id="create-next">Make my words</button><button class="btn ghost" id="create-back">Back</button></div>
</div>
<div class="step" id="create-2" hidden>
<div class="eyebrow ember">step 2 of 3</div>
<div class="eyebrow">step 2 of 3</div>
<h2>Write these 24 words down</h2>
<p class="sub">They are the wallet. Anyone with them has your IGN. They are shown once.</p>
<ol class="words" id="words"></ol>
<div class="note">Address <span class="mono" id="create-address"></span></div>
<div class="field"><div class="k">your address</div><div class="box mono"><span id="create-address"></span><button class="btn tiny" data-copy="create-address">Copy</button></div></div>
<div class="cta"><button class="btn primary big" id="create-written">I wrote them down</button></div>
</div>
<div class="step" id="create-3" hidden>
<div class="eyebrow ember">step 3 of 3</div>
<div class="eyebrow">step 3 of 3</div>
<h2>Type three of them</h2>
<p class="sub">So the paper is right before the words are gone from the screen.</p>
<div class="checks" id="checks"></div>
@ -87,31 +121,28 @@
<!-- import -->
<section class="screen" id="screen-import">
<div class="step">
<div class="eyebrow ember">import</div>
<div class="eyebrow">import</div>
<h2>Bring a key here</h2>
<p class="sub">Words from any wallet, a raw private key, or the key the miner app made on this machine.</p>
<div class="segs" id="import-mode">
<button class="seg on" data-mode="seed">24 words</button>
<button class="seg" data-mode="key">Private key</button>
<button class="seg" data-mode="miner" id="seg-miner">Miner's key</button>
<div class="seg" id="import-mode" role="radiogroup" aria-label="What to import">
<button class="seg-b on" data-mode="seed" role="radio" aria-checked="true">24 words</button>
<button class="seg-b" data-mode="key" role="radio" aria-checked="false">Private key</button>
<button class="seg-b" data-mode="miner" id="seg-miner" role="radio" aria-checked="false">Miner's key</button>
</div>
<label class="field" id="import-data-field"><span id="import-data-label">The words, in order</span><textarea id="import-data" rows="4" spellcheck="false" autocomplete="off"></textarea></label>
<div class="note" id="import-miner-note" hidden>The miner app's key file on this machine will be read. Rewards keep going to the same address.</div>
<label class="field"><span>New password for this machine</span><input type="password" id="import-pw" autocomplete="new-password"></label>
<label class="field"><span>Again</span><input type="password" id="import-pw2" autocomplete="new-password"></label>
<label class="field" id="import-data-field"><span class="k" id="import-data-label">The words, in order</span><textarea id="import-data" rows="4" spellcheck="false" autocomplete="off"></textarea></label>
<p class="note" id="import-miner-note" hidden>The miner app's key file on this machine will be read. Rewards keep going to the same address.</p>
<label class="field"><span class="k">New password for this machine</span><input type="password" id="import-pw" autocomplete="new-password"></label>
<label class="field"><span class="k">Again</span><input type="password" id="import-pw2" autocomplete="new-password"></label>
<div class="err" id="import-err"></div>
<div class="cta"><button class="btn primary big" id="import-go">Import</button><button class="btn ghost" id="import-back">Back</button></div>
</div>
</section>
<!-- the lock screen (lock-screen.js, app.js onLockScreen): the coin on the ember glow, the name, the short
address, one primary control. Touch ID enrolled in the app window: the fingerprint button, its line, a quiet
"Use password" that reveals the field in place. Otherwise the password field is the control. The system sheet
is raised by a tap, Return or Space on the button; once by itself on the first arrival after the person opened
the app, when the setting is on. -->
<!-- the lock screen (lock-screen.js, app.js onLockScreen; 0.1.4, kept): the mark on the ember glow, the name, the
short address, one primary control. -->
<section class="screen lock" id="screen-unlock">
<div class="lock-body">
<div class="lock-coin"><span class="lock-glow" aria-hidden="true"></span><img class="coin" src="coin.png" width="220" height="220" alt=""></div>
<div class="lock-coin"><span class="lock-glow" aria-hidden="true"></span><div class="mark-wrap lock-mark"><img src="mark.svg" width="88" height="88" alt=""></div></div>
<h1 class="lock-title">Igneum Wallet</h1>
<p class="lock-address mono" id="unlock-address"></p>
<div class="lock-controls">
@ -126,182 +157,224 @@
</form>
<div class="err lock-err" id="unlock-err"></div>
</div>
<div class="seedline lock-foot">Forgot it? Only the 24 words or the key open this wallet again.</div>
<p class="seedline lock-foot">Forgot it? Only the 24 words or the key open this wallet again.</p>
</div>
</section>
<!-- home -->
<!-- home: five pages behind the rail -->
<section class="screen" id="screen-home">
<div class="view" id="view-overview">
<div class="balance-card">
<div class="eyebrow">balance</div>
<div class="balance-row">
<img class="coin small" src="coin.png" width="56" height="56" alt="">
<div>
<div class="balance"><span id="balance">0</span><span class="unit">IGN</span></div>
<div class="mono reading" id="balance-note" hidden></div>
</div>
<!-- Home -->
<section class="page" id="page-home" data-page="home">
<div class="card hero-card">
<div class="bal-row">
<div class="bal" id="h-bal"><span class="v" id="h-balance">0</span><span class="unit mono">IGN</span></div>
<p class="line-text bal-line" id="h-balance-line"></p>
</div>
<div class="addr-row"><span class="mono" id="home-address"></span><button class="btn tiny" id="copy-address">Copy</button></div>
<div class="actions">
<p class="money-line" id="h-money"></p>
<div class="addr-big mono"><span id="home-address">not set</span><button class="btn small" data-copy="home-address">Copy</button></div>
<div class="row wrap actions">
<button class="btn primary big" id="go-send">Send</button>
<button class="btn big" id="go-receive">Receive</button>
<span class="note" id="backup-note" hidden>Your words are not written down yet. <a href="#" id="backup-link">Back up now</a>.</span>
</div>
<div class="note" id="backup-note" hidden>Your words have not been written down yet. <a href="#" id="backup-link">Back up now</a>.</div>
</div>
<div class="split">
<div class="card">
<div class="eyebrow">node</div>
<div class="card node-card" id="node-card">
<button class="disclose" id="node-toggle" aria-expanded="false" aria-controls="node-details">
<span class="node-line"><i class="dot" id="node-dot"></i><span id="node-line-text">Node starting</span></span>
<span class="disclose-right"><span class="dim" id="node-sub"></span><span class="chev" aria-hidden="true"></span></span>
</button>
<div class="details" id="node-details" hidden>
<div class="kv" id="node-kv"></div>
</div>
<div class="card">
<div class="eyebrow">finality</div>
<div class="kv" id="fin-kv"></div>
</div>
</div>
<div class="card">
<div class="eyebrow">history <span class="dim" id="scan-note"></span></div>
<div class="history" id="history"></div>
<div class="card-head"><h3>Recent</h3><button class="btn small ghost" id="recent-all">All history</button></div>
<div class="hist" id="recent"></div>
</div>
</div>
</section>
<div class="view" id="view-send">
<div class="step">
<div class="eyebrow ember">send</div>
<h2>Send IGN</h2>
<div id="send-form">
<label class="field"><span>To</span><input type="text" id="send-to" class="mono" placeholder="0x..." spellcheck="false" autocomplete="off"></label>
<label class="field"><span>Amount, IGN</span><input type="text" id="send-amount" class="mono" placeholder="0.0" inputmode="decimal" autocomplete="off"></label>
<!-- Send -->
<section class="page" id="page-send" data-page="send" hidden>
<div class="card">
<div class="card-head"><h3>Send IGN</h3><span class="eyebrow" id="send-eyebrow"></span></div>
<div class="send-form" id="send-form">
<label class="field"><span class="k">To</span><input type="text" id="send-to" class="mono" placeholder="0x" spellcheck="false" autocomplete="off"></label>
<label class="field"><span class="k">Amount</span><div class="row"><input type="text" id="send-amount" class="mono" placeholder="0.0" inputmode="decimal" autocomplete="off"><span class="unit-word mono">IGN</span><span class="note" id="send-balance"></span></div></label>
<div class="fee-row">
<span class="k mono">fee</span>
<span class="line-text" id="fee-line">shown when you review</span>
<button class="btn tiny ghost" id="fee-toggle" aria-expanded="false" aria-controls="fee-details" hidden>Details</button>
</div>
<p class="help" id="fee-details" hidden></p>
<div class="err" id="send-err"></div>
<div class="cta"><button class="btn primary big" id="send-quote">Review</button><button class="btn ghost" id="send-cancel">Cancel</button></div>
<div class="cta" id="send-cta"><button class="btn primary big" id="send-quote">Review</button></div>
</div>
<div id="send-confirm" hidden>
<div class="confirm">
<div class="row"><span>Amount</span><b class="mono" id="c-amount"></b></div>
<div class="row"><span>To</span><b class="mono small" id="c-to"></b></div>
<div class="row"><span>Fee, at most</span><b class="mono" id="c-fee"></b></div>
<div class="row total"><span>Leaves the wallet, at most</span><b class="mono" id="c-total"></b></div>
</div>
<p class="note" id="c-fee-note"></p>
<div class="err" id="confirm-send-err"></div>
<div class="note" id="send-bio-line"></div>
<div class="cta"><button class="btn primary big" id="send-go"><svg class="fp-ico" width="20" height="20" hidden><use href="#i-fp"></use></svg><span id="send-go-text">Send now</span></button><button class="btn ghost" id="send-edit">Edit</button></div>
<div class="ask inline" id="ask-send" hidden>
<span class="ask-text" id="ask-send-text"></span>
<button class="btn small primary" id="send-go"><svg class="fp-ico" width="16" height="16" hidden><use href="#i-fp"></use></svg><span id="send-go-text">Send now</span></button>
<button class="btn small ghost" id="ask-send-no">Cancel</button>
<span class="ask-foot" id="send-bio-line"></span>
<span class="ask-foot err" id="confirm-send-err"></span>
</div>
<div id="send-done" hidden>
<h3>Sent</h3>
<p class="note">It is pending until a block carries it, then in a block, then final once this wallet verifies the checkpoint over it.</p>
<p class="mono small" id="sent-hash"></p>
<div class="cta"><button class="btn primary" id="sent-view">View</button><button class="btn ghost" id="sent-home">Done</button></div>
<div class="sent" id="send-done" hidden>
<div class="hrow sent-row" id="sent-row"></div>
<div class="box mono"><span id="sent-hash"></span><button class="btn tiny" data-copy="sent-hash">Copy</button></div>
<div class="cta"><button class="btn small" id="sent-view">View in History</button><button class="btn small ghost" id="sent-again">Send another</button></div>
</div>
</div>
</div>
</section>
<div class="view" id="view-receive">
<div class="step">
<div class="eyebrow ember">receive</div>
<h2>Your address</h2>
<div class="qr" id="qr"></div>
<p class="mono addr-big" id="receive-address"></p>
<div class="cta"><button class="btn primary" id="receive-copy">Copy address</button><button class="btn ghost" id="receive-back">Back</button></div>
<p class="note">Only IGN on the Igneum network. Rewards from the miner app land here when the miner pays to this address.</p>
</div>
</div>
<div class="view" id="view-tx">
<div class="step">
<div class="eyebrow ember">transaction</div>
<h2 id="tx-title">Transfer</h2>
<div class="finality-line" id="tx-finality"></div>
<div class="kv" id="tx-kv"></div>
<div class="cta"><button class="btn ghost" id="tx-back">Back</button></div>
</div>
</div>
<div class="view" id="view-settings">
<div class="step">
<div class="eyebrow ember">settings</div>
<h2>Settings</h2>
<div class="version-row mono" id="version-row"></div>
<div class="card" id="bio-card"><h3 id="bio-title">Touch ID</h3>
<p class="note" id="bio-note">Unlock the wallet, confirm each send and show the backup with a fingerprint. The password still works everywhere.</p>
<div class="err" id="bio-err"></div>
<div id="bio-off">
<div class="inline"><input type="password" id="bio-pw" placeholder="Password" autocomplete="current-password"><button class="btn small primary fp" id="bio-enrol"><svg width="16" height="16"><use href="#i-fp"></use></svg><span id="bio-enrol-text">Turn on Touch ID</span></button></div>
<p class="note small" id="bio-off-note"></p>
</div>
<div id="bio-on" hidden>
<label class="switch"><input type="checkbox" id="ask-on-open"><span id="ask-on-open-text">Ask for Touch ID when the wallet opens</span></label>
<div class="inline idle-row"><span class="idle-label">Lock when idle</span><select class="select" id="idle-lock" aria-label="Lock when idle">
<option value="1">after 1 minute</option>
<option value="5">after 5 minutes</option>
<option value="15">after 15 minutes</option>
<option value="60">after 1 hour</option>
<option value="0">never</option>
</select><span class="note small" id="idle-lock-note"></span></div>
<div class="inline"><button class="btn small" id="bio-remove">Turn off</button><span class="note" id="bio-on-note"></span></div>
</div>
<!-- Receive -->
<section class="page" id="page-receive" data-page="receive" hidden>
<div class="card">
<div class="card-head"><h3>Your address</h3></div>
<div class="addr-big mono"><span id="receive-address">not set</span><button class="btn small" data-copy="receive-address">Copy</button></div>
<div class="qr-row">
<div class="qr" id="qr"></div>
<p class="help">Only IGN on the Igneum network. Rewards from the miner app land here when it pays this address. The code is drawn on this machine.</p>
</div>
</div>
</section>
<div class="card"><h3>Backup</h3>
<p class="note" id="backup-note-text">Show the 24 words (or the key) again. Needs the password.</p>
<div class="inline"><input type="password" id="backup-pw" placeholder="Password" autocomplete="current-password"><button class="btn small" id="backup-show">Show</button><button class="btn small fp" id="backup-touch" hidden><svg width="16" height="16"><use href="#i-fp"></use></svg><span>Show with Touch ID</span></button></div>
<!-- History -->
<section class="page" id="page-history" data-page="history" hidden>
<div class="card">
<div class="card-head"><h3>History</h3><span class="eyebrow" id="hist-eyebrow"></span></div>
<div class="hist" id="history"></div>
</div>
</section>
<!-- Settings -->
<section class="page" id="page-settings" data-page="settings" hidden>
<div class="card">
<div class="card-head"><h3>Security</h3></div>
<div class="srow" id="bio-row">
<div class="sw-text"><b id="bio-title">Touch ID</b> <span class="dim" id="bio-sentence"></span></div>
<div class="row"><button class="btn small" id="bio-toggle-btn">Turn on</button></div>
</div>
<div class="details indent" id="bio-enrol" hidden>
<div class="row wrap"><input type="password" class="addr-input" id="bio-pw" placeholder="Your password" autocomplete="current-password"><button class="btn small primary fp" id="bio-enrol-go"><svg width="16" height="16"><use href="#i-fp"></use></svg><span id="bio-enrol-text">Turn on Touch ID</span></button><button class="btn small ghost" id="bio-enrol-cancel">Cancel</button></div>
</div>
<p class="note indent" id="bio-off-note" hidden></p>
<div class="err indent" id="bio-err"></div>
<label class="switch" id="ask-on-open-row"><input type="checkbox" id="ask-on-open"><span class="track"></span><span class="sw-text"><b id="ask-on-open-text">Ask for Touch ID when the wallet opens</b> <span class="dim">Once, when you open the app. Never after an idle lock.</span></span></label>
<div class="srow" id="idle-row">
<div class="sw-text"><b>Lock when idle</b> <span class="dim" id="idle-lock-note"></span></div>
<select class="select" id="idle-lock" aria-label="Lock when idle">
<option value="1">after 1 minute</option>
<option value="5">after 5 minutes</option>
<option value="15">after 15 minutes</option>
<option value="60">after 1 hour</option>
<option value="0">never</option>
</select>
</div>
<button class="disclose" id="pw-toggle" aria-expanded="false" aria-controls="pw-details"><span>Change password</span><span class="chev" aria-hidden="true"></span></button>
<div class="details" id="pw-details" hidden>
<div class="row wrap"><input type="password" class="addr-input" id="pw-old" placeholder="Current" autocomplete="current-password"><input type="password" class="addr-input" id="pw-new" placeholder="New, 8 or more" autocomplete="new-password"><button class="btn small" id="pw-change">Change</button></div>
<p class="help">Touch ID is turned off by a password change; turn it on again above.</p>
<div class="err" id="pw-err"></div>
</div>
</div>
<div class="card">
<div class="card-head"><h3>Backup</h3><span class="eyebrow" id="backup-eyebrow"></span></div>
<button class="disclose" id="backup-toggle" aria-expanded="false" aria-controls="backup-details"><span>Show my words</span><span class="chev" aria-hidden="true"></span></button>
<div class="details" id="backup-details" hidden>
<div class="ask inline" id="ask-backup">
<span class="ask-text">Show the 24 words and the key on screen? Anyone who sees them has your IGN.</span>
<button class="btn small primary fp" id="backup-touch" hidden><svg width="16" height="16"><use href="#i-fp"></use></svg><span>Show with Touch ID</span></button>
<button class="btn small" id="backup-use-pw">Use password</button>
<button class="btn small ghost" id="backup-cancel">Cancel</button>
</div>
<div class="row wrap" id="backup-pw-row" hidden><input type="password" class="addr-input" id="backup-pw" placeholder="Your password" autocomplete="current-password"><button class="btn small primary" id="backup-show">Show</button></div>
<div class="err" id="backup-err"></div>
<div id="backup-out" hidden>
<ol class="words small" id="backup-words"></ol>
<p class="note small">Private key <span class="mono" id="backup-key"></span></p>
<button class="btn small ghost" id="backup-hide">Hide</button>
<div class="field"><div class="k">private key</div><div class="box mono key"><span id="backup-key"></span><button class="btn tiny" data-copy="backup-key">Copy</button><button class="btn tiny ghost" id="backup-hide">Hide</button></div></div>
</div>
</div>
<div class="card"><h3>Password</h3>
<div class="inline"><input type="password" id="pw-old" placeholder="Current" autocomplete="current-password"><input type="password" id="pw-new" placeholder="New, 8 or more" autocomplete="new-password"><button class="btn small" id="pw-change">Change</button></div>
<div class="err" id="pw-err"></div>
</div>
<div class="card"><h3>Export to MetaMask</h3>
<p class="note">Add the network, then import the private key. The key leaves this app only when you copy it here.</p>
<div class="kv" id="net-kv"></div>
<div class="inline">
<button class="btn small" id="net-add">Add to MetaMask</button>
<button class="btn small" id="net-copy">Copy network settings</button>
</div>
<div class="warn-box">
<b>Export the private key</b>
<p class="note">A copied key can be stolen from the clipboard, a screenshot or a notes app. Paste it straight into MetaMask and clear the clipboard.</p>
<div class="inline"><input type="password" id="export-pw" placeholder="Password" autocomplete="current-password"><button class="btn small danger" id="export-show">Show the key</button><button class="btn small danger fp" id="export-touch" hidden><svg width="16" height="16"><use href="#i-fp"></use></svg><span>Show with Touch ID</span></button></div>
<div class="err" id="export-err"></div>
<div id="export-out" hidden><p class="mono small" id="export-key"></p><button class="btn tiny" id="export-copy">Copy key</button> <button class="btn tiny ghost" id="export-hide">Hide</button></div>
<button class="disclose" id="export-toggle" aria-expanded="false" aria-controls="export-details"><span>Export the key for MetaMask</span><span class="chev" aria-hidden="true"></span></button>
<div class="details" id="export-details" hidden>
<p class="help">A copied key can be stolen from the clipboard, a screenshot or a notes app. Paste it straight into MetaMask and clear the clipboard.</p>
<div class="ask inline" id="ask-export">
<span class="ask-text">Show the private key on screen? Anyone who sees it has your IGN.</span>
<button class="btn small primary fp" id="export-touch" hidden><svg width="16" height="16"><use href="#i-fp"></use></svg><span>Show with Touch ID</span></button>
<button class="btn small" id="export-use-pw">Use password</button>
<button class="btn small ghost" id="export-cancel">Cancel</button>
</div>
<div class="row wrap" id="export-pw-row" hidden><input type="password" class="addr-input" id="export-pw" placeholder="Your password" autocomplete="current-password"><button class="btn small primary" id="export-show">Show</button></div>
<div class="err" id="export-err"></div>
<div class="box mono key" id="export-out" hidden><span id="export-key"></span><button class="btn tiny" data-copy="export-key">Copy</button><button class="btn tiny ghost" id="export-hide">Hide</button></div>
</div>
<div class="card"><h3>Network</h3>
<div class="kv" id="settings-node-kv"></div>
<div class="srow top-gap">
<div class="sw-text"><b>MetaMask network</b> <span class="dim mono" id="net-line"></span></div>
<div class="row"><button class="btn small" id="net-add">Add to MetaMask</button><button class="btn small ghost" id="net-copy">Copy settings</button></div>
</div>
<div class="card"><h3>This machine</h3>
<label class="switch"><input type="checkbox" id="start-login"><span>Start Igneum Wallet at login</span></label>
<label class="switch"><input type="checkbox" id="auto-update"><span>Install updates by itself when nothing is being sent</span></label>
<div class="kv" id="machine-kv"></div>
<div class="inline"><button class="btn small primary" id="update-install-s" hidden>Install now</button><button class="btn small" id="update-check">Check for updates</button><span class="note" id="update-note"></span></div>
</div>
<div class="card danger-card"><h3>Remove this wallet from this machine</h3>
<p class="note">The vault file is deleted. Only your 24 words or the key bring it back.</p>
<div class="inline"><input type="password" id="remove-pw" placeholder="Password" autocomplete="current-password"><button class="btn small danger" id="remove-go">Remove</button></div>
<div class="err" id="remove-err"></div>
</div>
<div class="cta"><button class="btn ghost" id="settings-back">Back</button></div>
</div>
</div>
<div class="card">
<div class="card-head"><h3>This machine</h3></div>
<label class="switch"><input type="checkbox" id="start-login"><span class="track"></span><span class="sw-text"><b>Start at login</b> <span class="dim">Opens when you sign in. The wallet locks itself when idle.</span></span></label>
<div class="field">
<div class="k">appearance</div>
<div class="seg" id="theme-seg" role="radiogroup" aria-label="Appearance">
<button class="seg-b" data-theme="system" role="radio" aria-checked="true">System</button>
<button class="seg-b" data-theme="light" role="radio" aria-checked="false">Light</button>
<button class="seg-b" data-theme="dark" role="radio" aria-checked="false">Dark</button>
</div>
</div>
</div>
<div class="card">
<div class="lead-row">
<div class="lead-text">
<h3 id="s-version">Igneum Wallet</h3>
<p class="help" id="s-update-note">Not checked yet.</p>
</div>
<div class="row">
<button class="btn small primary" id="s-install" hidden>Install now</button>
<button class="btn small" id="s-update">Check</button>
</div>
</div>
<label class="switch"><input type="checkbox" id="auto-update"><span class="track"></span><span class="sw-text"><b>Install updates by itself</b> <span class="dim">Downloads in the background and installs when nothing is being sent.</span></span></label>
</div>
<div class="card">
<div class="card-head"><h3>Node</h3><span class="eyebrow" id="node-eyebrow"></span></div>
<div class="field">
<div class="k">endpoint in use</div>
<div class="box mono"><span id="node-endpoint">none yet</span><button class="btn tiny" data-copy="node-endpoint">Copy</button></div>
</div>
<p class="line-text" id="node-source-line"></p>
<p class="help">The wallet picks the miner's node when it runs on this machine, else its own bundled node, else the public RPC. Finality is verified only with a node here.</p>
<p class="note mono small" id="node-ids"></p>
</div>
<details class="card adv" id="s-advanced">
<summary><h3>Advanced</h3><span class="eyebrow">this machine</span></summary>
<p class="note mono small" id="s-mid"></p>
<p class="note mono small" id="s-dirs"></p>
<p class="note" id="s-miner-file"></p>
<div class="row wrap top-gap"><button class="btn small danger" id="remove-start">Remove this wallet from this machine</button></div>
<div class="ask inline" id="ask-remove" hidden>
<span class="ask-text">Remove this wallet from this machine? The vault file is deleted. Only your 24 words or the key bring it back.</span>
<input type="password" class="addr-input short-pw" id="remove-pw" placeholder="Your password" autocomplete="current-password" aria-label="Password">
<button class="btn small primary" id="remove-go">Remove</button>
<button class="btn small ghost" id="ask-remove-no">Cancel</button>
<span class="ask-foot err" id="remove-err"></span>
</div>
</details>
</section>
</section>
</main>
<!-- the update card (update-card.js, app.js renderUpdateCard): one update, centred; Later, Escape or the backdrop
leaves the banner above -->
leaves the strip above -->
<div class="upd-wrap" id="upd" hidden>
<div class="upd-card" id="upd-card" role="dialog" aria-modal="true" aria-labelledby="upd-name" aria-describedby="upd-line" tabindex="-1">
<div class="upd-mark" id="upd-mark">

View file

@ -0,0 +1,161 @@
// node --test app/igneum-wallet/ui/view.test.mjs (no dependencies)
// Loads the View block of app.js (plain browser JS: the file is run with `module` defined and no `document`, so only
// the pure block executes) and checks the words each page shows for a given state (wallet-ui-3).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js'), 'utf8');
const mod = { exports: {} };
new Function('module', src)(mod);
const V = mod.exports.View;
const ME = '0x7e5f4552091a69125d5dfcb7b8c2659029395bdf', B = '0x2b5ad5c4795c026514f8317c7a215e218dccd6cf';
const NOW = 1_791_000_000;
const node = (over) => ({ source: 'miner', state: 'ok', chain_id: 7762959, chain: 'igneum-devnet-20', block: 140286, evm: 'http://127.0.0.1:26790', grpc: '127.0.0.1:26610', synced: true, message: "using the miner app's node on this machine", finality_active: true, latest_locked: 4674, ...over });
const fin = (over) => ({ verified_index: 4674, verified_hash: '0xf1', chain_number: 140246, signers: 11, voters: 12, fraction_total: 0.912, fraction_active: 0.98, weights_exact: true, verified_at: NOW - 14, message: 'checkpoint 4674 verified here', ...over });
const st = (over) => ({ version: '0.1.5', phase: 'home', balance: '31.6321', balance_known: true, scanning: false, scanned_to: 140286, node: node(), finality: fin(), settings: { network: 'devnet', start_at_login: false, auto_update: true, idle_lock_min: 5, ask_on_open: true }, update: { status: 'current', version: '0.1.5', checked_at: NOW - 780 }, now: NOW, ...over });
const entry = (over) => ({ hash: '0x' + 'c3d4'.repeat(16), kind: 'received', block: 140262, block_hash: '0xabcd', from: B, to: ME, value: '250000000000000000', fee: '25380000000000', ok: true, time: NOW - 140, finality: 'in_block', checkpoint: null, note: '', ...over });
test('the five pages and their order', () => {
assert.deepEqual(V.PAGES.map((p) => p.id), ['home', 'send', 'receive', 'history', 'settings']);
assert.equal(V.page('history').title, 'History');
assert.equal(V.page('nonsense').id, 'home');
});
test('numbers: IGN from wei, short addresses, times', () => {
assert.equal(V.ign('1500000000000000000'), '1.5');
assert.equal(V.ign('250000000000000000', 6), '0.25');
assert.equal(V.ign('10140000000000000000', 4), '10.14');
assert.equal(V.ign('0'), '0');
assert.equal(V.ign('nonsense'), '0');
assert.equal(V.ign('76140000000000', 9), '0.00007614');
assert.equal(V.shortHex('0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf'), '0x7E5F45\u20265Bdf');
assert.equal(V.shortHex(''), '');
assert.equal(V.withCommas(140286), '140,286');
assert.equal(V.ago(14), '14 s ago'); assert.equal(V.ago(780), '13 min ago'); assert.equal(V.ago(7200), '2 h ago');
assert.match(V.timeWord(NOW - 140, NOW), /^\d\d:\d\d$/);
assert.match(V.timeWord(NOW - 86400 * 3, NOW), /^\d+ \w+ \d\d:\d\d$/);
});
test('the balance: the number only when known, else one line that says why (never a 0)', () => {
assert.deepEqual(V.balanceLine(st()), { known: true, text: '' });
assert.deepEqual(V.balanceLine(st({ balance_known: false })), { known: false, text: 'reading the balance' });
assert.deepEqual(V.balanceLine(st({ balance_known: false, scanning: true, scanned_to: 61200, node: node({ block: 140270 }) })), { known: false, text: 'reading the chain, 61,200 of 140,270 blocks' });
assert.deepEqual(V.balanceLine(st({ balance_known: false, node: node({ state: 'starting' }) })), { known: false, text: 'waiting for the node to start' });
assert.deepEqual(V.balanceLine(st({ balance_known: false, node: node({ state: 'off', source: 'none' }) })), { known: false, text: 'waiting for a node' });
});
test('money first when a price exists; else the one grey line, devnet or not', () => {
assert.deepEqual(V.moneyLine(st()), { money: '', text: 'no market price on devnet: coins have no value' });
assert.deepEqual(V.moneyLine(st({ settings: { network: 'testnet' } })), { money: '', text: 'no market price yet' });
assert.deepEqual(V.moneyLine(st({ price_gbp_per_ign: 0.5 })), { money: '\u00a315.82', text: "at today's price" });
// a price with an unknown balance is no money line
assert.equal(V.moneyLine(st({ price_gbp_per_ign: 0.5, balance_known: false })).money, '');
});
test('the node line: the state word, the source in plain words, the block, the verification', () => {
assert.deepEqual(V.nodeLine(st()), { text: "Node synced \u00b7 the miner app's node \u00b7 block 140,286 \u00b7 verified to checkpoint 4,674", sub: '', tone: 'ok' });
const pub = st({ node: node({ source: 'public', grpc: '', finality_active: false, latest_locked: 0, message: 'using the public RPC; no node here, so finality cannot be verified' }), finality: fin({ verified_index: 0, chain_number: null, message: 'no node here' }) });
assert.equal(V.nodeLine(pub).text, 'Node synced \u00b7 the public RPC \u00b7 block 140,286 \u00b7 certificates cannot be verified without a node');
const own = st({ node: node({ source: 'own', state: 'starting', block: 0, message: 'the bundled node is starting; the chain syncs from the seed' }), finality: fin({ verified_index: 0 }) });
assert.deepEqual(V.nodeLine(own), { text: 'Node starting \u00b7 the bundled node', sub: 'the bundled node is starting; the chain syncs from the seed', tone: '' });
const none = st({ node: node({ source: 'none', state: 'off', block: 0, message: 'no node: install Igneum Miner, or wait for the bundled node' }), finality: fin({ verified_index: 0 }) });
assert.deepEqual(V.nodeLine(none), { text: 'Node not found', sub: 'no node: install Igneum Miner, or wait for the bundled node', tone: 'bad' });
const lost = V.nodeLine(st({ node: node({ state: 'lost', message: 'history: connect 127.0.0.1:26790: Connection refused (os error 61)' }) }));
assert.equal(lost.tone, 'bad'); assert.equal(lost.sub, 'the node stopped answering; trying again');
assert.equal(V.nodeLine(st({ finality: fin({ verified_index: 0 }) })).text, "Node synced \u00b7 the miner app's node \u00b7 block 140,286 \u00b7 waiting for the first certificate");
assert.equal(V.sourceWords('external'), 'the node named by the environment');
});
test('the node details: every old card row with a one-line meaning, no n/a', () => {
const rows = V.nodeDetails(st(), NOW);
const keys = rows.map((r) => r[0]);
assert.deepEqual(keys, ['source', 'chain', 'block', 'endpoint', 'finality on the node', 'verified here', 'signed by', 'checkpoint height', 'weights', 'checked']);
assert.equal(rows[5][1], 'checkpoint 4,674'); assert.equal(rows[5][3], 'ok');
assert.equal(rows[6][1], '11 of 12 voters'); assert.match(rows[6][2], /^91\.2% of all weight/);
assert.equal(rows[9][1], '14 s ago');
for (const r of rows) assert.ok(!/n\/a/.test(r[1]) && r[1] !== '', r[0]);
const none = V.nodeDetails(st({ node: node({ source: 'public', evm: 'https://rpc.example', finality_active: false }), finality: fin({ verified_index: 0, message: 'no node here' }) }), NOW);
assert.equal(none.find((r) => r[0] === 'finality on the node')[1], 'not checkable without a node');
assert.deepEqual(none.find((r) => r[0] === 'verified here').slice(1), ['nothing yet', 'no node here', 'dim']);
});
test('the pill: the wallet\'s own words', () => {
assert.deepEqual(V.pillWords(st()), { text: 'synced', cls: 'on' });
assert.deepEqual(V.pillWords(st({ node: node({ source: 'public' }) })), { text: 'public rpc', cls: 'on' });
assert.deepEqual(V.pillWords(st({ node: node({ state: 'starting' }) })), { text: 'starting', cls: '' });
assert.deepEqual(V.pillWords(st({ node: node({ state: 'off', source: 'none' }) })), { text: 'no node', cls: 'warn' });
assert.deepEqual(V.pillWords(st({ node: node({ state: 'lost' }) })), { text: 'node lost', cls: 'warn' });
assert.deepEqual(V.pillWords(st({ phase: 'unlock' })), { text: 'locked', cls: '' });
assert.deepEqual(V.pillWords(st({ quitting: true })), { text: 'stopping', cls: '' });
});
test('one state word per row: the wallet\'s verified final wins, failed from the receipt, else the node\'s word, else the label', () => {
const e = entry();
assert.deepEqual(V.stateWord(entry({ finality: 'final', checkpoint: 4673, block: 139900 }), 'executed'), { word: 'finalised', tone: 'ok', why: 'under checkpoint 4,673, verified here' });
assert.deepEqual(V.stateWord(entry({ finality: 'failed', ok: false }), 'executed'), { word: 'failed', tone: 'bad', why: 'the execution failed; the fee was still paid' });
assert.deepEqual(V.stateWord(e, 'pending'), { word: 'pending', tone: '', why: 'waiting for a block' });
assert.deepEqual(V.stateWord(e, 'included'), { word: 'included', tone: '', why: 'in block 140,262, not executed yet' });
assert.deepEqual(V.stateWord(e, 'executed'), { word: 'executed', tone: 'ink', why: 'in block 140,262' });
assert.deepEqual(V.stateWord(e, 'proven'), { word: 'proven', tone: 'ink', why: 'in block 140,262, proof paid' });
// the node says finalised but this wallet has not verified the certificate: the word, not the molten tone
assert.deepEqual(V.stateWord(e, 'finalised'), { word: 'finalised', tone: 'ink', why: 'under a locked checkpoint, not yet verified here' });
assert.deepEqual(V.stateWord(e, 'finality not active'), { word: 'finality not active', tone: 'ink', why: 'under a locked checkpoint; finality is paused on the network' });
assert.equal(V.stateWord(e, 'unknown').word, 'pending');
// no node word yet (the public RPC, or not asked): the wallet's label
assert.deepEqual(V.stateWord(e, ''), { word: 'included', tone: '', why: 'in block 140,262' });
assert.deepEqual(V.stateWord(entry({ finality: 'pending', block: 0 }), ''), { word: 'pending', tone: '', why: 'waiting for a block' });
assert.equal(V.stateWord(entry({ finality: 'pending', block: 0 }), 'included').why, 'in a block, not executed yet');
// a reward has no transaction hash: executed in its block, finalised under a verified checkpoint
const r = entry({ hash: 'reward-140201-0', kind: 'reward', from: '', block: 140201 });
assert.deepEqual(V.stateWord(r, ''), { word: 'executed', tone: 'ink', why: 'in block 140,201' });
assert.equal(V.stateWord(entry({ hash: 'proving-1-0', kind: 'proving', finality: 'final', checkpoint: 9 }), '').word, 'finalised');
// never a stronger word than the chain's: the words the row can show are exactly the node's plus failed
const words = new Set(['pending', 'included', 'executed', 'proven', 'finalised', 'finality not active', 'failed']);
for (const w of ['pending', 'included', 'executed', 'proven', 'finalised', 'finality not active', 'unknown', '']) assert.ok(words.has(V.stateWord(e, w).word), w);
});
test('the row model: kind word, who, sign, amount, the rows that need the node\'s word', () => {
const m = V.rowModel(entry(), 'included', NOW);
assert.equal(m.kind, 'Received'); assert.equal(m.who, 'from 0x2b5ad5\u2026d6cf'); assert.equal(m.amount, '+0.25'); assert.equal(m.in, true); assert.equal(m.synthetic, false); assert.equal(m.nodeWord, 'included');
const s = V.rowModel(entry({ kind: 'sent', from: ME, to: B, value: '1500000000000000000' }), '', NOW);
assert.equal(s.kind, 'Sent'); assert.equal(s.who, 'to 0x2b5ad5\u2026d6cf'); assert.equal(s.amount, '\u22121.5'); assert.equal(s.in, false);
assert.equal(V.rowModel(entry({ kind: 'self', from: ME, to: ME }), '', NOW).who, 'to yourself');
assert.equal(V.rowModel(entry({ hash: 'reward-1-0', kind: 'reward' }), '', NOW).who, 'block reward');
assert.equal(V.rowModel(entry({ hash: 'proving-1-0', kind: 'proving' }), '', NOW).kind, 'Proving payout');
const list = [entry({ hash: '0x01', finality: 'pending' }), entry({ hash: '0x02' }), entry({ hash: 'reward-1-0', kind: 'reward' }), entry({ hash: '0x03', finality: 'final', checkpoint: 1 }), entry({ hash: '0x04', finality: 'failed' }), entry({ hash: '0x05' })];
assert.deepEqual(V.needsNodeWord(list, 12), ['0x01', '0x02', '0x05']);
assert.deepEqual(V.needsNodeWord(list, 2), ['0x01', '0x02']);
});
test('send: the fee line, the gas words behind Details, the question in place', () => {
const q = { to: B, display_to: '0x2B5AD5c4795c026514f8317c7a215E218DCCD6cF', value_ign: '1.5', fee_max_ign: '0.00007614', total_max_ign: '1.50007614', gas: 25380, base_fee_gwei: '1', tip_gwei: '1', max_fee: '3000000000' };
assert.deepEqual(V.feeWords(null), { line: 'shown when you review', detail: '' });
const f = V.feeWords(q);
assert.equal(f.line, 'at most 0.00007614 IGN');
assert.equal(f.detail, 'Gas 25,380 at a base fee of 1 gwei (burned) plus a 1 gwei tip (to the miner), capped at 3 gwei; what is not used comes back.');
assert.equal(V.gwei('1500000000'), '1.5 gwei'); assert.equal(V.gwei('25000000000'), '25 gwei'); assert.equal(V.gwei('250000'), '0.00025 gwei'.replace('0.00025', '0'));
assert.deepEqual(V.sendAsk(q, true, 'Touch ID'), { text: 'Send 1.5 IGN to 0x2B5AD5\u2026D6cF? Fee at most 0.00007614 IGN; 1.50007614 IGN leaves the wallet at most.', button: 'Confirm with Touch ID' });
assert.equal(V.sendAsk(q, false).button, 'Send now');
assert.equal(V.sendAsk(q, true, 'Windows Hello').button, 'Confirm with Windows Hello');
});
test('settings: the Touch ID sentence, the idle sentence, the update card note', () => {
assert.equal(V.bioSentence({ enrolled: true }, 'Touch ID', true), 'Touch ID is on. It unlocks the wallet, confirms each send and shows the backup; the password still works everywhere.');
assert.equal(V.bioSentence({ enrolled: false, available: true }, 'Touch ID', false), 'Touch ID needs the Igneum Wallet app window; this page is open in a browser.');
assert.equal(V.bioSentence({ enrolled: false, available: false }, 'Touch ID', true), 'Touch ID is not set up on this Mac.');
assert.equal(V.bioSentence({ enrolled: false, available: false, message: 'Windows Hello is not configured.' }, 'Windows Hello', true), 'Windows Hello is not configured.');
assert.equal(V.bioSentence({ enrolled: false, available: true }, 'Windows Hello', true), 'Unlock, confirm each send and show the backup with Windows Hello. The password still works everywhere.');
assert.equal(V.idleSentence(5, 'Touch ID'), 'The key is cleared after 5 minutes without you; Touch ID or the password opens it again.');
assert.equal(V.idleSentence(0, 'Touch ID'), 'The wallet stays open until you lock it.');
assert.equal(V.updateNote({ status: 'current', version: '0.1.5', checked_at: NOW - 780 }, NOW), 'Up to date, checked 13 min ago.');
assert.equal(V.updateNote({ status: 'unknown', version: '' }, NOW), 'Not checked yet.');
assert.equal(V.updateNote({ status: 'ready', version: '0.1.6', wait: 'installs as soon as nothing is being sent' }, NOW), 'Igneum Wallet 0.1.6 is ready. Installs as soon as nothing is being sent.');
assert.equal(V.updateNote({ status: 'current', version: '0.1.5', updated_from: '0.1.4', checked_at: NOW - 60 }, NOW), 'Updated from 0.1.4. Up to date, checked 1 min ago.');
assert.equal(V.updateNote({ status: 'off', version: '' }, NOW), 'Updates are off in this build.');
assert.equal(V.updateLine({ status: 'downloading', version: '0.1.6', size: 20080717, progress: 0.43 }).text, 'Downloading Igneum Wallet 0.1.6 (20 MB): 43%');
assert.equal(V.updateLine({ status: 'current', version: '0.1.5' }), null);
});

View file

@ -130,7 +130,6 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
window.center()
window.delegate = self
window.isReleasedWhenClosed = false
window.appearance = NSAppearance(named: .darkAqua)
let conf = WKWebViewConfiguration()
conf.userContentController.add(self, name: "biometric") // the page's Touch ID bridge (Biometric.swift)
web = WKWebView(frame: window.contentView!.bounds, configuration: conf)

View file

@ -0,0 +1,209 @@
# Igneum Wallet UI 3: the audit, 6 October 2026
the project lead, 18:5x UK: "rebuild the wallet to the same standard" as the redesigned miner (miner-ui-3, shipped in Igneum Miner
0.3.14), "and update the site on that too". Branch `wallet-ui-3`, worktree `../igneum-wt-wallet-ui`, from `wallet-0.1.5`
1fa6fa7 (the shipper's 0.1.5 skeleton: wallet-v1 and release-0.3.13 merged, the version files at 0.1.5). The design is
`docs/plans/wallet-ui-3.md`; this file is what was found. The standard is `docs/plans/miner-ui-3.md` and its audit.
## 1. How it was looked at
| What | How |
|---|---|
| Every wallet screen | `tools/ui-mock/wallet.mjs` on port 4320 (new in this branch): a stand-in engine that serves `app/igneum-wallet/ui` and answers `/api/state` from a scenario. No vault, no key and no node were read; every address is one of the well-known test keys (`0x7E5F…5Bdf`, `0x2B5A…D6cF`). the project lead's live wallet data were not touched. |
| The captures | `tools/ui-mock/shoot-wallet.mjs`: Playwright's own chromium (the cached Chrome for Testing build, headless, never Chrome.app) at 1280 x 820 (the shipped window is 1120 x 780, the hosts' floor 900 x 620), 900 x 600 and 390 x 844, dark and light. |
| States this Mac cannot show | the scenarios: `welcome`, `unlock` (Touch ID enrolled), `home` (the miner app's node, a verified checkpoint, a history with every state), `public` (the public RPC, no certificate verification), `scanning` (the bundled node syncing), `nonode`, `empty`, `update` (an update ready; the card opens by itself). |
Screenshots: `docs/plans/wallet-ui-3/00-*.png` to `24-*.png`, named below. The build's screenshots carry the same names
with an `n` in front. `05-unlock-password` did not capture: the old lock screen's "Use password" link never became
clickable under the mock (the control area keeps its height and the link sits under the fingerprint button's line);
the 0.1.4 lock screen is kept as it is, so the build's `n05` covers it.
## 2. What a first-time wallet user sees, screen by screen
Rating: 10 = nothing to change. The three questions per screen, as the miner's audit: what does it say in jargon,
which number has no unit or no meaning, which 0 or blank has no reason word. A fourth for a wallet: where does the
user's money sit, and what does each state word let them do.
### 2.1 Welcome (`00-welcome.png`), 8/10
Good: one heading, one lead, three plain tiles (your key stays here, final means verified, works with the miner), two
buttons, the "nobody will ask for your words" line. The eyebrow "devnet v4 · nothing is bought or sold" is honest.
| Problem | Where |
|---|---|
| The coin is the retired ringed coin (`brand/README.md`: retired as a source; the miner-ui-3 welcome uses the mark). | the hero |
| 60 px headline, 148 px coin, three tiles and two buttons need 1,000 px of height; at 900 x 620 (the hosts' floor) the buttons sit under the fold. | whole screen |
### 2.2 Create (`01-create-password.png`, `02-create-words.png`), 8/10
Good: three steps named, one sentence each, the words in a numbered grid, three typed back before the words are gone.
Keep. The button "Make my words" is right; "Open my wallet" on step 3 is right.
| Problem | Where |
|---|---|
| The address under the words is a 42-character string with no Copy and no word for what it is beyond "Address". | step 2 |
| The step eyebrows are ember, the miner's are ash: two conventions for one thing. | the eyebrows |
### 2.3 Import (`03-import.png`), 8/10
Good: three modes as a segmented control, the miner's key as the third, the note that rewards keep going to the same
address. Keep.
### 2.4 The lock screen (`04-unlock-touch.png`), 9/10
The 0.1.4 lock screen is the best screen in the app: the glow, the name, the short address, one control, the quiet
"Use password". It keeps the retired coin; the build swaps the mark in and keeps everything else, including
`lock-screen.js` and its tests.
### 2.5 Home (`06-home.png`, lower `07-home-lower.png`), 6/10
What a wallet user sees first. Good: the balance large, the address with Copy, Send and Receive on the card, the
history on the same page.
| Problem | Where |
|---|---|
| No money. "31.6321 IGN" with no pounds line and no word for why there is none. The miner's Earnings says "nothing is bought or sold on devnet" on its money line; the wallet says it only on the welcome eyebrow. | the balance |
| Two engine cards side by side: Node (source, chain, block, finality, note: "igneum-devnet-20", "active, locked 4674", "using the miner app's node on this machine") and Finality ("verified here checkpoint 4674", "signed 11 of 12 voters, 91.2% of weight", "chain height 140,246", "weights at the checkpoint", "checked 6 Oct 19:05"). Ten rows of plumbing between the balance and the history. A wallet user needs one line: the node is synced and the wallet has verified up to a checkpoint. | the two cards |
| The history's state column is the wallet's label in capitals: "PENDING", "IN BLOCK 140262", "FINAL · CP 4673", "FAILED". The node has five state words for a transaction (`igneum_getTransactionStatus`: pending, included, executed, proven, finalised; `vendor/igneum-node` `igneum/exec/src/rpc.rs`, "ONE state word, never a stronger word than the chain's own state") and the wallet shows none of them: "in block" covers included, executed and proven alike. | the history |
| No reason under a state: "PENDING" (waiting for a block), "FAILED" (the execution failed; the fee was paid) are bare. | the history |
| "SENT", "RECEIVED", "REWARD", "PROVING", "SELF" pills in capitals; "to 0x2b5ad5…ccd6cf" in lower-case hex while the balance card shows the checksummed form. | the history |
| The amount column is the only coloured cell and it colours incoming molten; the state word, which is what changes, is grey. | the history |
| The pill in the top bar: "MINER NODE · BLOCK 140,286" in capitals: the node's source and the chain height, neither of which a wallet user asked for. | top bar |
| Settings is behind a gear icon; Receive and History have no place of their own; the page is one long scroll. | the chrome |
Apple would: the balance first with its money line, Send and Receive, one node line with Details behind it, then
the five newest rows with "All history". The engine rows go behind Details.
### 2.6 Send (`08-send.png`, review `09-send-review.png`, sent `10-send-sent.png`), 7/10
Good: two fields, Review, a review card with the amount, the address, the fee at most and what leaves the wallet at
most; the Touch ID confirm on the Send button; a Sent screen.
| Problem | Where |
|---|---|
| The fee note is a 40-word formula: "Fee = gas × price. Gas 25,380. Price = base fee 1 gwei (burned by the network) + tip 1 gwei (to the miner), capped at 3.000 gwei per gas; what is not used comes back." Gas, gwei, base fee, tip, cap: five terms a wallet user never chose. | the review |
| The review is a second screen, not a confirmation in place: Edit goes back, Send now goes on, and the two fields are gone from view. | the review |
| The Sent screen's one paragraph "It is pending until a block carries it, then in a block, then final once this wallet verifies the checkpoint over it." is the right idea as three state words, written as a sentence; the hash is a bare 66-character line with no Copy. | sent |
| No balance on the form: "Amount, IGN" with no "of 31.6321" beside it, so the only way to learn the limit is the quote's refusal. | the form |
### 2.7 Receive (`11-receive.png`), 8/10
Good: the QR drawn locally, the address, Copy address, one note. Keep. The address is a paragraph, not a box with
Copy beside it, so the eye finds the button under it rather than next to it.
### 2.8 Transaction (`12-tx-final.png`), 6/10
| Problem | Where |
|---|---|
| "FINAL · under checkpoint 4673, certificate verified by this wallet" in a mono box, then eight key-value rows: amount, when, from, to, fee paid, hash, block "139,900 · 0x0d9c…9c0d", verified checkpoint "4674 at chain height 140,246". Right facts, engine order. | whole screen |
| It is a page of its own with Back; the history row it came from is out of view. | the chrome |
### 2.9 Settings (`13-settings.png`, `14-settings-lower.png`, `15-settings-lowest.png`), 5/10
The page with the most work in it. Seven cards, 2,000 px tall, every one with a password field.
| Problem | Where |
|---|---|
| Three password fields on one page (Backup, Export the private key, Remove this wallet) plus two for the password change. Each sits open on the page waiting to be typed into, where the miner uses a question in place that opens only when asked. | Backup, Export, Password, Remove |
| The switches are the browser's checkboxes (fixed 5 October 2026 to be visible at all), not the miner's track switch; the labels have no second sentence. | Touch ID, This machine |
| "Export to MetaMask" lists network name, chain id "7762959 (0x76770f)", rpc url, symbol, decimals, then a warning box, then the password field. "Network" repeats source "miner", "ethereum rpc http://127.0.0.1:26790", "grpc 127.0.0.1:26610", chain id, network, public rpc. "This machine" lists machine "d937c69d", version, logs path, "miner key file present". Three cards of engine facts. | MetaMask, Network, This machine |
| No Appearance. No light mode (`<meta name="color-scheme" content="dark">`, one token set). | whole page |
| The version line "Igneum Wallet 0.1.5 · up to date" sits as a mono line under the heading; the update controls (Check for updates, Install now, the auto-update checkbox) sit two cards down inside "This machine". The miner has one update card. | version-row, This machine |
| "Remove this wallet from this machine" uses `confirm()`, a dialog the viewer never shows (the brief's rule, as the miner's quit). | `app.js` `remove-go` |
| The node endpoint cannot be changed; the engine has no route for it (the source is chosen by `node.rs`: the miner's node, else the bundled node, else the public RPC). Shown, not settable. | Network |
| The Back button at the foot is the only way out besides the gear. | foot |
Apple would: Settings as plain rows with one sentence each, grouped: Security (Touch ID, ask on open, lock when idle,
change password), Backup (show my words, export the key, MetaMask), This machine (start at login, Appearance), one
update card, Node (the endpoint in use), Advanced (the ids, the folders, remove).
### 2.10 The public RPC, scanning, no node, empty (`16-home-public.png`, `17-home-scanning.png`, `18-home-nonode.png`, `19-home-empty.png`), 6/10
| Problem | Where |
|---|---|
| Public RPC: "finality not checkable without a node" and "no node here: certificates cannot be verified" in ember, twice, in two cards; the history's "IN BLOCK" rows carry no word that they will stay there. The pill says "PUBLIC NODE" when there is no node. | 16 |
| Scanning: "reading the chain, 61,200 of 140,270 blocks" under a dimmed balance "0": a 0 in the hero while the real number is unknown. | 17 |
| No node: balance "0" dimmed with "waiting for a node"; the Node card says "no node: install Igneum Miner, or wait for the bundled node". Right words, wrong place (a card, not the node line). | 18 |
| Empty: "Nothing yet. Receive IGN, or point the miner app at this address." is good. "Your words have not been written down yet. Back up now." as a note under the buttons is good. Keep both. | 19 |
### 2.11 The update card (`20-update-card.png`), 9/10
The miner's card: the mark with the ring, "Igneum Wallet 0.1.6", one line, three notes, the size, Install now and
Later. `update-card.js` already says `NAME = 'Igneum Wallet'` on this branch (the miner-ui-3 owed row "the wallet's copy
still says Igneum Ember" was true of wallet-v1 before 0.1.3 and is closed here). Keep.
### 2.12 Narrow (`21-narrow-900-home.png`, phone `22-phone-home.png`, `23-phone-settings.png`)
900 x 600: usable; the two engine cards squeeze, the history's four columns hold. 390 wide: broken. The pill wraps to
four lines over the gear, "31.6321 IGN" overflows the card to the right, the Node and Finality cards' values wrap one
character per line ("ch / ec / kp / oi / nt"), Settings' password fields overflow. No phone layout exists.
### 2.13 Light mode (`24-light-home.png`)
None. The page is dark whatever the OS says. The Mac host also pins `window.appearance = darkAqua`
(`app/mac/IgneumWallet.swift` `buildWindow`), so even a page that followed `prefers-color-scheme` would see dark.
## 3. Every feature the old UI has (the build's tick list)
| # | Feature | Where it is today | Route |
|---|---|---|---|
| 1 | Welcome: create or import | welcome | none |
| 2 | Create: password, 24 words, three typed back | create, 3 steps | `api/create`, `api/create/confirm` |
| 3 | Import: 24 words, a private key, the miner's key file (disabled when absent) | import | `api/import` |
| 4 | The lock screen: Touch ID button, the one automatic prompt, Use password, the line under the button | unlock (`lock-screen.js`) | `api/unlock` (the host), the host bridge |
| 5 | Lock (button, the host's menu, the idle lock with the activity ping every 20 s) | top bar | `api/lock`, `api/activity` |
| 6 | Balance in IGN, "reading the chain N of M blocks", "reading the balance", "waiting for a node" | home | state |
| 7 | Address with Copy | home | state |
| 8 | "Your words have not been written down yet. Back up now." | home | state `backed_up` |
| 9 | Send: to, amount, the quote (address, amount, balance checks), the review (amount, to, fee at most, leaves at most, the fee note), Touch ID confirm or Send now, Edit, the Sent screen with the hash, View, Done | send | `api/send/quote`, `api/send`, the host `confirm` |
| 10 | Receive: the QR (SVG from the engine), the address, Copy address | receive | `api/receive` |
| 11 | History: up to 60 rows, kind, who, when, amount, the wallet's label (pending, in block N, final · cp N, failed), a click opens the transaction | home | state `history` |
| 12 | Transaction: the finality line, amount, when, from, to, fee paid, hash, block and block hash, note, the verified checkpoint | tx | state (`api/tx/<hash>` exists and is unused by the page) |
| 13 | Node: source, chain, block, finality active and latest locked, the engine's message | home card | state `node` |
| 14 | Finality: verified checkpoint, signers of voters and the weight fraction, chain height, weights exact or latest, checked at; else the message | home card | state `finality` |
| 15 | Touch ID: turn on (password once, then the prompt), turn off, "Ask when the wallet opens", lock when idle (1, 5, 15, 60, never), the needs-enrol line after a password change, the "needs the app window" and "not set up" lines | settings | `api/biometric/*`, `api/settings` |
| 16 | Backup: show the words and the key with the password or Touch ID, Hide, marks backed up | settings | `api/reveal`, `api/backed-up` |
| 17 | Change the password | settings | `api/password` |
| 18 | MetaMask: network name, chain id, RPC, symbol, decimals; Add to MetaMask (the site page); Copy network settings; export the key with the password or Touch ID, Copy key, Hide | settings | `api/network`, `api/open`, `api/reveal` |
| 19 | Network: source, Ethereum RPC, gRPC, chain id, network, public RPC | settings | state |
| 20 | This machine: start at login, install updates by itself, machine id, version, logs folder, miner key file present, Check for updates, Install now, the update note | settings | `api/settings`, `api/update/*` |
| 21 | Remove this wallet (password, a `confirm()` dialog) | settings | `api/remove` |
| 22 | The update banner (one strip) and the update card (`update-card.js`); Later per version and stage; the urgent path | everywhere | `api/update/install`, `open` |
| 23 | The pill: source and block, starting, connecting, public rpc, stopping, engine gone | top bar | state |
| 24 | The version in the header and the version line in Settings | chrome | state |
| 25 | The welcome eyebrow names the network | welcome | state `settings.network` |
| 26 | `?bio=touch`, `?update=<state>[&auto=0][&card=1]`, `?host=mac` | URL | none |
| 27 | The quit path (the host's menu; `api/quit` exists, no button) | none | `api/quit` |
What the engine has that the page never shows: `api/tx/<hash>` with `node_status` (the node's one state word per
transaction, `igneum_getTransactionStatus`), `events` (the engine's own activity lines: "sent 1.5 IGN to …", "earned
10.14 IGN as a block reward"), `uptime_s`, `app_dir`. What no route gives: a market price (`price_gbp_per_ign`, owed by
the engine as in the miner's plan, section 11), a settable node endpoint.
## 4. The top ten findings
| # | Finding | Consequence per tier |
|---|---|---|
| 1 | No money line and no reason for its absence: "31.6321 IGN" alone. The miner's Earnings says why there is no £. | Every user. A wallet that shows a number with no value and no word for it reads as a placeholder. |
| 2 | The node's five state words (pending, included, executed, proven, finalised) are not shown; the history says "in block" for three of them and the design rule (one state word, never stronger than the chain's) is not applied. | Every user; most for a sender waiting to know whether the other side can rely on a payment. |
| 3 | Ten rows of node and finality plumbing on the home screen between the balance and the history. | Every user; a home miner's wallet should read like the miner's Mine page: one node line, Details behind it. |
| 4 | Settings is seven cards with three open password fields and browser checkboxes; the update controls are split between a mono line at the top and a card at the bottom; no Appearance. | Every user. |
| 5 | The send fee is a 40-word gas formula; the review is a second screen rather than a question in place; the Sent screen's states are a sentence. | Every sender. |
| 6 | A `confirm()` dialog on Remove (the viewer never shows one). | Every user who removes a wallet: the button appears to do nothing. |
| 7 | No layout under 900 px: at 390 the pill covers the gear, the balance overflows, the engine cards wrap letter by letter. | Windows laptop users with a half-screen window; the Windows host's window can be dragged narrow. |
| 8 | No light mode on the page and the Mac host pins dark appearance. | Every Mac user on a light desktop. |
| 9 | Capitals everywhere a label sits (SENT, RECEIVED, IN BLOCK, MINER NODE · BLOCK 140,286): the engine's vocabulary shouted. | Every user. |
| 10 | The chrome has no sections: Settings behind a gear, Receive and History without a place, Back buttons at the foot of each view. | Every user. |
Smaller, fixed in the build with no mention elsewhere: the retired ringed coin on the welcome and the lock screen; the
ember step eyebrows; the lower-case hex in the history rows; the bare hash on the Sent screen; the address under the
words with no Copy; "PUBLIC NODE" when there is no node; the dimmed "0" while the balance is unknown.
## 5. What the earlier wallet work set out to do and what it achieved
0.1.0 to 0.1.4 (4 to 5 October 2026) built the engine's surface one screen at a time: the vault and the words, the
quote and the send, the QR, the history with rewards, the certificate verification, Touch ID, the update card, the
lock screen. Each screen is honest and complete; the lock screen and the update card are already at the standard.
What it did not do: put the screens on one design (the chrome is the miner's 0.3.3 top bar with a gear), say what the
balance is worth or why it cannot, use the node's state words, or lay out under 900 px. UI 3 keeps every feature,
every route and the two finished screens, and puts the rest on the miner's system.

246
docs/plans/wallet-ui-3.md Normal file
View file

@ -0,0 +1,246 @@
# Igneum Wallet UI 3: the wallet on the miner's system
6 October 2026. the project lead, 18:5x UK: "rebuild the wallet to the same standard" as the redesigned miner (miner-ui-3, in
Igneum Miner 0.3.14), "and update the site on that too". The audit is `docs/plans/wallet-ui-3-audit.md`. Branch
`wallet-ui-3`, worktree `../igneum-wt-wallet-ui`, from `wallet-0.1.5` 1fa6fa7. The 0.1.5 shipper takes it into the
wallet cut; this branch never touches a release branch. The Rust engine is read as it is: every route the page needs
exists (`server.rs`), so `src/` is untouched. One host line changes (section 9).
## 1. The one job of each screen
| Screen | Its one job | What it holds | What left it |
|---|---|---|---|
| Welcome, Create, Import | a wallet in under a minute, the words written down | as 0.1.4, the mark instead of the retired coin, the address with Copy under the words, ash step eyebrows | the coin |
| Lock screen | one tap back in | as 0.1.4 (`lock-screen.js` and its tests unchanged), the mark on the glow | the coin |
| Home | how much is here, and can it be relied on | the balance in IGN with its money line, the address with Copy, Send and Receive, the node line with Details, the five newest rows with "All history" | the Node and Finality cards (to the node line's Details), the long history (to its page) |
| Send | one payment, confirmed where it was typed | address, amount with the balance beside it, the fee line, Review turns the form into a question in place, then the pending row | the second screen, the gas formula, the Sent paragraph |
| Receive | the address, two ways | the address in a box with Copy, the QR, one line | nothing |
| History | one row per transaction with the node's word | every row: kind, who, when, amount, the state word with its reason; a row opens its details in place | the capitals, the wallet-only label, the transaction page |
| Settings | the switches, one sentence each | Security, Backup, This machine (with Appearance), one update card named Igneum Wallet, Node (the endpoint), Advanced | the seven cards, the three open password fields, the browser checkboxes, the `confirm()` |
Five sections in the rail: Home, Send, Receive, History, Settings. Lock and Quit in the foot. The rail keeps the
arrow keys and `aria-current`, as the miner's. Under 720 px it is a bottom tab bar with the five sections; Lock stays
in the top bar; Quit lives in the host's menu (the Mac and Windows hosts both have one).
## 2. Home
```
31.6321 IGN no market price on devnet: coins have no value
0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf [Copy] [Send] [Receive]
● Node synced · the miner app's node · block 140,286 · verified to checkpoint 4674 Details ⌄
Recent All history
row · row · row · row · row
```
| Element | Rule |
|---|---|
| The balance | `state.balance` in Unbounded at `--t-h1`; "IGN" in mono beside it. While `balance_known` is false the number is not shown: the line reads `reading the balance` (node ok), `reading the chain, 61,200 of 140,270 blocks` (scanning) or `waiting for a node` (no node) in ash. Never a 0 for an unknown. |
| The money line | with `price_gbp_per_ign` on the state (owed by the engine, as the miner's plan section 11): `≈ £12.34` with "at today's price" in ash. Without it on devnet: `no market price on devnet: coins have no value`. Without it elsewhere: `no market price yet`. Money first when it exists; one grey line explaining its absence when it does not. |
| The address | the checksummed form in a box, Copy beside it (`data-copy`). The backup note `Your words are not written down yet.` with "Back up now" under it while `backed_up` is false, as before. |
| Send, Receive | the two buttons; Send is the page's one primary. |
| The node line | `View.nodeLine(state)`: the state word first (`synced`, `starting`, `connecting`, `lost`, `off`), then the source in plain words (`the miner app's node`, `the bundled node`, `the public RPC`, `the node named by the environment`), the block, and the verification: `verified to checkpoint 4674` when `finality.verified_index` is set, `certificates cannot be verified without a node` on the public RPC, `waiting for the first certificate` otherwise. The dot is molten when ok, ember when lost or off, ash while starting. |
| Node details | the old Node and Finality cards' facts as `kv` rows with one-line meanings: source (the engine's message), chain (`igneum-devnet-20`, chain id), block, the endpoint in use with Copy, finality on the node (`active, locked 4674` or `paused` or `not checkable without a node`), the verified checkpoint (`4674`, "the newest certificate this wallet checked itself"), signed (`11 of 12 voters, 91.2% of all weight`), the checkpoint's chain height, weights (`taken at the checkpoint` or `the latest table`), checked (`14 s ago`). |
| Recent | the five newest history rows (section 5's row), then "All history" opens the History page. Empty: `Nothing yet. Receive IGN, or point the miner app at this address.` |
## 3. Send
```
To [0x… ]
Amount [1.5 ] IGN of 31.6321 IGN
Fee at most 0.00007614 IGN Details ⌄
[Review]
```
Review posts `api/send/quote` and turns the card's foot into the question, in place, the fields still visible above
it and locked:
```
Send 1.5 IGN to 0x2B5A…D6cF? Fee at most 0.00007614 IGN; 1.50007614 IGN leaves the wallet at most.
[Confirm with Touch ID] [Cancel]
```
| Element | Rule |
|---|---|
| The fee line | before a quote: `the fee is shown when you review`; after: `at most 0.00007614 IGN`. Details opens one grey line: `Gas 25,380 at a base fee of 1 gwei (burned) plus a 1 gwei tip (to the miner), capped at 3 gwei; what is not used comes back.` The numbers are the quote's (`gas`, `base_fee_gwei`, `tip_gwei`, `max_fee`). |
| The balance beside the amount | `of 31.6321 IGN` from `state.balance`; absent while the balance is unknown. |
| The ask | `View.sendAsk(quote)`: the words above; the button reads `Confirm with Touch ID` (or Windows Hello) when `confirm_needed` and the app window is the host, else `Send now`; the host-missing case keeps the old sentence in ember under the ask. Cancel unlocks the fields. |
| After the send | the card's foot becomes the pending row: `● pending · waiting for a block` with the hash short and Copy, then `View in History` and `Send another`. The row's word follows the node (section 5) on every poll, so the user can watch it go pending → included → executed without leaving. |
| Errors | the quote's refusals (not an address, more than the balance, the zero address) under the fields in ember, as before. |
The update card never opens while the Send page is up (`UpdateCard.blocked`, `ctx.view === 'send'`, unchanged).
## 4. Receive
The address in a box with Copy, the QR under it (240 px, the engine's SVG), one line: `Only IGN on the Igneum
network. Rewards from the miner app land here when it pays this address.` The QR is fetched when the page opens
(`api/receive`), as before.
## 5. History: one row per transaction, the node's word
```
↓ Received from 0x2B5A…D6cF · 19:03 +0.25 IGN ● included in block 140,262, not executed yet ⌄
↑ Sent to 0x2B5A…D6cF · 19:06 −1.5 IGN ● pending waiting for a block
◆ Reward block reward · 19:01 +10.14 IGN ● executed in block 140,201
```
| Part | Rule |
|---|---|
| Kind | a word, not a pill: Sent, Received, To yourself, Reward, Proving payout; an arrow glyph in ash before it. |
| Who | `from 0x2b5a…d6cf` or `to …` as the engine gives it (lower-case hex, the first 6 and last 4), `block reward`, `shard payout`; the time as `19:03`, with the date when not today. The checksummed form needs keccak, which the page does not carry: the engine could add `display_from` and `display_to` (owed, section 10). |
| Amount | `+0.25 IGN` (incoming, molten) or `−1.5 IGN` (outgoing, bone); up to six decimals, trailing zeros cut. |
| State word | ONE word, the node's (Ledger P17, design 2.4: never a stronger word than the chain's). `View.stateWord(entry, nodeState)`: the wallet's own `final` (a checkpoint this wallet verified covers it) → `finalised`; the wallet's `failed` → `failed`; else the node's `state` from `api/tx/<hash>` (`pending`, `included`, `executed`, `proven`, `finalised`, `finality not active`) when the page has it; else the wallet's label (`pending` → `pending`, `in_block` → `included`). A reward or a proving payout has no transaction hash (`reward-N-i`): its row says `executed` in a block and `finalised` under a verified checkpoint. |
| Reason | one grey line: pending `waiting for a block`; included `in block N, not executed yet`; executed `in block N`; proven `in block N, proof paid`; finalised `under checkpoint N, verified here` (or `under a locked checkpoint` when the word is the node's and the wallet has not verified it); finality not active `under a locked checkpoint; finality is paused on the network`; failed `the execution failed; the fee was still paid`. |
| Colour | pending and included ash; executed and proven ink; finalised molten; failed ember. The dot carries the colour. |
| Details | the chevron opens the row: hash with Copy, from, to (full checksummed), fee paid, block and block hash, the checkpoint, the engine's note, and the node's word verbatim (`node: proven`) so the two sources are side by side. |
| Where the node's word comes from | `api/tx/<hash>` GET (exists, unused before). The page asks for the rows that are not `final` and not `failed`, at most 12 per poll, newest first, and keeps the answer per hash; a row the wallet marks `final` stops asking. On the public RPC the call is tried once and the fallback label is used when it errors. |
The page lists every entry the state carries (up to 2,000 in the engine's file; the page renders them all, the list is
short). Empty: as Home's.
## 6. Settings
Plain rows, one sentence each, grouped as the miner's:
| Group | Rows |
|---|---|
| Security | Touch ID (or Windows Hello): on → `Touch ID is on. It unlocks the wallet, confirms each send and shows the backup; the password still works everywhere.` with Turn off; off → the switch opens the password field in place with Turn on (the enrolment flow as before: password once, then the prompt); the "needs the app window" and "not set up" lines as before · Ask for Touch ID when the wallet opens (switch) · Lock when idle (the select: 1, 5, 15, 60 minutes, never; one sentence) · Change password (a disclosure: current, new, Change) |
| Backup | Show my words (a disclosure; the ask in place: `Show the 24 words and the key on screen? Anyone who sees them has your IGN.` then the password field or the Touch ID button, then the words grid, the key, Hide; marks `backed_up`) · Export the key for MetaMask (a disclosure with the warning sentence, the same ask, Copy key, Hide) · MetaMask network: one line (`Igneum devnet · chain id 7762959 · rpc.devnet.igneum.network`) with Add to MetaMask and Copy settings |
| This machine | Start at login (switch: `Opens when you sign in; the wallet locks itself when idle.`) · Appearance: System / Light / Dark (the page's own storage, as the miner's) |
| Updates | one card: `Igneum Wallet 0.1.5 · up to date, checked 13 min ago` · Check · Install now when ready · `Install updates by itself when nothing is being sent` (switch) |
| Node | the endpoint in use with Copy (`http://127.0.0.1:26790`, read-only) · the source sentence (`the miner app's node on this machine`) · the public RPC · the chain id and the gRPC port in one grey line. A field to set the endpoint is owed: the engine chooses the source (`node.rs plan_own_node`, the probe order miner → bundled → public) and has no `api/settings {node_endpoint}`. The row says so in one line: `The wallet picks the miner's node when it runs here, else its own, else the public RPC.` |
| Advanced (details) | the machine id · the logs folder · the wallet folder · `the miner's key file: present` · Remove this wallet from this machine: the password field and the ask in place `Remove this wallet from this machine? The vault file is deleted. Only your 24 words or the key bring it back. Remove Cancel` |
The version row at the top of the old Settings and the Back button at its foot are gone: the update card carries the
version, the rail carries the way out.
## 7. Confirmations in place
No `confirm()`, no dialogs. A costly action turns its own row into a question with two buttons:
| Action | The row says |
|---|---|
| Send | `Send 1.5 IGN to 0x2B5A…D6cF? Fee at most 0.00007614 IGN; 1.50007614 IGN leaves the wallet at most. Confirm with Touch ID / Send now Cancel` |
| Show my words, Export the key | `Show the 24 words and the key on screen? Anyone who sees them has your IGN. Show (or Show with Touch ID) Cancel` |
| Remove the wallet | `Remove this wallet from this machine? The vault file is deleted. Only your 24 words or the key bring it back. Remove Cancel` |
| Quit | `Quit Igneum Wallet? The bundled node stops with it. Quit Cancel` (the strip over the page, as the miner's) |
| Lock, Copy, Touch ID off, the switches, Check | no question: each reverts in one tap and the toast says what happened |
## 8. Type, spacing, colour, widths
The miner's tokens, verbatim (`docs/plans/miner-ui-3.md` section 9): the eight type sizes, the six-step spacing scale,
the radii, the dark and light sets, ember as the one accent, molten for live states. `app.css` starts from the miner's
file; the wallet's own blocks (the words grid, the QR, the history row, the ask) are added on the same tokens.
| Width | Layout |
|---|---|
| 1180 px and up | rail 196 px, the balance and its money line on one row, the history row with every cell |
| 1000 to 1180 | the rail folds to icons |
| 720 to 1000 | the history row's reason goes under the state word |
| under 720 | a bottom tab bar (Home, Send, Receive, History, Settings), the balance stacks, the row is two lines (kind and who; amount and state), 16 px gutters, no horizontal scroll |
## 9. The one host change
`app/mac/IgneumWallet.swift` `buildWindow` sets `window.appearance = NSAppearance(named: .darkAqua)`, which pins the
WKWebView's `prefers-color-scheme` to dark. The line is removed so System follows the Mac; the window's background
stays obsidian until the page paints (the page covers it). Nothing else in the host moves. The Windows host
(`app/windows/wallet-host.cpp`) does not pin a scheme; nothing to change.
## 10. What the engine is asked for (none of it built here)
| Field or route | Why | Owner |
|---|---|---|
| `price_gbp_per_ign` | the money line | the engine, when a market exists (the miner's plan says the same) |
| `api/settings {node_endpoint}` or a `node_source` choice | the Node row's field | the engine; today `node.rs` chooses |
| `node_status` per entry on `/api/state` | would save the per-row `api/tx/<hash>` calls | the engine, later; the page works without it |
Until a field arrives the page treats it as unset: the money line explains, the Node row is read-only, the state words
come from `api/tx/<hash>`.
## 11. Tests
`view.test.mjs` (new; `node --test`, no dependencies, loads the `View` block of `app.js` as the miner's does): the
five pages and their titles; the state word for every combination of the wallet's label and the node's word
(pending, included, executed, proven, finalised, finality not active, failed; a reward's words; the fallback without
a node word); the reason line per word; the money line with and without a price, on devnet and off; the node line per
source and state; the balance line while unknown (reading, scanning, waiting); the send ask's words with and without
Touch ID; the fee words; the short address; the row model (kind word, who, sign, amount); the Settings' Touch ID
sentence. `lock-screen.test.mjs` and `update-card.test.mjs` are unchanged and pass.
## 12. The build
| File | What |
|---|---|
| `app/igneum-wallet/ui/index.html` | the rail (five sections, Lock and Quit in the foot), the top bar with the page title and the pill, the quit ask strip, the welcome with the mark, Create and Import as before with ash eyebrows and the address Copy, the lock screen as 0.1.4 with the mark, the five pages, the update card, the toast |
| `app/igneum-wallet/ui/app.css` | the miner's tokens and chrome (dark and light sets, the rail, the top bar, the notices strip, the ask, the disclosures, the segmented control, the switch, the kv, the update card, the bottom tab bar under 720 px), plus the wallet's blocks: the balance hero, the words grid, the checks, the QR, the history row and its details, the send card, the lock screen |
| `app/igneum-wallet/ui/app.js` | `View` (pure, exported for the test): `PAGES`, `page`, `shortHex`, `ign`, `moneyLine`, `balanceLine`, `nodeWords`, `nodeLine`, `nodeDetails`, `rowModel`, `stateWord`, `stateReason`, `feeWords`, `sendAsk`, `bioSentence`, `updateCardLine`; the DOM block: the pages behind the rail, the per-hash node word cache (`api/tx/<hash>`), the asks, the appearance, `?page=`, the existing `?bio=`, `?update=`, `?host=` switches; the Touch ID bridge, the lock-screen logic and the update card logic carried over unchanged |
| `app/igneum-wallet/ui/view.test.mjs` | section 11 |
| `app/mac/IgneumWallet.swift` | section 9, one line |
| `tools/ui-mock/wallet.mjs`, `tools/ui-mock/shoot-wallet.mjs` | the mock and the capture list (the audit's) |
| `docs/plans/wallet-ui-3/n*.png` | the result, the same names as the audit's captures, plus the real Mac window (`m*.png`) from the built host on a fresh data dir |
## 13. The build (6 October 2026, evening)
Three UI files rewritten, `view.test.mjs` added, one Swift line removed, nothing in `src/`. `node --test` on the four UI
files: 20 pass (11 view, 5 lock screen, 4 update card). The engine was built in this worktree under the Mac build lock
(a warm `target`, 100 s) so the new files are compiled in (`include_str!`); the window host was built with `swiftc`
under the same lock. Captures:
| Set | What | How |
|---|---|---|
| `n00` to `n24` | every audit screen on the new UI, the same names | the mock on 4320, Playwright's cached chromium at nice 19, one browser, closed after the batch |
| `m00-welcome`, `m01-home`, `m02-history`, `m03-receive`, `m04-settings`, `m05-light`, `m06-lock` | the real Mac window (`Igneum Wallet --snapshot`, 1120 x 780, the shipped size) on this worktree's engine, a fresh scratch data dir, a throwaway wallet created through the engine's own API for the shot and deleted with the dir afterwards (never funded, never the project lead's) | the engine at nice 19, the host at nice 19 |
What the real run showed: the engine found the miner app's node on this Mac at 18:31:47Z and lost it 20 s later
(`connect 127.0.0.1:26790: Connection refused`); on the second run it found no node at all. Node 1 on this Mac was
not answering steadily between 18:31Z and 18:36Z. The wallet's words for it were right (`Node lost`, then `Node not
found`, the pill `NODE LOST` / `NO NODE`, the balance line `waiting for a node`); one leak was fixed on the spot: the
node line's sub showed the engine's raw error, it now says `the node stopped answering; trying again` and keeps the
raw message in Details. A bare engine has no bundled `igneumd` beside it, so the second run's message is the packaged
build's own ("igneumd is not next to the wallet"); the DMG carries the node.
Light mode on the real window follows the page's `?theme=light` switch (added for the captures, never stored) and,
with the host line removed, the Mac's own appearance.
### What the 0.1.5 shipper takes
| Item | Where |
|---|---|
| The three UI files, `view.test.mjs`, `update-card.js` and `lock-screen.js` unchanged | `app/igneum-wallet/ui/` |
| The one Swift line (no pinned `darkAqua`) | `app/mac/IgneumWallet.swift` |
| The mock and the shot script, the two docs and the capture folder | `tools/ui-mock/wallet.mjs`, `tools/ui-mock/shoot-wallet.mjs`, `docs/plans/wallet-ui-3*.md`, `docs/plans/wallet-ui-3/` |
| The site: `site/wallet.html`, the wallet section of `site/index.html`, `site/img/wallet-home.webp` and `site/img/wallet-final.webp` (from the mock at 1120 x 780, example addresses, 2240 x 1560) | `site/` |
| The DMG recipe is unchanged: `packaging/mac/build-wallet-dmg.sh` compiles the engine and the host from these files | the shipper's cut |
| `tools/ci/no-secrets-check.sh` fails on `app/igneum-wallet/src/vault.rs:129` (a test fixture, a 64-hex key next to the word key) on `wallet-0.1.5` before this branch; untouched here, the shipper's to settle | pre-existing |
| Owed to the engine: `price_gbp_per_ign`; a settable node endpoint; `display_from` and `display_to` per entry (the page has no keccak, so the row shows the plain hex short form); `node_status` per entry on the state | section 10 |
### The tick list (section 3 of the audit)
| # | Feature | Where it is now |
|---|---|---|
| 1 to 3 | Welcome, Create, Import | as before; the mark, the address Copy, ash eyebrows |
| 4 | The lock screen | as 0.1.4; the mark |
| 5 | Lock, the idle lock, the activity ping | Lock in the rail foot and the top bar on narrow widths; the ping unchanged |
| 6 | The balance and its unknown states | Home, the hero; no 0 for an unknown |
| 7 | The address with Copy | Home, Receive |
| 8 | The backup note | Home |
| 9 | Send: quote, review, Touch ID, Sent | Send, the ask in place, the pending row |
| 10 | Receive | Receive |
| 11 | History | History (every row), Home (five) |
| 12 | Transaction details | the row's disclosure |
| 13, 14 | Node and Finality | the node line and its Details |
| 15 | Touch ID, ask on open, idle lock | Settings, Security |
| 16 | Backup | Settings, Backup, with the ask |
| 17 | Change password | Settings, Security, a disclosure |
| 18 | MetaMask | Settings, Backup |
| 19 | Network | Settings, Node |
| 20 | This machine, updates | Settings, This machine and the update card |
| 21 | Remove | Settings, Advanced, with the ask (no dialog) |
| 22 | The update banner and card | unchanged (`update-card.js`); the banner is the notices strip |
| 23 | The pill | the top bar: the wallet's own words (`synced`, `starting`, `no node`, `locked`, `stopping`, `engine gone`) |
| 24 | The version | the update card; the rail foot |
| 25 | The welcome eyebrow | unchanged |
| 26 | The URL switches | as before, plus `?page=` |
| 27 | Quit | the rail foot, with the ask strip |
| new | The money line, the node's state words with reasons, the node line, Appearance and light mode, the bottom tab bar, the Node endpoint row | this build |

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 763 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 74 KiB

File diff suppressed because one or more lines are too long

View file

@ -161,7 +161,10 @@ section+section{padding-top:0;scroll-margin-top:var(--sm)}
.state p{font-size:14px;color:var(--ash);line-height:1.45}
.state::after{content:'';position:absolute;right:-9px;top:50%;width:14px;height:14px;border-top:2px solid var(--line-2);border-right:2px solid var(--line-2);transform:translateY(-50%) rotate(45deg);z-index:1}
.state:last-child::after{display:none}
@media (max-width:640px){.states{grid-template-columns:1fr}.state::after{right:auto;left:50%;top:auto;bottom:-9px;transform:translateX(-50%) rotate(135deg)}}
.states.words5{grid-template-columns:repeat(5,minmax(0,1fr))}
.states.words5 .t{font-size:clamp(15px,1.4vw,17px)}
@media (max-width:1100px){.states.words5{grid-template-columns:repeat(3,minmax(0,1fr))}.states.words5 .state:nth-child(3)::after{display:none}}
@media (max-width:640px){.states,.states.words5{grid-template-columns:1fr}.state::after,.states.words5 .state:nth-child(3)::after{right:auto;left:50%;top:auto;bottom:-9px;transform:translateX(-50%) rotate(135deg);display:block}.state:last-child::after{display:none}}
.group{display:grid;gap:var(--gap);grid-template-columns:1fr;margin-bottom:var(--sec)}
@media (min-width:900px){.group{grid-template-columns:300px minmax(0,1fr);gap:48px}}
@ -247,8 +250,8 @@ td.num{font-variant-numeric:tabular-nums;white-space:nowrap}
<div class="shot reveal">
<figure class="frame" style="margin:0">
<div class="bar"><i></i><i></i><i></i><b>Igneum Wallet</b></div>
<img src="/img/wallet-home.webp" width="2240" height="1560" alt="The wallet window: a balance in IGN, an example address with Send and Receive, node and finality panels, and a history of block rewards" fetchpriority="high" decoding="async">
<figcaption>Igneum Wallet on a private test network, 5 Oct 2026. The address is an example.</figcaption>
<img src="/img/wallet-home.webp" width="2240" height="1560" alt="The wallet window: the balance in IGN with the line that there is no market price on devnet, an example address with Copy, Send and Receive, one node line, and the recent transactions each with the node's state word" fetchpriority="high" decoding="async">
<figcaption>Igneum Wallet 0.1.5 on the devnet, 6 Oct 2026. The address is an example.</figcaption>
</figure>
</div>
</div>
@ -257,19 +260,21 @@ td.num{font-variant-numeric:tabular-nums;white-space:nowrap}
<section id="finality">
<div class="wrap">
<div class="sec-head reveal">
<h2>Three states, checked here</h2>
<p>Every transfer in the history is pending, in a block, or final. The third one is the wallet's own verdict.</p>
<h2>One word from the node, one verdict from the wallet</h2>
<p>Every row in the history carries the node's own state word for that transaction, never a stronger one. "Finalised" turns molten only when this wallet has verified the certificate itself.</p>
</div>
<div class="states reveal">
<div class="states words5 reveal">
<div class="state pending"><span class="pill"><i></i>pending</span><div class="t">Waiting for a block</div><p>Signed on this machine and handed to the node. Nothing has carried it yet.</p></div>
<div class="state block"><span class="pill"><i></i>in a block</span><div class="t">Chain block 8</div><p>A block carries it and the chain's height passed it. It can still be reorganised until a checkpoint covers it.</p></div>
<div class="state final"><span class="pill"><i></i>final · cp 5</span><div class="t">Under a verified checkpoint</div><p>Its block sits under a 30-second checkpoint whose certificate this wallet verified: the voter list, the aggregate BLS signature, two thirds of all weight.</p></div>
<div class="state"><span class="pill"><i></i>included</span><div class="t">In a block, not run</div><p>A block carries it; the executor has not reached it, or the block is off the selected chain.</p></div>
<div class="state block"><span class="pill"><i></i>executed</span><div class="t">Run on the chain</div><p>The executor ran it in a segment of its selected chain. The balance moved.</p></div>
<div class="state block"><span class="pill"><i></i>proven</span><div class="t">Proof paid</div><p>The shard holding it has a paid proof record carried by a chain block.</p></div>
<div class="state final"><span class="pill"><i></i>finalised</span><div class="t">Under a verified checkpoint</div><p>Its block sits under a 30-second checkpoint whose certificate this wallet verified: the voter list, the aggregate BLS signature, two thirds of all weight.</p></div>
</div>
<div class="shot reveal" style="margin-top:var(--gap);max-width:880px">
<figure class="frame" style="margin:0">
<div class="bar"><i></i><i></i><i></i><b>Transaction</b></div>
<img src="/img/wallet-final.webp" width="1640" height="820" alt="A sent transfer of 1.5 IGN shown as final under checkpoint 5, certificate verified by this wallet, with amount, example addresses, fee paid, hash, block and the verified checkpoint at chain height" loading="lazy" decoding="async">
<figcaption>The transfer from the test run: 1.5 IGN, final under checkpoint 5, certificate verified by the wallet. Addresses are examples.</figcaption>
<div class="bar"><i></i><i></i><i></i><b>History</b></div>
<img src="/img/wallet-final.webp" width="2240" height="1560" alt="The History page: one row per transaction with the node's state word and a reason line, one row opened to show the hash, the addresses, the fee, the block, the checkpoint and the node's own word" loading="lazy" decoding="async">
<figcaption>The History page, 6 Oct 2026: a row opened on a transfer finalised under a verified checkpoint. Addresses are examples.</figcaption>
</figure>
</div>
<div class="reveal" style="margin-top:var(--gap)">
@ -280,7 +285,7 @@ td.num{font-variant-numeric:tabular-nums;white-space:nowrap}
<li>The aggregate signature over the checkpoint, by the keys the bitmap names.</li>
<li>The rule: signed weight at least two thirds of the active weight and of the total weight. Then the transfer's block must sit under the checkpoint on the chain.</li>
</ol>
<p class="pt" style="margin-top:16px">The same steps the browser verifier on the home page runs, with the node's own finality code compiled into the wallet. The node's own "locked" field is never read.</p>
<p class="pt" style="margin-top:16px">The same steps the browser verifier on the home page runs, with the node's own finality code compiled into the wallet. The node's own "locked" field is never read for the verdict; the node's state word is shown beside it, and the wallet never shows a stronger word than the chain's.</p>
</div>
</div>
</section>
@ -303,14 +308,14 @@ td.num{font-variant-numeric:tabular-nums;white-space:nowrap}
</div>
<div class="group reveal" id="money">
<div class="g-head"><div class="eyebrow ember">02 · Send, receive, history</div><h3>Everything on one screen</h3><p>Signing happens in the engine, in Rust. Nothing on the window side ever holds the key.</p></div>
<div class="g-head"><div class="eyebrow ember">02 · Send, receive, history</div><h3>Five sections, one design</h3><p>Home, Send, Receive, History, Settings, on the miner's design. Signing happens in the engine, in Rust. Nothing on the window side ever holds the key.</p></div>
<div class="feats">
<div class="feat"><b>Balance</b><p>Read from your node every few seconds, in IGN, with the block it was read at.</p></div>
<div class="feat"><b>Send with the fee shown</b><p>An EIP-1559 transfer. The review shows the amount, the fee at most as base fee plus tip, and what leaves the wallet at most. The zero address and more than the balance are refused.</p></div>
<div class="feat"><b>Balance first</b><p>Read from your node every few seconds, in IGN. A pounds line appears only when a market price exists; on the devnet the wallet says there is none and that the coins have no value.</p></div>
<div class="feat"><b>Send, confirmed in place</b><p>An EIP-1559 transfer. Review turns the form into one question: the amount, the address, the fee at most and what leaves the wallet at most. Touch ID confirms it on the Mac. The zero address and more than the balance are refused.</p></div>
<div class="feat"><b>Receive by QR</b><p>The code is drawn on your machine as SVG. No image service, no call across the network.</p></div>
<div class="feat"><b>History with rewards</b><p>Transfers in and out, block rewards from the execution records, one per blue block, and proving payouts when a block carries a proof record.</p></div>
<div class="feat"><b>Finality per transaction</b><p>Pending, in a block, or final with the checkpoint index, verified here. <a href="#finality" style="color:var(--ember)">How it checks</a>.</p></div>
<div class="feat"><b>Lock</b><p>One button locks the wallet and zeroes the key in memory. The password opens it again.</p></div>
<div class="feat"><b>The node's word per transaction</b><p>Pending, included, executed, proven or finalised, with one line of why. Finalised is molten only once this wallet verified the checkpoint. <a href="#finality" style="color:var(--ember)">How it checks</a>.</p></div>
<div class="feat"><b>Lock, Touch ID, light and dark</b><p>One button locks the wallet and zeroes the key in memory. Touch ID unlocks it on the Mac; the password always works. The window follows the system's light or dark setting, or your own.</p></div>
</div>
</div>

View file

@ -0,0 +1,89 @@
#!/usr/bin/env node
// Screenshots of the wallet UI from the mock (tools/ui-mock/wallet.mjs), headless, with Playwright's own chromium
// (never Chrome.app). No real wallet is read: the mock's addresses are the well-known test keys.
//
// node tools/ui-mock/shoot-wallet.mjs <out dir> [prefix] [port]
//
// Playwright: the package is taken from PLAYWRIGHT_CORE (a playwright-core folder) and the browser from
// PLAYWRIGHT_CHROMIUM (the executable); both default to the install this Mac has. Each shot below names a scenario,
// a page URL tail, a size and the clicks that reach the screen. The list is the audit's and the build's (the same
// names with another prefix), so before and after sit side by side in docs/plans/wallet-ui-3/.
import path from 'node:path';
import fs from 'node:fs';
const out = process.argv[2];
const prefix = process.argv[3] || '';
const port = parseInt(process.argv[4] || '4320', 10);
if (!out) { console.error('usage: shoot-wallet.mjs <out dir> [prefix] [port]'); process.exit(2); }
fs.mkdirSync(out, { recursive: true });
const pwDir = process.env.PLAYWRIGHT_CORE || '/Users/joshm/Projects/brandguard/node_modules/playwright-core';
const exe = process.env.PLAYWRIGHT_CHROMIUM || (process.env.HOME + '/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing');
const { chromium } = await import(path.join(pwDir, 'index.mjs'));
// the browser runs at nice 19 (the project lead, 6 October 2026: the captures must not push the Mac's fans), one at a time, closed at the end
import os from 'node:os';
const nicer = path.join(os.tmpdir(), 'igneum-nice-chromium.sh');
fs.writeFileSync(nicer, '#!/bin/sh\nexec nice -n 19 ' + JSON.stringify(exe) + ' "$@"\n', { mode: 0o755 });
const BASE = `http://127.0.0.1:${port}/t/mock/`;
const WIDE = { width: 1280, height: 820 }, NARROW = { width: 900, height: 600 }, PHONE = { width: 390, height: 844 };
// the clicks reach a screen by its control's id; `type` fills a field; `wait` sleeps (ms); `eval` runs page JS
const SHOTS = [
{ n: '00-welcome', s: 'welcome', size: WIDE },
{ n: '01-create-password', s: 'welcome', size: WIDE, do: [['click', '#go-create']] },
{ n: '02-create-words', s: 'welcome', size: WIDE, do: [['click', '#go-create'], ['type', '#create-pw', 'example-password'], ['type', '#create-pw2', 'example-password'], ['click', '#create-next'], ['wait', 400]] },
{ n: '03-import', s: 'welcome', size: WIDE, do: [['click', '#go-import']] },
{ n: '04-unlock-touch', s: 'unlock', size: WIDE, q: 'bio=touch' },
{ n: '05-unlock-password', s: 'unlock', size: WIDE, do: [['wait', 600], ['eval', "document.getElementById('unlock-use-pw').click()"], ['wait', 300]], q: 'bio=touch' },
{ n: '06-home', s: 'home', size: WIDE },
{ n: '07-home-lower', s: 'home', size: WIDE, do: [['scroll', 900]] },
{ n: '08-send', s: 'home', size: WIDE, do: [['click', '#go-send']] },
{ n: '09-send-review', s: 'home', size: WIDE, do: [['click', '#go-send'], ['type', '#send-to', '0x2B5AD5c4795c026514f8317c7a215E218DCCD6cF'], ['type', '#send-amount', '1.5'], ['click', '#send-quote'], ['wait', 400]] },
{ n: '10-send-sent', s: 'plain', size: WIDE, do: [['click', '#go-send'], ['type', '#send-to', '0x2B5AD5c4795c026514f8317c7a215E218DCCD6cF'], ['type', '#send-amount', '1.5'], ['click', '#send-quote'], ['wait', 400], ['click', '#send-go'], ['wait', 600]], q: 'nobio=1' },
{ n: '11-receive', s: 'home', size: WIDE, do: [['click', '#go-receive'], ['wait', 300]] },
{ n: '12-tx-final', s: 'home', size: WIDE, do: [['click', '[data-hash="0x' + 'c9d0'.repeat(16) + '"]'], ['wait', 400]] },
{ n: '13-settings', s: 'home', size: WIDE, do: [['settings'], ['wait', 300]] },
{ n: '14-settings-lower', s: 'home', size: WIDE, do: [['settings'], ['wait', 300], ['scroll', 900]] },
{ n: '15-settings-lowest', s: 'home', size: WIDE, do: [['settings'], ['wait', 300], ['eval', "var a = document.getElementById('s-advanced'); if (a) a.open = true; var r = document.getElementById('remove-start'); if (r) r.click();"], ['wait', 200], ['scroll', 1800]] },
{ n: '16-home-public', s: 'public', size: WIDE },
{ n: '17-home-scanning', s: 'scanning', size: WIDE },
{ n: '18-home-nonode', s: 'nonode', size: WIDE },
{ n: '19-home-empty', s: 'empty', size: WIDE },
{ n: '20-update-card', s: 'update', size: WIDE, q: 'update=ready&card=1' },
{ n: '21-narrow-900-home', s: 'home', size: NARROW },
{ n: '22-phone-home', s: 'home', size: PHONE },
{ n: '23-phone-settings', s: 'home', size: PHONE, do: [['settings'], ['wait', 300]] },
{ n: '24-light-home', s: 'home', size: WIDE, scheme: 'light' },
];
const only = (process.env.ONLY||"").split(",").filter(Boolean);
const browser = await chromium.launch({ executablePath: nicer, headless: true });
for (const sh of SHOTS) {
if (only.length && !only.includes(sh.n)) continue;
const ctx = await browser.newContext({ viewport: sh.size, deviceScaleFactor: 2, colorScheme: sh.scheme || 'dark' });
const page = await ctx.newPage();
page.on('pageerror', (e) => console.log(' page error on', sh.n + ':', e.message));
try {
await fetch(`http://127.0.0.1:${port}/mock/scenario?set=${sh.s}`);
await page.goto(BASE + '?scenario=' + sh.s + (sh.q ? '&' + sh.q : ''), { waitUntil: 'networkidle' });
await page.waitForTimeout(700);
for (const step of sh.do || []) {
const [op, sel, val] = step;
if (op === 'click') await page.click(sel, { timeout: 4000 });
else if (op === 'type') await page.fill(sel, val);
else if (op === 'wait') await page.waitForTimeout(sel);
else if (op === 'scroll') await page.evaluate((y) => { const m = document.getElementById('main'); if (m) m.scrollTop = y; window.scrollTo(0, y); }, sel);
else if (op === 'eval') await page.evaluate(sel);
// Settings: the rail's tab (wallet-ui-3), else the old gear (the audit's UI)
else if (op === 'settings') await page.evaluate(() => { const b = document.querySelector('#rail-nav [data-page="settings"]') || document.getElementById('btn-settings'); if (b) b.click(); });
await page.waitForTimeout(150);
}
await page.waitForTimeout(350);
const file = path.join(out, prefix + sh.n + '.png');
await page.screenshot({ path: file });
console.log('wrote', file);
} catch (e) {
console.log(' FAILED', sh.n + ':', e.message.split('\n')[0]);
}
await ctx.close();
}
await browser.close();

177
tools/ui-mock/wallet.mjs Normal file
View file

@ -0,0 +1,177 @@
#!/usr/bin/env node
// A stand-in for the Igneum Wallet engine, for working on app/igneum-wallet/ui without a vault, a key or a node.
// Serves the wallet's UI files at /t/mock/ (the same path shape as the engine) and answers the JSON API from a
// scenario. Every address below is an example (the well-known test keys); no real wallet is read. POSTs are logged
// and answered as the engine would, without doing anything. 6 October 2026, for wallet-ui-3.
//
// node tools/ui-mock/wallet.mjs [port] default 4320
// open http://127.0.0.1:4320/t/mock/?scenario=home
//
// Scenarios (set with ?scenario= on the page URL, or GET /mock/scenario?set=<name>):
// welcome no vault yet (the welcome, create and import screens)
// unlock a vault, locked, Touch ID enrolled (the page adds ?bio=touch for the button layout)
// home unlocked on the miner app's node: a balance, a history with every state word, a verified checkpoint
// public unlocked on the public RPC: no node here, so finality cannot be verified
// scanning unlocked, the bundled node syncing, the history being read
// nonode unlocked, no node found yet
// plain as home, without Touch ID (the send goes through without a host)
// empty unlocked, a fresh wallet with nothing in it
// update as home, with an update ready (the card opens by itself)
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const uiDir = path.resolve(here, '../../app/igneum-wallet/ui');
const fontDir = path.resolve(here, '../../app/igneum-app/ui/fonts');
const coin = path.resolve(here, '../../brand/igneum-coin-1024.png');
const port = parseInt(process.argv[2] || '4320', 10);
const PREFIX = '/t/mock';
// example addresses: the accounts of the well-known test keys (never a real wallet)
const ME = '0x7e5f4552091a69125d5dfcb7b8c2659029395bdf', ME_D = '0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf';
const B = '0x2b5ad5c4795c026514f8317c7a215e218dccd6cf', B_D = '0x2B5AD5c4795c026514f8317c7a215E218DCCD6cF';
const C_D = '0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69';
const t0 = Date.now() / 1000;
let scenario = 'home';
const posts = [];
const IGN = (n) => BigInt(Math.round(n * 1e6)) * 10n ** 12n;
function entry(o) {
return { hash: '0x' + o.h.repeat(16), kind: 'received', block: 0, block_hash: '0x' + o.h.repeat(16).split('').reverse().join(''), from: B, to: ME, value: '0', fee: '0', ok: true, time: 0, finality: 'in_block', checkpoint: null, note: '', ...o };
}
// the history, newest first: a send pending, a receive in a block, a reward, a proving payout, finals, a failed send
function history(t) {
return [
entry({ h: 'a1b2', kind: 'sent', from: ME, to: B, value: IGN(1.5).toString(), fee: '', block: 0, block_hash: '', time: t - 9, finality: 'pending' }),
entry({ h: 'c3d4', kind: 'received', from: B, to: ME, value: IGN(0.25).toString(), fee: '25380000000000', block: 140262, time: t - 140, finality: 'in_block' }),
entry({ h: 'e5f6', kind: 'reward', from: '', to: ME, value: IGN(10.14).toString(), block: 140201, time: t - 300, finality: 'in_block', note: 'block reward' }),
entry({ h: 'a7b8', kind: 'proving', from: '', to: ME, value: IGN(0.0421).toString(), block: 140180, time: t - 420, finality: 'in_block', note: 'shard payout' }),
entry({ h: 'c9d0', kind: 'sent', from: ME, to: B, value: IGN(2).toString(), fee: '25380000000000', block: 139900, time: t - 3600, finality: 'final', checkpoint: 4673 }),
entry({ h: 'e1f2', kind: 'reward', from: '', to: ME, value: IGN(10.14).toString(), block: 139877, time: t - 3700, finality: 'final', checkpoint: 4673, note: 'block reward' }),
entry({ h: 'a3b4', kind: 'sent', from: ME, to: B, value: IGN(0.1).toString(), fee: '25380000000000', block: 139800, time: t - 4200, finality: 'failed', ok: false, note: 'the execution failed (reverted or out of gas)' }),
entry({ h: 'c5d6', kind: 'self', from: ME, to: ME, value: IGN(1).toString(), fee: '25380000000000', block: 139750, time: t - 5000, finality: 'final', checkpoint: 4668 }),
entry({ h: 'e7f8', kind: 'received', from: B, to: ME, value: IGN(5).toString(), fee: '25380000000000', block: 139000, time: t - 86400, finality: 'final', checkpoint: 4600 }),
entry({ h: 'a9b0', kind: 'reward', from: '', to: ME, value: IGN(10.14).toString(), block: 138950, time: t - 90000, finality: 'final', checkpoint: 4598, note: 'block reward' }),
];
}
// the node's one state word per transaction (igneum_getTransactionStatus), by the entry's label
function nodeWord(e) {
if (e.finality === 'pending') return 'pending';
if (e.finality === 'failed') return 'executed';
if (e.finality === 'final') return 'finalised';
return e.kind === 'reward' || e.kind === 'proving' ? 'executed' : e.block % 2 ? 'included' : 'proven';
}
function base(t) {
const block = 140270 + Math.floor(t - t0);
return {
version: '0.1.5', phase: 'home', has_vault: true, unlocked: true, backed_up: true, source: 'created', address: ME, display: ME_D,
balance_wei: IGN(31.6321).toString(), balance: '31.6321', balance_known: true,
node: { source: 'miner', state: 'ok', chain_id: 7_762_959, chain: 'igneum-devnet-20', block, evm: 'http://127.0.0.1:26790', grpc: '127.0.0.1:26610', synced: true, message: "using the miner app's node on this machine", finality_active: true, latest_locked: 4674 },
finality: { verified_index: 4674, verified_hash: '0x' + 'f1'.repeat(32), chain_number: block - 40, signers: 11, voters: 12, fraction_total: 0.912, fraction_active: 0.98, weights_exact: true, verified_at: t - 14, message: 'checkpoint 4674 verified here' },
history: history(t), scanning: false, scanned_to: block,
update: { status: 'current', version: '0.1.5', url: '', notes: '', file: '', error: '', checked_at: t - 780, available: false, downloaded: false, ready: false, applying: false, progress: 0, size: 0, auto: true, wait: '', urgent: false, urgent_text: '', unsupported: false, min_supported: '', updated_from: '', rolled_back: '' },
settings: { start_at_login: false, network: 'devnet', auto_update: true, idle_lock_min: 5, ask_on_open: true },
biometric: { available: true, kind: 'touchid', enrolled: true, message: '', last_result: 'ok', last_op: 'unlock', last_at: t - 600, idle_lock: true, idle_lock_min: 5, needs_enrol: false },
events: [{ t: t - 9, kind: 'ok', text: 'sent 1.5 IGN to ' + B_D + ' (0xa1b2a1b2a1)' }, { t: t - 300, kind: 'ok', text: 'earned 10.14 IGN as a block reward' }],
miner_wallet_file: '/Users/you/Library/Application Support/Igneum/app/wallet.json', miner_wallet_present: true,
add_network_page: 'https://igneum.network/metamask', public_rpc: 'https://rpc.devnet.igneum.network',
machine_id: 'd937c69d4e2f', host: 'studio', log_dir: '/Users/you/Library/Logs/Igneum', app_dir: '/Users/you/Library/Application Support/Igneum/wallet', uptime_s: Math.floor(t - t0) + 1500, now: t, quitting: false,
};
}
function stateNow() {
const t = Date.now() / 1000;
const s = base(t);
switch (scenario) {
case 'welcome': Object.assign(s, { phase: 'welcome', has_vault: false, unlocked: false, backed_up: false, source: '', address: '', display: '', balance: '', balance_wei: '', balance_known: false, history: [], scanned_to: null }); s.biometric.enrolled = false; break;
case 'unlock': Object.assign(s, { phase: 'unlock', unlocked: false, balance_known: false, balance: '', history: [] }); break;
case 'public':
s.node = { ...s.node, source: 'public', state: 'ok', evm: 'https://rpc.devnet.igneum.network', grpc: '', message: 'using the public RPC; no node here, so finality cannot be verified', finality_active: false, latest_locked: 0 };
s.finality = { verified_index: 0, verified_hash: '', chain_number: null, signers: 0, voters: 0, fraction_total: 0, fraction_active: 0, weights_exact: false, verified_at: 0, message: 'no node here: certificates cannot be verified' };
s.history = s.history.map((e) => e.finality === 'final' ? { ...e, finality: 'in_block', checkpoint: null } : e);
s.biometric.enrolled = false;
break;
case 'scanning':
s.node = { ...s.node, source: 'own', state: 'starting', block: 140270, evm: 'http://127.0.0.1:26800', grpc: '127.0.0.1:26620', synced: false, message: 'the bundled node is starting; the chain syncs from the seed', finality_active: false, latest_locked: 0 };
s.finality = { ...s.finality, verified_index: 0, chain_number: null, message: 'waiting for a certificate to verify' };
s.scanning = true; s.scanned_to = 61200 + Math.floor((t - t0) * 40) % 79000; s.balance_known = false; s.balance = '';
s.history = s.history.filter((e) => e.block && e.block < 139900);
break;
case 'nonode':
s.node = { ...s.node, source: 'none', state: 'off', block: 0, evm: '', grpc: '', synced: false, message: 'no node: install Igneum Miner, or wait for the bundled node', finality_active: false, latest_locked: 0 };
s.finality = { verified_index: 0, verified_hash: '', chain_number: null, signers: 0, voters: 0, fraction_total: 0, fraction_active: 0, weights_exact: false, verified_at: 0, message: 'waiting for a node' };
s.balance_known = false; s.balance = ''; s.scanned_to = null;
break;
case 'plain': s.biometric.enrolled = false; break;
case 'empty': Object.assign(s, { balance: '0', balance_wei: '0', history: [], backed_up: false, events: [] }); s.biometric.enrolled = false; break;
case 'update': s.update = { ...s.update, status: 'ready', version: '0.1.6', 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.', available: true, downloaded: true, ready: true, progress: 1, size: 20_080_717, wait: 'installs as soon as nothing is being sent' }; break;
}
return s;
}
const QR = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 29 29" shape-rendering="crispEdges"><rect width="29" height="29" fill="#fff"/><path fill="#000" d="M0 0h7v1H0zM0 1h1v1H0zM6 1h1v1H6zM0 2h1v1H0zM2 2h3v1H2zM6 2h1v1H6zM0 3h1v1H0zM2 3h3v1H2zM6 3h1v1H6zM0 4h1v1H0zM2 4h3v1H2zM6 4h1v1H6zM0 5h1v1H0zM6 5h1v1H6zM0 6h7v1H0zM22 0h7v1h-7zM22 1h1v1h-1zM28 1h1v1h-1zM22 2h1v1h-1zM24 2h3v1h-3zM28 2h1v1h-1zM22 3h1v1h-1zM24 3h3v1h-3zM28 3h1v1h-1zM22 4h1v1h-1zM24 4h3v1h-3zM28 4h1v1h-1zM22 5h1v1h-1zM28 5h1v1h-1zM22 6h7v1h-7zM0 22h7v1H0zM0 23h1v1H0zM6 23h1v1H6zM0 24h1v1H0zM2 24h3v1H2zM6 24h1v1H6zM0 25h1v1H0zM2 25h3v1H2zM6 25h1v1H6zM0 26h1v1H0zM2 26h3v1H2zM6 26h1v1H6zM0 27h1v1H0zM6 27h1v1H6zM0 28h7v1H0zM9 0h1v1H9zM11 0h2v1h-2zM15 0h1v1h-1zM18 0h2v1h-2zM8 1h2v1H8zM12 1h1v1h-1zM14 1h3v1h-3zM19 1h1v1h-1zM10 2h1v1h-1zM13 2h2v1h-2zM17 2h1v1h-1zM20 2h1v1h-1zM8 3h1v1H8zM11 3h1v1h-1zM15 3h2v1h-2zM18 3h1v1h-1zM9 4h3v1H9zM13 4h1v1h-1zM16 4h2v1h-2zM20 4h1v1h-1zM8 5h1v1H8zM10 5h1v1h-1zM12 5h3v1h-3zM18 5h2v1h-2zM8 6h1v1H8zM10 6h1v1h-1zM12 6h1v1h-1zM14 6h1v1h-1zM16 6h1v1h-1zM18 6h1v1h-1zM20 6h1v1h-1zM9 7h2v1H9zM13 7h2v1h-2zM17 7h3v1h-3zM0 8h2v1H0zM3 8h2v1H3zM6 8h3v1H6zM11 8h1v1h-1zM13 8h3v1h-3zM18 8h2v1h-2zM21 8h2v1h-2zM25 8h1v1h-1zM27 8h2v1h-2zM1 9h1v1H1zM4 9h2v1H4zM8 9h1v1H8zM10 9h3v1h-3zM14 9h1v1h-1zM16 9h2v1h-2zM19 9h1v1h-1zM22 9h2v1h-2zM26 9h1v1h-1zM0 10h3v1H0zM5 10h2v1H5zM9 10h1v1H9zM12 10h2v1h-2zM15 10h3v1h-3zM20 10h2v1h-2zM24 10h1v1h-1zM27 10h1v1h-1zM2 11h1v1H2zM4 11h1v1H4zM6 11h1v1H6zM8 11h2v1H8zM11 11h1v1h-1zM13 11h1v1h-1zM17 11h2v1h-2zM21 11h1v1h-1zM23 11h3v1h-3zM28 11h1v1h-1zM0 12h2v1H0zM3 12h3v1H3zM7 12h1v1H7zM10 12h2v1h-2zM14 12h2v1h-2zM19 12h1v1h-1zM22 12h1v1h-1zM25 12h2v1h-2zM1 13h1v1H1zM5 13h1v1H5zM8 13h1v1H8zM12 13h1v1h-1zM16 13h1v1h-1zM18 13h2v1h-2zM21 13h2v1h-2zM24 13h1v1h-1zM27 13h2v1h-2zM0 14h1v1H0zM2 14h3v1H2zM6 14h2v1H6zM9 14h2v1H9zM13 14h2v1h-2zM17 14h1v1h-1zM20 14h1v1h-1zM23 14h1v1h-1zM26 14h1v1h-1zM28 14h1v1h-1zM1 15h2v1H1zM4 15h1v1H4zM7 15h1v1H7zM11 15h2v1h-2zM15 15h2v1h-2zM19 15h2v1h-2zM22 15h2v1h-2zM25 15h1v1h-1zM27 15h1v1h-1zM0 16h1v1H0zM3 16h2v1H3zM6 16h1v1H6zM8 16h2v1H8zM12 16h1v1h-1zM14 16h2v1h-2zM18 16h1v1h-1zM21 16h1v1h-1zM24 16h3v1h-3zM28 16h1v1h-1zM2 17h1v1H2zM5 17h2v1H5zM9 17h1v1H9zM11 17h1v1h-1zM13 17h2v1h-2zM17 17h2v1h-2zM20 17h2v1h-2zM23 17h1v1h-1zM26 17h2v1h-2zM0 18h2v1H0zM4 18h1v1H4zM7 18h2v1H7zM10 18h2v1h-2zM15 18h1v1h-1zM19 18h1v1h-1zM22 18h3v1h-3zM27 18h1v1h-1zM1 19h2v1H1zM5 19h1v1H5zM8 19h1v1H8zM12 19h2v1h-2zM16 19h2v1h-2zM20 19h1v1h-1zM25 19h2v1h-2zM28 19h1v1h-1zM0 20h1v1H0zM3 20h1v1H3zM6 20h2v1H6zM9 20h3v1H9zM14 20h1v1h-1zM18 20h1v1h-1zM21 20h2v1h-2zM24 20h1v1h-1zM26 20h1v1h-1zM8 21h1v1H8zM10 21h1v1h-1zM13 21h2v1h-2zM17 21h1v1h-1zM19 21h2v1h-2zM23 21h2v1h-2zM27 21h2v1h-2zM9 22h2v1H9zM12 22h1v1h-1zM15 22h3v1h-3zM20 22h1v1h-1zM22 22h1v1h-1zM24 22h3v1h-3zM28 22h1v1h-1zM8 23h1v1H8zM11 23h2v1h-2zM14 23h1v1h-1zM18 23h2v1h-2zM21 23h1v1h-1zM24 23h1v1h-1zM26 23h2v1h-2zM9 24h1v1H9zM13 24h2v1h-2zM16 24h1v1h-1zM20 24h5v1h-5zM27 24h1v1h-1zM8 25h2v1H8zM11 25h1v1h-1zM15 25h2v1h-2zM18 25h1v1h-1zM22 25h1v1h-1zM25 25h2v1h-2zM28 25h1v1h-1zM10 26h2v1h-2zM13 26h1v1h-1zM17 26h2v1h-2zM20 26h1v1h-1zM23 26h2v1h-2zM26 26h1v1h-1zM8 27h1v1H8zM12 27h2v1h-2zM15 27h1v1h-1zM19 27h1v1h-1zM21 27h2v1h-2zM24 27h1v1h-1zM27 27h2v1h-2zM9 28h3v1H9zM14 28h2v1h-2zM18 28h1v1h-1zM20 28h3v1h-3zM25 28h2v1h-2z"/></svg>';
const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
function send(res, code, type, body, cache) {
res.writeHead(code, { 'Content-Type': type, 'Cache-Control': cache ? 'public, max-age=86400' : 'no-store', 'Content-Length': Buffer.byteLength(body) });
res.end(body);
}
function json(res, v) { send(res, 200, 'application/json; charset=utf-8', JSON.stringify(v)); }
http.createServer((req, res) => {
const u = new URL(req.url, 'http://127.0.0.1');
if (u.pathname === '/mock/scenario') {
if (u.searchParams.get('set')) scenario = u.searchParams.get('set');
return json(res, { scenario, posts: posts.slice(-20) });
}
if (!u.pathname.startsWith(PREFIX)) return send(res, 404, 'text/plain', 'not found');
const rest = u.pathname.slice(PREFIX.length) || '/';
if (rest === '/' || rest === '/index.html') {
if (u.searchParams.get('scenario') && u.searchParams.get('scenario') !== scenario) scenario = u.searchParams.get('scenario');
return send(res, 200, types['.html'], fs.readFileSync(path.join(uiDir, 'index.html')));
}
if (req.method === 'GET' && rest === '/api/state') return json(res, stateNow());
if (req.method === 'GET' && rest === '/api/network') return json(res, { ok: true, chain_id: 7762959, chain_id_hex: '0x76770f', chain_name: 'Igneum devnet', rpc_url: 'https://rpc.devnet.igneum.network', symbol: 'IGN', decimals: 18, add_network_page: 'https://igneum.network/metamask', local_rpc: 'http://127.0.0.1:26790', public_rpc: 'https://rpc.devnet.igneum.network' });
if (req.method === 'GET' && rest === '/api/log') return json(res, { lines: [] });
if (req.method === 'GET' && rest.startsWith('/api/tx/')) {
const hash = rest.slice('/api/tx/'.length).toLowerCase();
const e = stateNow().history.find((x) => x.hash.toLowerCase() === hash);
if (!e) return json(res, { ok: false, error: 'unknown transaction' });
return json(res, { ok: true, entry: e, tx: null, receipt: null, node_status: { state: nodeWord(e), finalityActive: true }, verified: null });
}
if (req.method === 'POST') {
let body = ''; req.on('data', (c) => { body += c; }); req.on('end', () => {
posts.push({ t: Date.now() / 1000, path: rest, body: body.slice(0, 300) }); console.log('POST', rest, body.slice(0, 200));
let b = {}; try { b = JSON.parse(body || '{}'); } catch (e) { /* empty */ }
if (rest === '/api/receive') return json(res, { ok: true, address: ME, display: ME_D, svg: QR });
if (rest === '/api/create') return json(res, { ok: true, words: 'abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action actor actress actual'.split(' '), address: ME, display: ME_D });
if (rest === '/api/send/quote') {
if (!/^0x[0-9a-fA-F]{40}$/.test(b.to || '')) return json(res, { ok: false, error: 'that is not an address: 0x and 40 hex characters' });
const amt = parseFloat(b.amount || '0');
if (!(amt > 0)) return json(res, { ok: false, error: 'the amount needs to be more than 0' });
if (amt > 31.6321) return json(res, { ok: false, error: 'more than the balance (31.6321 IGN)' });
return json(res, { ok: true, to: b.to.toLowerCase(), value: IGN(amt).toString(), gas: 25380, base_fee: '1000000000', tip: '1000000000', max_fee: '3000000000', fee_max: '76140000000000', total_max: (IGN(amt) + 76140000000000n).toString(), chain_id: 7762959, nonce: 14, value_ign: String(amt), fee_max_ign: '0.00007614', total_max_ign: (amt + 0.00007614).toFixed(8).replace(/0+$/, ''), base_fee_gwei: '1', tip_gwei: '1', display_to: b.to === B ? B_D : b.to, confirm_nonce: 'n-1', confirm_reason: 'Send ' + amt + ' IGN to ' + (b.to.slice(0, 6) + '…' + b.to.slice(-4)), confirm_needed: scenario !== 'public' && scenario !== 'empty' && scenario !== 'plain' });
}
if (rest === '/api/send') return json(res, { ok: true, hash: '0x' + 'a1b2'.repeat(16), nonce: 14 });
if (rest === '/api/reveal') return json(res, { ok: true, words: 'abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action actor actress actual'.split(' '), private_key: '0x' + '01'.repeat(32) });
if (rest === '/api/unlock' || rest === '/api/create/confirm' || rest === '/api/import') { scenario = 'home'; return json(res, { ok: true }); }
if (rest === '/api/lock') { scenario = 'unlock'; return json(res, { ok: true }); }
if (rest === '/api/remove') { scenario = 'welcome'; return json(res, { ok: true }); }
if (rest === '/api/biometric/challenge') return json(res, { ok: true, nonce: 'n-2', reason: 'Show your recovery words' });
json(res, { ok: true });
});
return;
}
if (rest === '/coin.png') return send(res, 200, types['.png'], fs.readFileSync(coin), true);
if (rest.startsWith('/fonts/')) {
const f = path.normalize(path.join(fontDir, rest.slice('/fonts/'.length)));
if (!f.startsWith(fontDir) || !fs.existsSync(f)) return send(res, 404, 'text/plain', 'not found');
return send(res, 200, types['.woff2'], fs.readFileSync(f), true);
}
const file = path.normalize(path.join(uiDir, rest));
if (!file.startsWith(uiDir) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) return send(res, 404, 'text/plain', 'not found');
send(res, 200, types[path.extname(file)] || 'application/octet-stream', fs.readFileSync(file));
}).listen(port, '127.0.0.1', () => console.log('wallet ui-mock on http://127.0.0.1:' + port + PREFIX + '/ scenario=' + scenario));