diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 5a2ab8610..ba2bcc827 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -1,5 +1,6 @@ /* 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. */ + Unbounded for headings, IBM Plex Sans for text, IBM Plex Mono for numbers and labels. Fonts ship in the binary. + One type scale (--t-*), one spacing scale (--s-*), one colour set (:root), used by every screen. */ @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,53 +10,66 @@ @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 */ + --obsidian:#0C0C0E;--graphite:#16161A;--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);--node-blue:#7FA7C9;--nvidia:#8BE37A; + /* type scale */ + --t-xs:11px;--t-sm:12px;--t-base:13px;--t-md:14px;--t-lg:15px;--t-xl:16px;--t-2xl:18px;--t-num:26px;--t-h3:16px;--t-h2:32px;--t-h1:52px; + /* spacing scale */ + --s-1:4px;--s-2:8px;--s-3:12px;--s-4:16px;--s-5:20px;--s-6:28px;--s-7:40px; + --gutter:var(--s-6);--card-pad:22px;--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:var(--s-5);--gap-tile:var(--s-3); + --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;--drawer-h:260px} *{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-lg);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-h3)} 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{font-variant-numeric:tabular-nums} +.eyebrow{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.18em;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:44px;padding:10px 20px;border-radius:10px;font-weight:600;font-size:var(--t-lg);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.ghost.on{color:var(--molten);border-color:var(--molten-40);background:var(--molten-10)} .btn.danger:hover{color:var(--ember);border-color:var(--ember)} +.btn .chev{transition:transform .2s ease;color:var(--ash)} +.btn.on .chev{transform:rotate(180deg);color:var(--molten)} .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)} :focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px} +@media (prefers-reduced-motion:reduce){.btn,.btn .chev{transition:none}} /* 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: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 button,.top .pill{-webkit-app-region:no-drag} body.mac .top{padding-left:92px} -.brand{display:flex;align-items:center;gap:10px} +.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} +.top-right{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto} +.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} @@ -64,115 +78,118 @@ body.mac .top{padding-left:92px} @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} - +/* banners under the top bar: clock, update, job */ +.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:var(--ember-12);border-bottom:1px solid var(--ember-40);font-size:var(--t-md);line-height:1.4} .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.clock.warn{background:var(--molten-10);border-bottom-color:var(--molten-40)} +.banner .hint{font-size:var(--t-xs);color:var(--ash);flex-basis:100%;text-align:center} +.banner.update{flex-wrap:wrap;row-gap:var(--s-2)} .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{flex-wrap:wrap;row-gap:var(--s-2);background:var(--molten-10);border-bottom-color:var(--molten-40)} .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} +.banner.job .job-results{flex-basis:100%;margin:0;font-size:var(--t-xs);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:var(--s-2)} +.job-history th{text-align:left;font-weight:500;color:var(--ash);font-size:var(--t-xs);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{margin-top:var(--s-3);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:var(--s-2)} +.clock-card.warn{border-color:var(--molten-40);background:rgba(255,179,92,.06)} +.clock-msg{font-size:var(--t-md);color:var(--bone);line-height:1.45} .clock-card .note{margin-top:0} /* screens */ -main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter)} +main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain} body.has-bottom main{bottom:var(--bottom)} -body.drawer-open main{bottom:calc(var(--bottom) + 260px)} +body.drawer-open main{bottom:calc(var(--bottom) + var(--drawer-h))} .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} @keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}} @media (prefers-reduced-motion:reduce){.screen{animation:none}} /* 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} +.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-7) 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))} @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} +@media (prefers-reduced-motion:reduce){.coin-wrap::before{animation:none}} +.lead{font-size:var(--t-2xl);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} +.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} +.tile .h{font-family:var(--head);font-weight:700;font-size:var(--t-h3);margin-bottom:var(--s-2)} +.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} +.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} +.cards{display:flex;flex-direction:column;gap:var(--s-3);margin-top:var(--s-2)} .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} +.card.detecting .t{font-family:var(--head);font-weight:700;font-size:var(--t-xl)} +.card.detecting .s{font-size:var(--t-sm);color:var(--ash);margin-top:var(--s-1)} .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{width:52px;height:52px;border-radius:14px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);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.nvidia{color:var(--nvidia)} .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 .name{font-family:var(--head);font-weight:700;font-size:var(--t-2xl);line-height:1.2} +.gpu .meta{font-family:var(--mono);font-size:var(--t-sm);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 .msg{font-size:var(--t-sm);color:var(--ember);margin-top:var(--s-1)} /* 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{display:flex;align-items:center;gap:var(--s-4);background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:16px 20px;min-width:0;flex-wrap:wrap} .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{width:48px;height:48px;border-radius:13px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);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.nvidia{color:var(--nvidia)} .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)} +.gpu-row .info{flex:1;min-width:180px;display:flex;flex-direction:column;gap:var(--s-1)} +.gpu-row .name{font-family:var(--head);font-weight:700;font-size:var(--t-xl);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;white-space:nowrap} +.kind.discrete,.kind.apple{color:var(--molten);border-color:var(--molten-40)} .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{font-family:var(--mono);font-size:var(--t-sm);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)} +.gpu-row .reason{font-size:var(--t-sm);color:var(--ash)} +.gpu-row .msg{font-size:var(--t-sm);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 .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);margin-right:var(--s-1)} .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{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:var(--t-base);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{padding:12px 14px;gap:var(--s-3);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 .gpu-row .name{font-size:var(--t-md)} +.cards.compact .gpu-row .info{flex-basis:calc(100% - 50px);min-width:120px} +.cards.compact .gpu-row .power{margin-left:50px} +.cards.compact .gpu-row .switch{margin-left:auto} .cards.compact .ids .k{display:none} -.power{display:flex;align-items:center;gap:8px;flex:0 0 auto} +.power{display:flex;align-items:center;gap:var(--s-2);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} +.power .pv{font-size:var(--t-sm);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)} @@ -186,22 +203,22 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card /* 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 .n{font-family:var(--head);font-weight:700;font-size:var(--t-md);line-height:1.25;display:flex;align-items:center;gap:var(--s-2);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 .h .unit{font-family:var(--mono);font-size:var(--t-xs);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{font-family:var(--mono);font-size:var(--t-sm);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{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);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)} +.gpu-tile .msg{font-size:var(--t-sm);color:var(--ash)} +.gpu-off{margin-top:var(--s-3);font-size:var(--t-sm);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} +.options{display:flex;flex-direction:column;gap:var(--s-3);margin-top:6px} +.option{display:flex;gap:var(--s-4);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} @@ -209,61 +226,63 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .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} +.option .t{font-family:var(--head);font-weight:700;font-size:var(--t-xl);display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.option .s{font-size:var(--t-md);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 var(--molten-40);border-radius:999px;padding:2px 8px} +.addr-input{width:100%;margin-top:var(--s-2);background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;color:var(--bone);font-size:var(--t-md);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)} +.err{font-size:var(--t-base);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 .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash);white-space:nowrap} +.cell .v{font-family:var(--head);font-weight:700;font-size:var(--t-num);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:var(--s-2);min-height:1.1em} .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 .unit{font-family:var(--mono);font-size:var(--t-sm);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 .s{font-family:var(--mono);font-size:var(--t-sm);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)} +.cell.bad .s{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)} +.card-head{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);margin-bottom:var(--s-3);flex-wrap:wrap} +.stats{display:flex;flex-wrap:wrap;gap:12px 16px;font-size:var(--t-sm);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} +.legend{display:flex;flex-wrap:wrap;gap:14px 18px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ink-2)} +.legend span{display:inline-flex;align-items:center;gap:var(--s-2)} .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} +table{border-collapse:collapse;width:100%;font-size:var(--t-base)} 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{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);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} +td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(--ash);white-space:normal;max-width:280px} +.empty{color:var(--ash);font-size:var(--t-base);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{display:flex;justify-content:space-between;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: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} +.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;text-align:right} .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{display:flex;flex-direction:column;font-family:var(--mono);font-size:var(--t-sm);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>div>span:first-child{min-width:0} +.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)} .feed .k{color:var(--molten);margin-right:6px} .feed .k.error{color:var(--ember)} .feed .k.block{color:var(--ember)} @@ -271,61 +290,128 @@ td .sub{display:block;font-family:var(--mono);font-size:11px;color:var(--ash);wh /* 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} +.sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;max-height:calc(100vh - 48px);overflow:auto;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:var(--t-lg);color:var(--ink-2)} +.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)} +.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)} +.check{display:flex;align-items:center;gap:10px;font-size:var(--t-md);cursor:pointer;margin-top:var(--s-1)} +.check.small{font-size:var(--t-sm);color:var(--ash);margin-top:0} +.check input{width:18px;height:18px;accent-color:var(--ember);margin:0} +.check.small input{width:15px;height:15px} .sheet .cta{justify-content:flex-start} /* 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} +@media (prefers-reduced-motion:reduce){.panel,.sheet{animation:none}} +.panel-head{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid var(--line);flex:0 0 auto} +.panel-body{padding:14px 22px 28px;overflow:auto;display:flex;flex-direction:column;gap:18px;overscroll-behavior:contain} +.row{display:flex;gap:10px;align-items:center;min-width:0} .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} +.row .addr-input{margin-top:0;min-width:0} +.num{width:90px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:9px 12px;color:var(--bone);font-size:var(--t-md);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} +.switch{display:flex;align-items:center;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:6px 0;line-height:1.35} .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 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} /* 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} +.bottom{position:fixed;left:0;right:0;bottom:0;height:var(--bottom);display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);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:var(--s-2);flex:0 0 auto} +.bottom .mid{font-size:var(--t-sm);color:var(--ash);display:flex;align-items:center;justify-content:center;gap:0;min-width:0;flex:1;overflow:hidden;white-space:nowrap} +.bottom .mid .pc{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis} +.bottom .mid .pc+.pc::before{content:"·";margin:0 7px;color:var(--line-2)} +.bottom .mid .nm{color:var(--ink-2);max-width:220px;flex-shrink:1} +.bottom .mid .ad{flex-shrink:0} +.bottom .right .mono{font-size:var(--t-sm)} +@media (max-width:1100px){.bottom .mid .up{display:none}.bottom .mid .nm{max-width:140px}.log-tools .at{display:none}.log-search{flex-basis:120px;max-width:200px}} +@media (max-width:960px){.bottom .mid .ad{display:none}} /* 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} +.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;box-shadow:0 -12px 30px rgba(0,0,0,.35)} +body.drawer-open .drawer{height:var(--drawer-h)} +body.drawer-drag .drawer{transition:none} +body.drawer-drag{cursor:row-resize} +body.drawer-drag main{pointer-events:none} +@media (prefers-reduced-motion:reduce){.drawer{transition:none}} +.drawer-grip{height:10px;flex:0 0 10px;cursor:row-resize;display:flex;align-items:center;justify-content:center;touch-action:none} +.drawer-grip i{width:44px;height:3px;border-radius:2px;background:var(--line-2);transition:background .15s ease} +.drawer-grip:hover i,body.drawer-drag .drawer-grip i{background:var(--ash)} +.drawer-head{display:flex;align-items:center;gap:var(--s-3);padding:2px var(--gutter) 8px;border-bottom:1px solid var(--line);flex:0 0 auto;flex-wrap:wrap;row-gap:6px} +.chips{display:flex;gap:6px;flex-wrap:wrap} +.chip{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;text-transform:uppercase;border:1px solid var(--line);border-radius:999px;padding:4px 10px;background:transparent;color:var(--ash);cursor:pointer;white-space:nowrap;line-height:1.3;transition:color .15s ease,border-color .15s ease} +.chip:hover{color:var(--ink-2);border-color:var(--line-2)} +.chip.on{color:var(--molten);border-color:var(--molten-40)} +.chip:disabled{opacity:.4;cursor:default} +.log-search{display:flex;align-items:center;gap:6px;background:var(--graphite);border:1px solid var(--line);border-radius:999px;padding:3px 10px 3px 10px;color:var(--ash);flex:1 1 160px;min-width:140px;max-width:300px} +.log-search:focus-within{border-color:var(--molten-40);color:var(--ink-2)} +.log-search input{flex:1;min-width:0;background:transparent;border:0;outline:0;color:var(--bone);font-family:var(--mono);font-size:var(--t-sm);padding:2px 0;user-select:text;-webkit-user-select:text} +.log-search input::-webkit-search-cancel-button{-webkit-appearance:none} +.log-search input::placeholder{color:var(--ash)} +.log-search .count{font-size:var(--t-xs);color:var(--ash);white-space:nowrap;font-variant-numeric:tabular-nums} +.log-nav{display:flex;align-items:center;gap:6px} +.log-nav input{width:84px;background:var(--graphite);border:1px solid var(--line);border-radius:999px;padding:4px 10px;color:var(--bone);font-size:var(--t-xs);letter-spacing:.06em;outline:0;user-select:text;-webkit-user-select:text;text-align:center} +.log-nav input:focus{border-color:var(--molten-40)} +.log-nav input::placeholder{color:var(--ash);letter-spacing:.04em} +.log-tools{display:flex;align-items:center;gap:var(--s-2);margin-left:auto} +.log-tools .at{font-size:var(--t-xs);min-width:64px;text-align:right} +.log-ruler{position:relative;height:16px;flex:0 0 16px;background:var(--graphite);border-bottom:1px solid var(--line);cursor:pointer;overflow:hidden} +.log-ruler.empty{cursor:default} +.log-ruler.empty>*{display:none} +.log-ruler .t0,.log-ruler .t1{position:absolute;top:0;z-index:2;font-size:9px;line-height:16px;color:var(--ash);padding:0 6px;margin:0 var(--gutter);pointer-events:none;letter-spacing:.04em;background:var(--graphite);border-radius:0 0 6px 0} +.log-ruler .t1{right:0;border-radius:0 0 0 6px} +.log-ruler .mk{position:absolute;top:4px;bottom:4px;width:2px;margin-left:-1px;background:var(--ember);opacity:.9;pointer-events:none} +.log-ruler .mk.swap{background:var(--molten)} +.log-ruler .win{position:absolute;top:0;bottom:0;background:rgba(244,241,236,.14);border-left:1px solid rgba(244,241,236,.35);border-right:1px solid rgba(244,241,236,.35);min-width:3px;pointer-events:none} +.log-ruler:hover .win{background:rgba(244,241,236,.2)} +.log-view{position:relative;flex:1;overflow:auto;overscroll-behavior:contain;user-select:text;-webkit-user-select:text;contain:strict} +.log-pad{position:relative;min-height:100%} +.log{margin:0;position:absolute;top:0;left:0;right:0;padding:6px var(--gutter);font-size:var(--t-sm);line-height:19px;color:var(--ink-2);white-space:pre;will-change:transform} +.log .ln{height:19px;overflow:hidden;text-overflow:ellipsis;white-space:pre} +.log-view.wrap .log{position:relative;padding-bottom:12px} +.log-view.wrap .log .ln{height:auto;white-space:pre-wrap;word-break:break-all;overflow:visible} .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.node{color:var(--node-blue)} +.log .src.miner1,.log .src.miner2,.log .src.miner3,.log .src.miner4,.log .src.miner5,.log .src.miner6,.log .src.miner7,.log .src.miner8{color:var(--molten)} .log .src.app{color:var(--ember)} -.log .e{color:var(--ember)} +.log .src.watch{color:var(--ash)} +.log .e,.log .e .src{color:var(--ember)} +.log .ln.hit{background:rgba(255,179,92,.18);box-shadow:inset 3px 0 0 var(--molten);margin:0 calc(-1 * var(--gutter));padding:0 var(--gutter)} +.log mark{background:rgba(255,179,92,.3);color:var(--bone);border-radius:2px;padding:0 1px} +.log-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--ash);font-size:var(--t-sm);padding:0 var(--gutter);text-align:center} +.log-jump{position:absolute;right:calc(var(--gutter) + 14px);bottom:14px;background:var(--graphite);border-color:var(--molten-40);color:var(--molten);box-shadow:0 8px 24px rgba(0,0,0,.5);z-index:2;gap:6px;padding:6px 12px} +.log-jump:hover{background:#1c1a18;border-color:var(--molten)} -.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)} +.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:var(--t-base);z-index:40;box-shadow:0 10px 30px rgba(0,0,0,.5);max-width:min(90vw,520px);text-align:center} +body.drawer-open .toast{bottom:calc(var(--bottom) + var(--drawer-h) + 16px)} +/* narrow windows: the strip folds to two columns, the side column drops under the main one */ @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} } +/* short windows (under 700 px): tighter paddings, a lower hero, a smaller canvas, so the bottom bar and the drawer + always have room */ +@media (max-height:700px){ + :root{--card-pad:18px;--tile-pad:14px 16px;--gap:var(--s-4)} + #screen-dashboard{padding:16px 0 20px} + #dag{height:150px} + .hero{gap:var(--s-3);padding:var(--s-5) 0 var(--s-6)} + .coin-wrap{width:104px;height:104px} + .coin{width:104px;height:104px} + h1{font-size:40px} + .lead{font-size:var(--t-xl)} + .step{padding:32px 0 32px} + .feed{max-height:220px} + .drawer-head{padding-bottom:6px} +} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 9c512dc45..7fe3d0508 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -11,6 +11,22 @@ if (params.get('screen')) forced = params.get('screen'); var forcedUpdate = params.get('update'); // a sample update state for screenshots: available|downloading|ready|waiting|applying|urgent|manual|error|updated if (params.get('host') === 'mac') document.body.classList.add('mac'); + // ?debug=1 prints a budget line every 5 s (ms of JS per second in the canvas, the state render and the log) and + // exposes the counters as window.__igneumPerf + var debug = params.get('debug') === '1'; + // the hosts hide the window to the menu bar or the tray: nothing draws or polls fast while document.hidden. + // ?visible=1 ignores that (screenshots from a hidden pane). + var forceVisible = params.get('visible') === '1'; + function hiddenNow() { return document.hidden && !forceVisible; } + var perf = { frames: 0, draw: 0, render: 0, log: 0, poll: 0, polls: 0, since: performance.now() }; + if (debug) { + window.__igneumPerf = perf; + setInterval(function () { + var s = (performance.now() - perf.since) / 1000; + console.log('[perf] ' + (hiddenNow() ? 'hidden' : 'visible') + ' ' + (perf.frames / s).toFixed(1) + ' fps · js ms/s: canvas ' + (perf.draw / s).toFixed(2) + ' render ' + (perf.render / s).toFixed(2) + ' log ' + (perf.log / s).toFixed(2) + ' poll ' + (perf.poll / s).toFixed(2) + ' · ' + (perf.polls / s).toFixed(2) + ' polls/s · total ' + ((perf.draw + perf.render + perf.log + perf.poll) / s / 10).toFixed(2) + '% of one core'); + perf.frames = 0; perf.draw = 0; perf.render = 0; perf.log = 0; perf.poll = 0; perf.polls = 0; perf.since = performance.now(); + }, 5000); + } // ---------- helpers ---------- function api(path, body) { @@ -26,11 +42,12 @@ function clock(t) { var d = new Date(t * 1000); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0'); } var toastT = null; function toast(text) { var t = $('toast'); t.textContent = text; t.hidden = false; clearTimeout(toastT); toastT = setTimeout(function () { t.hidden = true; }, 2200); } - function copyText(text) { - if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function () { toast('Copied'); }, function () { fallbackCopy(text); }); } - else fallbackCopy(text); + function copyText(text, msg) { + msg = msg || 'Copied'; + if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function () { toast(msg); }, function () { fallbackCopy(text, msg); }); } + else fallbackCopy(text, msg); } - function fallbackCopy(text) { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); toast('Copied'); } catch (e) { toast('Select and copy it by hand'); } document.body.removeChild(ta); } + function fallbackCopy(text, msg) { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); toast(msg || 'Copied'); } catch (e) { toast('Select and copy it by hand'); } document.body.removeChild(ta); } document.addEventListener('click', function (e) { var b = e.target.closest('[data-copy]'); if (!b) return; copyText($(b.getAttribute('data-copy')).textContent); @@ -44,7 +61,7 @@ $('bottom').hidden = !dash; $('btn-settings').hidden = !dash; document.body.classList.toggle('has-bottom', dash); - if (!dash) document.body.classList.remove('drawer-open'); + if (!dash && drawerOpen()) setDrawer(false); if (p === 'cards' && !cardsSeen) { cardsSeen = true; api('api/detect', {}); } api('api/phase', { phase: p }); } @@ -157,72 +174,278 @@ if (!state) return; api(state.mining.paused ? 'api/resume' : 'api/pause', {}); }); - $('btn-logs').addEventListener('click', function () { document.body.classList.toggle('drawer-open'); if (document.body.classList.contains('drawer-open')) pollLog(); }); + $('btn-logs').addEventListener('click', function () { setDrawer(!document.body.classList.contains('drawer-open')); }); $('btn-quit').addEventListener('click', function () { if (!confirm('Quit Igneum Miner? The miner stops first, then the node.')) return; api('api/quit', {}); $('pill-text').textContent = 'stopping'; $('btn-quit').disabled = true; }); // ---------- log drawer ---------- - var logAfter = 0, logFilter = '', logLines = []; - document.querySelectorAll('.chip').forEach(function (c) { c.addEventListener('click', function () { document.querySelectorAll('.chip').forEach(function (x) { x.classList.toggle('on', x === c); }); logFilter = c.getAttribute('data-src'); renderLog(); }); }); - function renderLog() { - var el = $('log'), follow = $('log-follow').checked; - var lines = logLines.filter(function (l) { return !logFilter || l.src.indexOf(logFilter) === 0; }).slice(-600); - el.innerHTML = lines.map(function (l) { return '' + clock(l.t) + ' ' + esc(l.src.padEnd(6)) + ' ' + (l.err ? '' : '') + esc(l.text) + (l.err ? '' : ''); }).join('\n'); - if (follow) el.scrollTop = el.scrollHeight; + // logLines: everything fetched this session (newest last, capped at LOG_CAP). view: the lines after the source chip + // and the search box. The list is virtualised: rows have a fixed height (RH) and only the rows in the viewport are in + // the DOM, so 20,000 lines cost the same as 20. Wrap mode renders the whole view with wrapped lines when it is small + // enough. Polling runs only while the drawer is open and the window is visible. + var LOG_CAP = 20000, WRAP_MAX = 5000, RH = 19, OVERSCAN = 8; + var logAfter = 0, logFilter = '', logQuery = '', logLines = [], view = [], viewDirty = true, wrap = false, follow = true; + var logView = $('log-view'), logPre = $('log'), logPad = $('log-pad'); + var winStart = -1, winEnd = -1, autoScroll = false, scrollRaf = 0, rulerAt = 0, rulerCount = -1, searchT = null, logTimer = null; + var ERR_RE = /\[error\]|\berror\b|\bfailed\b|\bpanic\b|exited with code/i, SWAP_RE = /hourly program changed|program changed|seed change|hot swap|new program|exit-on-seed-change/i; + function isErr(l) { return l.err || ERR_RE.test(l.text); } + function isSwap(l) { return SWAP_RE.test(l.text); } + function drawerOpen() { return document.body.classList.contains('drawer-open'); } + function setDrawer(open) { + document.body.classList.toggle('drawer-open', open); + var b = $('btn-logs'); b.classList.toggle('on', open); b.setAttribute('aria-expanded', open ? 'true' : 'false'); + $('btn-logs-text').textContent = open ? 'Close logs' : 'Logs'; + if (open) { applyDrawerHeight(); pollLog(); scheduleLogPoll(); viewDirty = true; renderLog(true); } + else if (logTimer) { clearTimeout(logTimer); logTimer = null; } } + // height: dragged by the grip, clamped to [140, 70% of the window]; a short window gets a smaller default + var drawerH = 0; + function defaultDrawerH() { return window.innerHeight < 700 ? 180 : 260; } + function applyDrawerHeight() { + var max = Math.floor(window.innerHeight * 0.7), min = 140; + if (!drawerH) { try { drawerH = parseInt(localStorage.getItem('igneum.drawer-h'), 10) || 0; } catch (e) { } } + var h = Math.max(min, Math.min(max, drawerH || defaultDrawerH())); + document.body.style.setProperty('--drawer-h', h + 'px'); + if (drawerOpen()) renderLog(true); + } + (function () { + var grip = $('drawer-grip'), startY = 0, startH = 0; + grip.addEventListener('pointerdown', function (e) { + startY = e.clientY; startH = parseInt(getComputedStyle(document.body).getPropertyValue('--drawer-h'), 10) || defaultDrawerH(); + grip.setPointerCapture(e.pointerId); document.body.classList.add('drawer-drag'); e.preventDefault(); + }); + grip.addEventListener('pointermove', function (e) { + if (!document.body.classList.contains('drawer-drag')) return; + drawerH = startH + (startY - e.clientY); applyDrawerHeight(); + }); + function end(e) { if (!document.body.classList.contains('drawer-drag')) return; document.body.classList.remove('drawer-drag'); try { localStorage.setItem('igneum.drawer-h', String(parseInt(getComputedStyle(document.body).getPropertyValue('--drawer-h'), 10))); } catch (x) { } } + grip.addEventListener('pointerup', end); grip.addEventListener('pointercancel', end); + grip.addEventListener('dblclick', function () { drawerH = defaultDrawerH(); applyDrawerHeight(); try { localStorage.removeItem('igneum.drawer-h'); } catch (x) { } }); + })(); + window.addEventListener('resize', function () { if (drawerOpen()) applyDrawerHeight(); }); + document.querySelectorAll('#log-chips .chip').forEach(function (c) { c.addEventListener('click', function () { document.querySelectorAll('#log-chips .chip').forEach(function (x) { x.classList.toggle('on', x === c); }); logFilter = c.getAttribute('data-src'); viewDirty = true; renderLog(true); }); }); + $('log-search').addEventListener('input', function () { clearTimeout(searchT); var v = this.value; searchT = setTimeout(function () { logQuery = v.trim().toLowerCase(); viewDirty = true; renderLog(true); }, 120); }); + $('log-search').addEventListener('keydown', function (e) { if (e.key === 'Escape') { this.value = ''; logQuery = ''; viewDirty = true; renderLog(true); } }); + $('log-wrap').addEventListener('click', function () { wrap = !wrap; this.classList.toggle('on', wrap); logView.classList.toggle('wrap', wrap); renderLog(true); }); + $('log-follow').addEventListener('change', function () { setFollow(this.checked); if (follow) renderLog(true); }); + $('log-jump').addEventListener('click', function () { setFollow(true); renderLog(true); }); + $('log-copy').addEventListener('click', function () { + var r = visibleRange(), rows = view.slice(r[0], r[1]); + if (!rows.length) { toast('Nothing to copy'); return; } + copyText(rows.map(function (l) { return clock(l.t) + ' ' + l.src.padEnd(6) + ' ' + l.text; }).join('\n'), 'Copied ' + rows.length + ' line' + (rows.length === 1 ? '' : 's')); + }); + $('log-last-error').addEventListener('click', function () { jumpToLast(isErr, 'No error in the log'); }); + $('log-last-swap').addEventListener('click', function () { jumpToLast(isSwap, 'No program swap in the log yet'); }); + $('log-time').addEventListener('keydown', function (e) { if (e.key === 'Enter') { jumpToTime(this.value); } }); + $('log-time').addEventListener('change', function () { jumpToTime(this.value); }); + $('log-ruler').addEventListener('click', function (e) { + if (!view.length) return; + var r = this.getBoundingClientRect(), f = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)); + var t0 = view[0].t, t1 = view[view.length - 1].t; + jumpToIndex(lowerBound(view, t0 + f * (t1 - t0))); + }); + function setFollow(on) { follow = on; $('log-follow').checked = on; $('log-jump').hidden = on; } + function lowerBound(arr, t) { var lo = 0, hi = arr.length; while (lo < hi) { var mid = (lo + hi) >> 1; if (arr[mid].t < t) lo = mid + 1; else hi = mid; } return lo; } + function jumpToTime(v) { + var m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec((v || '').trim()); + if (!m) { toast('Type a time as HH:MM:SS'); return; } + if (!view.length) { toast('Nothing in the log'); return; } + var last = new Date(view[view.length - 1].t * 1000), d = new Date(last.getTime()); + d.setHours(+m[1], +m[2], +(m[3] || 0), 0); + if (d.getTime() > last.getTime() + 1000) d.setDate(d.getDate() - 1); // a time later than the newest line means yesterday + var target = d.getTime() / 1000; + if (target < view[0].t - 1) { toast('The log starts at ' + clock(view[0].t)); return; } + jumpToIndex(lowerBound(view, target)); + } + function jumpToLast(test, missing) { + for (var i = view.length - 1; i >= 0; i--) if (test(view[i])) { jumpToIndex(i); return; } + toast(missing); + } + var hitSeq = 0, hitT = 0; + function jumpToIndex(i) { + if (!view.length) return; + i = Math.max(0, Math.min(view.length - 1, i)); + setFollow(false); + hitSeq = view[i].seq; hitT = performance.now(); + autoScroll = true; + logView.scrollTop = Math.max(0, rowTop(i) - Math.floor(logView.clientHeight / 3)); + renderLog(true); + setTimeout(function () { hitSeq = 0; renderLog(true); }, 1600); + } + function rowTop(i) { + if (!wrap) return i * RH; + var kids = logPre.children; if (i < kids.length) return kids[i].offsetTop; return logPre.scrollHeight; + } + function visibleRange() { + if (!view.length) return [0, 0]; + if (wrap) { // find by offsetTop (the rows are all in the DOM) + var kids = logPre.children, top = logView.scrollTop, bottom = top + logView.clientHeight, a = -1, b = kids.length; + for (var k = 0; k < kids.length; k++) { var y = kids[k].offsetTop; if (a < 0 && y + kids[k].offsetHeight > top) a = k; if (y >= bottom) { b = k; break; } } + return [Math.max(0, a), b]; + } + var s = Math.floor(logView.scrollTop / RH), n = Math.ceil(logView.clientHeight / RH) + 1; + return [Math.max(0, s), Math.min(view.length, s + n)]; + } + function rebuildView() { + var q = logQuery; + view = logLines.filter(function (l) { return (!logFilter || l.src.indexOf(logFilter) === 0) && (!q || l.text.toLowerCase().indexOf(q) >= 0 || l.src.indexOf(q) >= 0); }); + viewDirty = false; winStart = winEnd = -1; + } + function appendView(lines) { + var q = logQuery; + for (var i = 0; i < lines.length; i++) { var l = lines[i]; if ((!logFilter || l.src.indexOf(logFilter) === 0) && (!q || l.text.toLowerCase().indexOf(q) >= 0 || l.src.indexOf(q) >= 0)) view.push(l); } + } + function rowHtml(l) { + var text = esc(l.text), q = logQuery; + if (q) { // highlight every match (case-insensitive, on the escaped text; the query itself is escaped too) + var eq = esc(q), lower = text.toLowerCase(), out = '', at = 0, idx; + while ((idx = lower.indexOf(eq, at)) >= 0) { out += text.slice(at, idx) + '' + text.slice(idx, idx + eq.length) + ''; at = idx + eq.length; } + text = out + text.slice(at); + } + return '
' + clock(l.t) + ' ' + esc(l.src.padEnd(6)) + ' ' + text + '
'; + } + function renderLog(force) { + if (!drawerOpen()) return; + var a = performance.now(); + if (viewDirty) rebuildView(); + var n = view.length; + $('log-empty').hidden = n > 0; + $('log-empty').textContent = logLines.length ? 'No line matches.' : 'Nothing logged yet. Lines appear as the node and the miner write them.'; + $('log-count').textContent = (logQuery || logFilter) ? (n + ' of ' + logLines.length) : (n ? String(n) : ''); + var canWrap = n <= WRAP_MAX; $('log-wrap').disabled = !canWrap; $('log-wrap').title = canWrap ? 'Wrap long lines' : 'Wrap is off above 5,000 lines; filter or search first'; + if (wrap && canWrap) { + logPad.style.height = ''; logPre.style.transform = ''; + if (force || winStart !== 0 || winEnd !== n) { logPre.innerHTML = view.map(rowHtml).join(''); winStart = 0; winEnd = n; } + } else { + if (wrap && !canWrap) { wrap = false; $('log-wrap').classList.remove('on'); logView.classList.remove('wrap'); } + logPad.style.height = (n * RH) + 'px'; + if (follow) { autoScroll = true; logView.scrollTop = Math.max(0, n * RH - logView.clientHeight); } + else if (logView.scrollTop > n * RH) { autoScroll = true; logView.scrollTop = Math.max(0, n * RH - logView.clientHeight); } + var r = visibleRange(), s = Math.max(0, r[0] - OVERSCAN), e = Math.min(n, r[1] + OVERSCAN); + if (force || s !== winStart || e !== winEnd) { + var h = ''; for (var i = s; i < e; i++) h += rowHtml(view[i]); + logPre.innerHTML = h; logPre.style.transform = 'translateY(' + (s * RH) + 'px)'; winStart = s; winEnd = e; + } + } + if (follow && wrap) { autoScroll = true; logView.scrollTop = logView.scrollHeight; } + updateAt(); updateRuler(force); + perf.log += performance.now() - a; + } + function updateAt() { + var r = visibleRange(); + $('log-at').textContent = view.length ? clock(view[Math.min(view.length - 1, r[0])].t) : ''; + } + // the ruler: the span of the view, error (ember) and swap (molten) marks, and the window in view; rebuilt when the + // count changes, at most every 2 s, from a sample of at most 4,000 lines + function updateRuler(force) { + var ruler = $('log-ruler'), n = view.length; + if (!n) { ruler.classList.add('empty'); return; } + ruler.classList.remove('empty'); + var t0 = view[0].t, t1 = view[n - 1].t, span = Math.max(1, t1 - t0); + var r = visibleRange(), win = $('log-ruler-win'), i0 = Math.min(n - 1, r[0]), i1 = Math.max(i0, Math.min(n - 1, r[1] - 1)); + win.style.left = ((view[i0].t - t0) / span * 100) + '%'; + win.style.width = Math.max(0.5, (view[i1].t - view[i0].t) / span * 100) + '%'; + var now = performance.now(); + if (n === rulerCount && now - rulerAt < 2000) return; + rulerAt = now; rulerCount = n; + $('log-ruler-0').textContent = clock(t0); $('log-ruler-1').textContent = clock(t1); + ruler.querySelectorAll('.mk').forEach(function (m) { m.remove(); }); + var step = Math.max(1, Math.floor(n / 4000)), h = ''; + for (var i = 0; i < n; i += step) { var l = view[i]; var sw = isSwap(l); if (sw || isErr(l)) h += ''; } + win.insertAdjacentHTML('beforebegin', h); + } + logView.addEventListener('scroll', function () { + if (autoScroll) { autoScroll = false; return; } + var atEnd = logView.scrollTop + logView.clientHeight >= logView.scrollHeight - RH; + if (atEnd !== follow) setFollow(atEnd); + if (!scrollRaf) scrollRaf = requestAnimationFrame(function () { scrollRaf = 0; renderLog(false); }); + }); function pollLog() { - if (!document.body.classList.contains('drawer-open')) return; - api('api/log?after=' + logAfter + '&limit=400').then(function (r) { - if (r.lines && r.lines.length) { logLines = logLines.concat(r.lines).slice(-3000); logAfter = r.lines[r.lines.length - 1].seq; renderLog(); } + if (!drawerOpen() || hiddenNow()) return; + api('api/log?after=' + logAfter + '&limit=' + (logAfter ? 400 : 5000)).then(function (r) { + perf.polls++; + if (r.lines && r.lines.length) { + logLines = logLines.concat(r.lines); logAfter = r.lines[r.lines.length - 1].seq; + if (logLines.length > LOG_CAP) { logLines = logLines.slice(-LOG_CAP); viewDirty = true; } else if (!viewDirty) appendView(r.lines); + var a = performance.now(); renderLog(false); perf.poll += performance.now() - a; + } }).catch(function () { }); } - setInterval(pollLog, 1500); + function scheduleLogPoll() { clearTimeout(logTimer); logTimer = setTimeout(function () { pollLog(); if (drawerOpen()) scheduleLogPoll(); }, 1500); } // ---------- the blocks strip (your blocks over the last 10 minutes) ---------- - var c = $('dag'), x = c.getContext('2d'), W = 0, H = 0, seen = {}, born = {}; - function size() { W = c.clientWidth; H = c.clientHeight; if (!W || !H) return; c.width = W * dpr; c.height = H * dpr; x.setTransform(dpr, 0, 0, dpr, 0, 0); } + // The minute grid never moves (it hangs off "now", which is always the right edge), so it is drawn once per size + // into an offscreen layer. A frame is clear + the layer + the rail + the blocks. Blocks drift 1.7 px/s, so the + // strip redraws twice a second; while a block flashes (1.2 s after it appears) it runs at the display rate. Nothing + // draws while the window is hidden (the hosts hide it to the menu bar or the tray). + var c = $('dag'), x = c.getContext('2d'), W = 0, H = 0, born = {}, grid = document.createElement('canvas'), gx = grid.getContext('2d'); + var SPAN = 600, S = 18, PAD = 10, RIGHT_PAD = 26; + function size() { + W = c.clientWidth; H = c.clientHeight; if (!W || !H) return; + c.width = W * dpr; c.height = H * dpr; x.setTransform(dpr, 0, 0, dpr, 0, 0); + grid.width = W * dpr; grid.height = H * dpr; gx.setTransform(dpr, 0, 0, dpr, 0, 0); + gx.clearRect(0, 0, W, H); + var right = W - RIGHT_PAD, left = PAD, pxs = (right - left) / SPAN; + gx.font = '500 10px IBM Plex Mono, monospace'; gx.textAlign = 'center'; + for (var m = 0; m <= 10; m++) { + var px = right - m * 60 * pxs; + gx.strokeStyle = m === 0 ? 'rgba(255,179,92,0.5)' : 'rgba(42,42,48,0.9)'; gx.lineWidth = 1; gx.setLineDash(m === 0 ? [] : [3, 6]); + gx.beginPath(); gx.moveTo(px, 14); gx.lineTo(px, H - 18); gx.stroke(); gx.setLineDash([]); + if (px > 30) { gx.fillStyle = 'rgba(154,154,158,0.8)'; gx.fillText(m === 0 ? 'now' : '-' + m + ' min', px, H - 5); } + } + var rail = H * 0.5; + gx.strokeStyle = 'rgba(58,58,66,0.9)'; gx.lineWidth = 1; gx.beginPath(); gx.moveTo(left, rail); gx.lineTo(right, rail); gx.stroke(); + requestDraw(); + } window.addEventListener('resize', function () { size(); }); function rrect(px, py, w, h, r) { x.beginPath(); x.moveTo(px + r, py); x.lineTo(px + w - r, py); x.quadraticCurveTo(px + w, py, px + w, py + r); x.lineTo(px + w, py + h - r); x.quadraticCurveTo(px + w, py + h, px + w - r, py + h); x.lineTo(px + r, py + h); x.quadraticCurveTo(px, py + h, px, py + h - r); x.lineTo(px, py + r); x.quadraticCurveTo(px, py, px + r, py); x.closePath(); } + // returns true while something is animating (a flash), so the caller keeps the fast rate function draw(now) { if (!W || !H) size(); - if (!W) return; + if (!W) return false; x.clearRect(0, 0, W, H); - var span = 600, S = 18, pad = 10, right = W - 26, left = pad; - var pxs = (right - left) / span; + x.drawImage(grid, 0, 0, W, H); + var right = W - RIGHT_PAD, pxs = (right - PAD) / SPAN; var nowU = state ? (state.now + (performance.now() - stateAt) / 1000) : Date.now() / 1000; - // minute lines - x.font = '500 10px IBM Plex Mono, monospace'; x.textAlign = 'center'; - for (var m = 0; m <= 10; m++) { - var t = nowU - m * 60, px = right - (nowU - t) * pxs; - x.strokeStyle = m === 0 ? 'rgba(255,179,92,0.5)' : 'rgba(42,42,48,0.9)'; x.lineWidth = 1; x.setLineDash(m === 0 ? [] : [3, 6]); - x.beginPath(); x.moveTo(px, 14); x.lineTo(px, H - 18); x.stroke(); x.setLineDash([]); - if (px > 30) { x.fillStyle = 'rgba(154,154,158,0.8)'; x.fillText(m === 0 ? 'now' : '-' + m + ' min', px, H - 5); } - } var found = state ? state.mining.found : []; - var rail = H * 0.5; - x.strokeStyle = 'rgba(58,58,66,0.9)'; x.lineWidth = 1; x.beginPath(); x.moveTo(left, rail); x.lineTo(right, rail); x.stroke(); - var shown = 0; - found.forEach(function (t, i) { - var age = nowU - t; if (age < 0 || age > span) return; shown++; + var rail = H * 0.5, shown = 0, animating = false; + for (var i = 0; i < found.length; i++) { + var t = found[i], age = nowU - t; if (age < 0 || age > SPAN) continue; shown++; var key = String(t); - if (!born[key]) born[key] = now; + if (!born[key]) born[key] = age < 5 ? now : now - 2000; // only a block that just arrived flashes, not the whole strip on first paint var fl = Math.max(0, 1 - (now - born[key]) / 1200); + if (fl > 0) animating = true; var px = right - age * pxs, py = rail + ((i % 3) - 1) * 22; if (fl > 0) { var g = x.createRadialGradient(px, py, 0, px, py, S * 2.2); g.addColorStop(0, 'rgba(255,179,92,' + (0.6 * fl) + ')'); g.addColorStop(1, 'rgba(255,179,92,0)'); x.fillStyle = g; x.beginPath(); x.arc(px, py, S * 2.2, 0, Math.PI * 2); x.fill(); } x.fillStyle = '#F2541B'; rrect(px - S / 2, py - S / 2, S, S, 5); x.fill(); if (fl > 0) { x.strokeStyle = 'rgba(255,179,92,' + fl + ')'; x.lineWidth = 2; rrect(px - S / 2 - 2, py - S / 2 - 2, S + 4, S + 4, 6); x.stroke(); } - }); + } + // forget flashes older than the strip, so the map does not grow for a day-long run + if (found.length && Object.keys(born).length > found.length + 50) { var keep = {}; for (var k = 0; k < found.length; k++) if (born[String(found[k])]) keep[String(found[k])] = born[String(found[k])]; born = keep; } if (!shown) { x.fillStyle = 'rgba(154,154,158,0.9)'; x.font = '500 12px IBM Plex Mono, monospace'; x.textAlign = 'center'; var msg = !state ? 'waiting for the engine' : state.mining.state === 'mining' ? 'mining: your blocks appear here as the node accepts them' : state.mining.state === 'paused' ? 'paused' : state.node.synced ? 'waiting for the worker' : 'waiting for the node to sync'; x.fillText(msg, W / 2, rail - 14); } + return animating && !reduce; } - var stateAt = performance.now(); - function frame(now) { if (phase === 'dashboard') draw(now); if (!reduce) requestAnimationFrame(frame); else setTimeout(function () { frame(performance.now()); }, 1000); } - requestAnimationFrame(frame); + var stateAt = performance.now(), drawTimer = null, drawRaf = 0; + function frame(now) { + drawRaf = 0; clearTimeout(drawTimer); drawTimer = null; + if (hiddenNow()) return; + var fast = false; + if (phase === 'dashboard') { var a = performance.now(); fast = draw(now); perf.draw += performance.now() - a; perf.frames++; } + if (fast) drawRaf = requestAnimationFrame(frame); + else drawTimer = setTimeout(function () { drawTimer = null; drawRaf = requestAnimationFrame(frame); }, reduce ? 1000 : 500); + } + function requestDraw() { if (hiddenNow() || drawRaf) return; clearTimeout(drawTimer); drawTimer = null; drawRaf = requestAnimationFrame(frame); } + document.addEventListener('visibilitychange', function () { + if (hiddenNow()) { clearTimeout(drawTimer); drawTimer = null; if (drawRaf) { cancelAnimationFrame(drawRaf); drawRaf = 0; } clearTimeout(logTimer); logTimer = null; } + else { poll(); requestDraw(); if (drawerOpen()) { pollLog(); scheduleLogPoll(); } } + }); + requestDraw(); // ---------- render ---------- // ---------- GPU rows: one per card, with a switch and an identities stepper ---------- @@ -313,7 +536,20 @@ var b = e.target.closest('[data-power-retry]'); if (!b) return; api('api/power/apply', {}).then(function () { toast('Administrator prompt: allow it to set the cap'); }); }); - var lastEventsKey = ''; + var lastEventsKey = '', lastFoot = ''; + function skewWord(sk) { sk = Math.round(Math.abs(sk)); return sk < 120 ? sk + ' s' : sk < 7200 ? Math.floor(sk / 60) + ' min ' + (sk % 60) + ' s' : Math.floor(sk / 3600) + ' h ' + Math.floor(sk % 3600 / 60) + ' min'; } + // sync ETA from the block rate over the last minute of readings: "about 2 h 10 min left" + var syncSamples = []; + function syncEta(n, now) { + if (n.state !== 'syncing' || !(n.headers > n.blocks)) { syncSamples = []; return ''; } + if (!syncSamples.length || now - syncSamples[syncSamples.length - 1].t >= 1) syncSamples.push({ t: now, b: n.blocks }); + while (syncSamples.length > 2 && now - syncSamples[0].t > 90) syncSamples.shift(); + var a = syncSamples[0], b = syncSamples[syncSamples.length - 1]; + if (b.t - a.t < 5 || b.b <= a.b) return syncSamples.length > 15 ? 'no progress in the last minute' : 'measuring the sync rate'; + var rate = (b.b - a.b) / (b.t - a.t), left = (n.headers - n.blocks) / rate; + var word = left < 60 ? 'under a minute' : left < 3600 ? 'about ' + Math.round(left / 60) + ' min' : left < 86400 ? 'about ' + Math.floor(left / 3600) + ' h ' + Math.round(left % 3600 / 60) + ' min' : 'over a day'; + return word + ' left at ' + Math.round(rate) + ' blocks/s'; + } function renderDashboard(s) { var m = s.mining, n = s.node, p = s.program, f = s.finality; $('d-hash').textContent = m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1); @@ -325,18 +561,22 @@ $('d-node').textContent = nodeWord; // the live numbers always show, so a stuck label is visible as stuck var live = 'height ' + withCommas(n.blocks) + (n.headers > n.blocks ? ' of ' + withCommas(n.headers) : '') + ' · daa ' + withCommas(n.daa) + ' · ' + n.peers + ' peer' + (n.peers === 1 ? '' : 's'); - $('d-node-sub').textContent = (s.clock && s.clock.severity === 'block') ? 'clock ' + Math.round(Math.abs(s.clock.skew_s)) + ' s ' + (s.clock.skew_s < 0 ? 'behind' : 'ahead') + ': fix it to mine' : n.state === 'restarting' ? ('restart in ' + n.restart_in_s + ' s · ' + live) : (n.state === 'syncing' && n.message ? n.message + ' · ' : '') + live; - $('d-node-sub').title = n.last_reading_age_s >= 0 ? 'last reading ' + Math.round(n.last_reading_age_s) + ' s ago' : ''; + var eta = syncEta(n, s.now); + var syncing = n.state === 'syncing' && n.headers > n.blocks ? (eta || n.message || 'downloading blocks') + ' · ' + withCommas(n.blocks) + ' of ' + withCommas(n.headers) + ' (' + Math.floor(n.blocks / n.headers * 100) + '%)' : ''; + $('d-node-sub').textContent = (s.clock && s.clock.severity === 'block') ? 'clock ' + skewWord(s.clock.skew_s) + ' ' + (s.clock.skew_s < 0 ? 'behind' : 'ahead') + ': sync it to mine' : n.state === 'restarting' ? ('restart in ' + n.restart_in_s + ' s · ' + live) : syncing || (n.state === 'syncing' && n.message ? n.message + ' · ' : '') + live; + $('d-node-sub').title = $('d-node-sub').textContent + (n.last_reading_age_s >= 0 ? ' · last reading ' + Math.round(n.last_reading_age_s) + ' s ago' : ''); + $('d-hash-sub').title = $('d-hash-sub').textContent; $('d-blocks-sub').title = $('d-blocks-sub').textContent; var cell = $('d-node-cell'); cell.classList.toggle('ok', n.state === 'synced'); cell.classList.toggle('bad', n.state === 'failed' || n.state === 'restarting' || n.state === 'stopped' || (s.clock && s.clock.severity === 'block')); if (n.daa > 0 && p.eta_s >= 0) { $('d-eta').textContent = hms(p.eta_s); $('d-eta-sub').textContent = p.message + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : ''); } - else { $('d-eta').textContent = '--:--'; $('d-eta-sub').textContent = 'waiting for the node'; } + else { $('d-eta').textContent = '--:--'; $('d-eta-sub').textContent = n.state === 'syncing' ? 'after the node is synced' : 'waiting for the node'; } + $('d-eta-sub').title = $('d-eta-sub').textContent; var nowU = s.now; $('d-found-10').textContent = m.found.filter(function (t) { return nowU - t <= 600; }).length; $('d-found-60').textContent = m.found.length; $('d-chain-blocks').textContent = compact(n.blocks); // per-card tiles (enabled cards), the rest in one muted line var on = m.cards.filter(function (c) { return c.enabled; }), off = m.cards.filter(function (c) { return !c.enabled; }); - $('d-cards-eyebrow').textContent = on.length + ' worker' + (on.length === 1 ? '' : 's') + (m.cards.length > on.length ? ' of ' + m.cards.length + ' GPUs' : ''); + $('d-cards-eyebrow').textContent = on.length + ' worker' + (on.length === 1 ? '' : 's') + (m.cards.length > on.length ? ' of ' + m.cards.length + ' GPU' + (m.cards.length === 1 ? '' : 's') : ''); var tb = $('d-cards'); if (!on.length) tb.innerHTML = '
' + (m.cards.length ? 'No card switched on. Open settings to pick one.' : 'No GPU worker. The node runs on its own.') + '
'; else tb.innerHTML = on.map(function (cd) { @@ -378,7 +618,12 @@ } // bottom $('btn-pause').textContent = m.paused ? 'Resume' : 'Pause'; - $('foot-status').textContent = (s.quitting ? 'stopping: miners first, then the node' : (s.display_name ? s.display_name + ' · ' : '') + (m.state === 'mining' ? 'mining' : m.state) + ' · node ' + nodeWord + ' · up ' + uptime(s.uptime_s) + (s.address.display ? ' · rewards to ' + s.address.display.slice(0, 8) + '…' + s.address.display.slice(-4) : '')); + // the bottom line: the machine name truncates first, the address is always short, the rest never wraps + var foot = s.quitting ? 'stopping: miners first, then the node' : + (s.display_name ? '' + esc(s.display_name) + '' : '') + + '' + esc(m.state === 'mining' ? 'mining' : m.state) + 'node ' + esc(nodeWord) + 'up ' + uptime(s.uptime_s) + '' + + (s.address.display ? 'rewards to ' + esc(s.address.display.slice(0, 6) + '…' + s.address.display.slice(-4)) + '' : ''); + if (foot !== lastFoot) { lastFoot = foot; $('foot-status').innerHTML = foot; } $('foot-version').textContent = 'v' + s.version; } function renderPill(s) { @@ -537,9 +782,12 @@ if (phase === 'dashboard') renderDashboard(s); if (s.quitting && !$('btn-quit').disabled) { $('btn-quit').disabled = true; } } - var failures = 0, first = true; + var failures = 0, first = true, pollTick = 0; function poll() { + // hidden windows poll every 5 s (the pill and the tray text still need a recent state), visible ones every second + if (hiddenNow() && (pollTick++ % 5)) return; api('api/state').then(function (s) { + var a = performance.now(); failures = 0; if (first) { first = false; @@ -550,11 +798,21 @@ if (start === 'cards') renderCards(s); } render(s); + perf.render += performance.now() - a; perf.polls++; + if (failures === 0 && $('pill').classList.contains('away')) { $('pill').classList.remove('away', 'warn'); } }).catch(function () { failures++; - if (failures >= 4) { $('pill').classList.add('warn'); $('pill-text').textContent = state && state.quitting ? 'stopped' : 'engine away'; } + if (failures >= 4) { $('pill').classList.add('warn', 'away'); $('pill-text').textContent = state && state.quitting ? 'stopped' : 'engine away'; if (phase === 'dashboard') { $('d-node').textContent = 'no answer'; $('d-node-sub').textContent = 'the engine is not answering; the window reconnects by itself'; $('d-node-cell').classList.add('bad'); } } }); } poll(); setInterval(poll, 1000); size(); + // debug: time N synchronous calls of each hot path (ms per call), independent of the host's frame rate + if (debug) window.__igneumBench = function (n) { + n = n || 100; var out = {}, a, i; + a = performance.now(); for (i = 0; i < n; i++) draw(performance.now()); out.drawMsPerFrame = +((performance.now() - a) / n).toFixed(3); + if (state) { a = performance.now(); for (i = 0; i < n; i++) render(state); out.renderMsPerPoll = +((performance.now() - a) / n).toFixed(3); } + if (drawerOpen()) { a = performance.now(); for (i = 0; i < n; i++) renderLog(true); out.logMsPerRender = +((performance.now() - a) / n).toFixed(3); } + return out; + }; })(); diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index f68a688cc..814cc454e 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -304,8 +304,10 @@ - -
+ +
+
@@ -314,17 +316,37 @@
- + +
+ + + +
+
+ + + + +
-

+  
+
+
+ +
+