Merge remote-tracking branch 'origin/gpu-logos-21' into release-0.3.21

This commit is contained in:
igneum-labs 2026-10-07 13:29:27 +00:00
commit 16a45243b4
21 changed files with 421 additions and 32 deletions

View file

@ -195,11 +195,6 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
/* GPU rows (first run) */
.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>.badge,.gpu-row.off>.switch,.gpu-row.off .info>:not(.driver){opacity:.72}
.badge{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;flex:0 0 44px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)}
.badge.apple{color:var(--bone)}
.badge.nvidia{color:var(--nvidia)}
.badge.amd{color:var(--ember)}
.badge.intel{color:var(--intel)}
.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}
@ -479,18 +474,21 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
.row{display:flex;gap:10px;align-items:center;min-width:0}
.row.wrap{flex-wrap:wrap}
.row .addr-input{margin-top:0;min-width:0}
.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}
/* the custom switch (Prove page fix, 7 October 2026): the label is the positioned ancestor, the native input is hidden
the accessible way (1 px, clipped, still focusable, the label association kept), the knob lives inside the track at
both states; the size class is the switch's own (big), never the DAG legend's swatch class */
.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;position:relative}
.switch+.switch{border-top:1px solid var(--line)}
.switch input{position:absolute;opacity:0;width:0;height:0}
.switch input{position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}
.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}
.switch.lg .track{width:52px;height:30px;flex-basis:52px}
.switch.lg .track::after{width:24px;height:24px}
.switch.lg input:checked+.track::after{transform:translateX(22px)}
.switch.big .track{width:52px;height:30px;flex-basis:52px}
.switch.big .track::after{width:24px;height:24px}
.switch.big input:checked+.track::after{transform:translateX(22px)}
.sw-text{min-width:0}
.sw-text b{font-weight:600}
.sw-text .dim{font-size:var(--t-base)}
@ -669,13 +667,14 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
.dag-chip{position:absolute;right:10px;top:10px;font-size:var(--t-xs);color:var(--ash);background:var(--graphite);border:1px solid var(--line-2);border-radius:999px;padding:3px 10px;pointer-events:none;z-index:2}
.legend{display:flex;flex-wrap:wrap;gap:8px 16px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ash);align-items:center}
.legend span{display:inline-flex;align-items:center;gap:7px}
.lg{width:10px;height:10px;border-radius:3px;display:inline-block;border:1.5px solid var(--ash);background:var(--row)}
.lg.you{border-color:var(--molten);box-shadow:0 0 0 2px var(--molten-40)}
.lg.pending{border-color:var(--ash)}
.lg.included{border-color:var(--ember)}
.lg.excluded{border-color:var(--ash);opacity:.45}
.lg.proven{background:var(--ember);border-color:var(--ember)}
.lg.locked{border-color:var(--ember);border-radius:50%;box-shadow:0 0 0 2px var(--ember-40)}
/* the DAG legend swatches, scoped to the legend (a bare .lg reached label.switch.lg until 7 October 2026) */
.legend .lg{width:10px;height:10px;border-radius:3px;display:inline-block;border:1.5px solid var(--ash);background:var(--row)}
.legend .lg.you{border-color:var(--molten);box-shadow:0 0 0 2px var(--molten-40)}
.legend .lg.pending{border-color:var(--ash)}
.legend .lg.included{border-color:var(--ember)}
.legend .lg.excluded{border-color:var(--ash);opacity:.45}
.legend .lg.proven{background:var(--ember);border-color:var(--ember)}
.legend .lg.locked{border-color:var(--ember);border-radius:50%;box-shadow:0 0 0 2px var(--ember-40)}
/* Cards: the Tuning strip above the list */
.tune-strip{padding:var(--s-4) var(--card-pad)}
.ts-row{display:flex;align-items:center;gap:var(--s-4);flex-wrap:wrap}
@ -804,9 +803,10 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
.proof-scene{height:180px;border:1px solid var(--line);border-radius:6px;background:var(--row);margin:10px 0;overflow:hidden}
.proof-scene canvas{display:block;width:100%;height:100%}
.proof-head{display:flex;justify-content:space-between;align-items:baseline;font-weight:500;color:var(--bone);margin-bottom:8px}
.track{display:flex;gap:3px;height:3px;margin-bottom:10px}
.track i{flex:1 1 0;background:var(--line);border-radius:2px}
.track i.proving{background:var(--ember)}.track i.verified{background:var(--bone)}.track i.paid{background:var(--molten)}
/* the inspector's shard track, scoped to its element (a bare .track reached every switch's track until 7 October 2026) */
#i-track{display:flex;gap:3px;height:3px;margin-bottom:10px}
#i-track i{flex:1 1 0;background:var(--line);border-radius:2px}
#i-track i.proving{background:var(--ember)}#i-track i.verified{background:var(--bone)}#i-track i.paid{background:var(--molten)}
.shards{list-style:none;margin:0 0 12px;padding:0;font-size:var(--t-xs);line-height:1.7;max-height:120px;overflow:auto}
.shards li{display:flex;justify-content:space-between;gap:8px;color:var(--ink-2)}
.shards li::before{content:"\25CF";font-size:7px;margin-right:6px;color:var(--line-2)}
@ -818,3 +818,34 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
.inspector .note{font-size:var(--t-sm);margin-top:12px;padding-top:10px;border-top:1px solid var(--line)}
.inspector .top-gap{margin-top:8px}
@media (max-width:900px){.cf-graph{grid-template-columns:1fr}.inspector{border-left:0;padding-left:0;border-top:1px solid var(--line);padding-top:var(--s-3)}}
/* ---------- vendor marks (gpu-logos, 7 October 2026): a self-contained block, the last thing in the file ----------
One simplified monochrome glyph per GPU vendor (View.vendorMark in app.js) in a soft rounded well: the vendor colour
at low alpha behind it, a hairline ring in the same colour, the glyph in the full colour. The tokens are the
module's own (--mark-*), one set per theme, so the light hex of every vendor reads at 3:1 or better on its well
(view.test.mjs computes the ratio and checks the hex here). The ember accent is for state and never tints a brand.
Nothing animates: hover and focus-within only deepen the ring. One mark per row, drawn by View.markHtml alone. */
:root{--mark-nvidia:#8BE37A;--mark-nvidia-well:rgba(139,227,122,.14);--mark-nvidia-ring:rgba(139,227,122,.45);--mark-amd:#FF5A5A;--mark-amd-well:rgba(255,90,90,.14);--mark-amd-ring:rgba(255,90,90,.45);--mark-intel:#7CC4FF;--mark-intel-well:rgba(124,196,255,.14);--mark-intel-ring:rgba(124,196,255,.45);--mark-apple:#E6E3DD;--mark-apple-well:rgba(230,227,221,.14);--mark-apple-ring:rgba(230,227,221,.45);--mark-gpu:#9A9A9E;--mark-gpu-well:rgba(154,154,158,.14);--mark-gpu-ring:rgba(154,154,158,.45)}
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){--mark-nvidia:#2F8A22;--mark-nvidia-well:rgba(47,138,34,.1);--mark-nvidia-ring:rgba(47,138,34,.45);--mark-amd:#C41E2A;--mark-amd-well:rgba(196,30,42,.1);--mark-amd-ring:rgba(196,30,42,.45);--mark-intel:#1C6FD6;--mark-intel-well:rgba(28,111,214,.1);--mark-intel-ring:rgba(28,111,214,.45);--mark-apple:#4A4A50;--mark-apple-well:rgba(74,74,80,.1);--mark-apple-ring:rgba(74,74,80,.45);--mark-gpu:#6B6B70;--mark-gpu-well:rgba(107,107,112,.1);--mark-gpu-ring:rgba(107,107,112,.45)}}
:root[data-theme="light"]{--mark-nvidia:#2F8A22;--mark-nvidia-well:rgba(47,138,34,.1);--mark-nvidia-ring:rgba(47,138,34,.45);--mark-amd:#C41E2A;--mark-amd-well:rgba(196,30,42,.1);--mark-amd-ring:rgba(196,30,42,.45);--mark-intel:#1C6FD6;--mark-intel-well:rgba(28,111,214,.1);--mark-intel-ring:rgba(28,111,214,.45);--mark-apple:#4A4A50;--mark-apple-well:rgba(74,74,80,.1);--mark-apple-ring:rgba(74,74,80,.45);--mark-gpu:#6B6B70;--mark-gpu-well:rgba(107,107,112,.1);--mark-gpu-ring:rgba(107,107,112,.45)}
.badge{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;flex:0 0 44px;border:1px solid var(--mark-gpu-ring);color:var(--mark-gpu);background:var(--mark-gpu-well);box-shadow:0 0 0 0 transparent;transition:box-shadow .15s ease,border-color .15s ease}
.badge svg{width:22px;height:22px;display:block}
.badge.nvidia{color:var(--mark-nvidia);background:var(--mark-nvidia-well);border-color:var(--mark-nvidia-ring)}
.badge.amd{color:var(--mark-amd);background:var(--mark-amd-well);border-color:var(--mark-amd-ring)}
.badge.intel{color:var(--mark-intel);background:var(--mark-intel-well);border-color:var(--mark-intel-ring)}
.badge.apple{color:var(--mark-apple);background:var(--mark-apple-well);border-color:var(--mark-apple-ring)}
.badge.gpu{color:var(--mark-gpu);background:var(--mark-gpu-well);border-color:var(--mark-gpu-ring)}
.gpu-row:hover .badge,.gpu-line:hover .badge,.gpu-row:focus-within .badge,.gpu-line:focus-within .badge{border-color:currentColor;box-shadow:0 0 0 3px var(--mark-gpu-well)}
.gpu-row:hover .badge.nvidia,.gpu-line:hover .badge.nvidia,.gpu-row:focus-within .badge.nvidia,.gpu-line:focus-within .badge.nvidia{box-shadow:0 0 0 3px var(--mark-nvidia-well)}
.gpu-row:hover .badge.amd,.gpu-line:hover .badge.amd,.gpu-row:focus-within .badge.amd,.gpu-line:focus-within .badge.amd{box-shadow:0 0 0 3px var(--mark-amd-well)}
.gpu-row:hover .badge.intel,.gpu-line:hover .badge.intel,.gpu-row:focus-within .badge.intel,.gpu-line:focus-within .badge.intel{box-shadow:0 0 0 3px var(--mark-intel-well)}
.gpu-row:hover .badge.apple,.gpu-line:hover .badge.apple,.gpu-row:focus-within .badge.apple,.gpu-line:focus-within .badge.apple{box-shadow:0 0 0 3px var(--mark-apple-well)}
.badge.mini{width:26px;height:26px;border-radius:8px;flex:0 0 26px;display:inline-flex;vertical-align:middle;margin-right:8px}
.badge.mini svg{width:15px;height:15px}
@media (prefers-reduced-motion:reduce){.badge{transition:none}}
/* the series line under the name: mono, small, quiet */
.gen{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.gpu-row .gen{margin-top:-2px}
.gpu-line .who .gen{margin:-1px 0 1px}
#bc-card{display:flex;align-items:center;min-width:0}
#bc-card>span{min-width:0;overflow:hidden;text-overflow:ellipsis}

View file

@ -305,6 +305,84 @@ var View = (function () {
// the tooltip on a card's name: what the tool calls it (gfx1201), its device index, the OpenCL platform, the PCI address
function cardTitle(cd) { var t = []; if (cd.code && cd.code !== cd.name) t.push(cd.code); if (cd.device !== undefined && cd.device !== '') t.push(cd.worker + ' device ' + cd.device); if (cd.platform) t.push(cd.platform); if (cd.bus) t.push('bus ' + cd.bus); return t.join(' · '); }
// ---------- vendor marks (gpu-logos, 7 October 2026) ----------
// One simplified monochrome glyph per GPU vendor, drawn by hand from the public mark's geometry (never a copied logo
// file, never a raster): a nominative use that names the hardware. Each glyph is under 2 KB and takes currentColor,
// so the per-vendor token tints it; the ember accent is reserved for state and never tints a brand. The light hex of
// every vendor reads at 3:1 or better on its well (the vendor colour at WELL_ALPHA_LIGHT over the graphite card);
// view.test.mjs computes the ratio and checks app.css carries the same hex values.
var WELL_ALPHA_LIGHT = 0.10, WELL_ALPHA_DARK = 0.14;
var VENDORS = {
nvidia: { label: 'NVIDIA', dark: '#8BE37A', light: '#2F8A22' },
amd: { label: 'AMD Radeon', dark: '#FF5A5A', light: '#C41E2A' },
intel: { label: 'Intel', dark: '#7CC4FF', light: '#1C6FD6' },
apple: { label: 'Apple', dark: '#E6E3DD', light: '#4A4A50' },
gpu: { label: 'GPU', dark: '#9A9A9E', light: '#6B6B70' }
};
var SVG_OPEN = '<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false"';
var MARKS = {
// the eye: a lens with a three-quarter swirl and its centre
nvidia: SVG_OPEN + ' fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2.6 12C5 7.9 8.3 5.8 12 5.8s7 2.1 9.4 6.2c-2.4 4.1-5.7 6.2-9.4 6.2S5 16.1 2.6 12z"/><path d="M15.6 12A3.6 3.6 0 1 0 12 15.6"/><circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none"/></svg>',
// the arrow: the corner bracket pointing up and right, the bracket below it
amd: SVG_OPEN + ' fill="currentColor"><path d="M8 3h13v13l-4-4V7h-5z"/><path d="M3 8l4 4v5h5l4 4H3z"/></svg>',
// the swoosh: an open tilted ring around the dot of the i
intel: SVG_OPEN + ' fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M7.4 7.6C4.9 8.8 3.4 10.5 3.4 12.3c0 3.4 4.3 5.9 9.8 5.9 4.5 0 7.6-1.6 7.6-3.9 0-1.4-1.3-2.6-3.5-3.3"/><path d="M11.2 9.6v6.2"/><circle cx="11.2" cy="6.6" r="1.1" fill="currentColor" stroke="none"/></svg>',
// the apple: the bitten fruit with its leaf
apple: SVG_OPEN + ' fill="currentColor"><path d="M16.4 12.6c0-2.5 2-3.6 2.1-3.7-1.2-1.7-3-1.9-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.8-1.7 0-3.2 1-4.1 2.5-1.8 3-.5 7.6 1.3 10.1.9 1.2 1.9 2.6 3.2 2.5 1.3 0 1.8-.8 3.3-.8 1.6 0 2 .8 3.3.8 1.4 0 2.3-1.2 3.1-2.5 1-1.4 1.4-2.8 1.4-2.9 0 0-2.7-1-2.9-4.1zM13.9 5.3c.7-.8 1.2-2 1-3.2-1 0-2.2.7-2.9 1.5-.6.7-1.2 1.9-1 3 1.1.1 2.2-.5 2.9-1.3z"/></svg>',
// the neutral fallback: a chip with its pins
gpu: SVG_OPEN + ' fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><rect x="5.5" y="5.5" width="13" height="13" rx="2.2"/><rect x="9.5" y="9.5" width="5" height="5" rx="1"/><path d="M9 2.5v3M12 2.5v3M15 2.5v3M9 18.5v3M12 18.5v3M15 18.5v3M2.5 9h3M2.5 12h3M2.5 15h3M18.5 9h3M18.5 12h3M18.5 15h3"/></svg>'
};
function escH(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
// the vendor a row is drawn as: the engine's word when it is one of the four, else the name decides (an OpenCL
// integrated row arrives as vendor "other" with "Intel UHD Graphics 770"), else the neutral chip
function vendorOf(cd) {
var v = (cd && cd.vendor || '').toLowerCase();
if (VENDORS[v] && v !== 'gpu') return v;
var n = (cd && cd.name || '') + ' ' + (cd && cd.code || '');
if (/nvidia|geforce|quadro|\bRTX\b|\bGTX\b/i.test(n)) return 'nvidia';
if (/\bamd\b|radeon|\bgfx\d/i.test(n)) return 'amd';
if (/intel|\barc\b|iris|\buhd\b/i.test(n)) return 'intel';
if (/apple|\bM\d (max|pro|ultra)\b/i.test(n)) return 'apple';
return 'gpu';
}
// the series line under the name, in mono: "RTX 50 series · Blackwell", "RX 9000 series · RDNA 4", "Arc B series ·
// Battlemage", "M5 series · integrated"; an integrated row always ends in "integrated" (the same glyph, never a second mark)
function seriesLine(cd) {
var v = vendorOf(cd), n = cd.name || '', code = cd.code || '', m, out = '';
if (v === 'nvidia') {
if ((m = /\b(RTX|GTX)\s*(\d)(\d)(\d{2})\b/i.exec(n))) { var tens = m[2] + m[3], fam = m[1].toUpperCase(), gen = { RTX50: 'Blackwell', RTX40: 'Ada Lovelace', RTX30: 'Ampere', RTX20: 'Turing', GTX16: 'Turing', GTX10: 'Pascal', GTX90: 'Maxwell' }[fam + tens]; out = fam + ' ' + tens + ' series' + (gen ? ' · ' + gen : ''); }
else if (/\b(H100|H200|A100|B200|B100|L40|L4|A30|A10)\b/i.test(n)) out = 'data centre';
else if (/\bRTX\b|quadro/i.test(n)) out = 'RTX workstation';
else out = 'GeForce';
} else if (v === 'amd') {
if ((m = /\bRX\s*(\d)(\d{3})\b/i.exec(n))) { var g = { 9: 'RDNA 4', 7: 'RDNA 3', 6: 'RDNA 2', 5: 'RDNA' }[m[1]]; out = 'RX ' + m[1] + '000 series' + (g ? ' · ' + g : ''); }
else if (/\bgfx12/i.test(code + n)) out = 'RDNA 4'; else if (/\bgfx11/i.test(code + n)) out = 'RDNA 3'; else if (/\bgfx103/i.test(code + n)) out = 'RDNA 2'; else if (/\bgfx10/i.test(code + n)) out = 'RDNA';
else if (/\bMI\d{2,3}/i.test(n)) out = 'Instinct · data centre';
else out = 'Radeon';
} else if (v === 'intel') {
if ((m = /\bArc\b.*?\b([ABC])(\d{3})\b/i.exec(n)) || (m = /\b([ABC])(\d{3})\b.*\bArc\b/i.exec(n))) { var L = m[1].toUpperCase(); out = 'Arc ' + L + ' series · ' + ({ A: 'Alchemist', B: 'Battlemage', C: 'Celestial' })[L]; }
else if (/iris/i.test(n)) out = 'Iris Xe'; else if (/\buhd\b/i.test(n)) out = 'UHD Graphics'; else out = 'Intel graphics';
} else if (v === 'apple') {
out = (m = /\bM(\d)\b/.exec(n)) ? 'M' + m[1] + ' series' : 'Apple silicon';
} else out = cd.worker ? String(cd.worker) : '';
if (cd.kind === 'integrated' || cd.kind === 'apple') out = (out ? out + ' · ' : '') + 'integrated';
return out;
}
// the mark for a row: the vendor word, the glyph, the label for the title, the series line
function vendorMark(cd) { var v = vendorOf(cd); return { vendor: v, svg: MARKS[v], label: VENDORS[v].label, series: seriesLine(cd) }; }
function markHtml(cd, size) { var mk = vendorMark(cd); return '<div class="badge ' + (size ? size + ' ' : '') + mk.vendor + '" data-mark="' + mk.vendor + '" title="' + escH(mk.label) + '">' + mk.svg + '</div>'; }
// the name block beside the mark: the name, the kind pill, the series line; shared by the first-run rows and the Cards page
function nameHtml(cd, extra) { var mk = vendorMark(cd); return '<div class="name" title="' + escH(cardTitle(cd)) + '">' + escH(cd.name) + (extra || '') + ' <span class="kind ' + escH(cd.kind) + '">' + escH(kindWord(cd.kind)) + '</span></div>' + (mk.series ? '<div class="gen">' + escH(mk.series) + '</div>' : ''); }
// the two together: one mark, one name block
function rowHead(cd, extra) { return markHtml(cd) + nameHtml(cd, extra); }
// sRGB relative luminance and the WCAG contrast ratio, for the test and for anyone picking a new vendor hex
function hexRgb(h) { h = h.replace('#', ''); return [0, 2, 4].map(function (i) { return parseInt(h.substr(i, 2), 16); }); }
function luminance(rgb) { var c = rgb.map(function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; }
function contrast(a, b) { var la = luminance(a), lb = luminance(b), hi = Math.max(la, lb), lo = Math.min(la, lb); return (hi + 0.05) / (lo + 0.05); }
// the well's colour: the vendor hex at alpha over the card ground (graphite)
function wellRgb(hex, alpha, ground) { var v = hexRgb(hex), g = hexRgb(ground); return v.map(function (c, i) { return Math.round(c * alpha + g[i] * (1 - alpha)); }); }
function vendorContrast(v, theme) { var hex = VENDORS[v][theme], ground = theme === 'light' ? '#FFFFFF' : '#16161A', alpha = theme === 'light' ? WELL_ALPHA_LIGHT : WELL_ALPHA_DARK; return contrast(hexRgb(hex), wellRgb(hex, alpha, ground)); }
// one GPU row on the Mine page: cd = state.mining.cards[i]
function cardRow(cd) {
var removed = cd.removed_at > 0, unusable = !!cd.problem;
@ -1000,7 +1078,7 @@ var View = (function () {
var addr = (s.address || {}).display || '';
var ignLine = c.minerIgn ? fmtIgn(c.minerIgn) + (addr ? ' to ' + shortHex(addr, 6, 4) : '') + ' (72 producer points, 8 for signing' + (c.ramp && c.ramp < 1 ? ', ramp ' + Math.round(c.ramp * 100) + '%' : '') + ')' : (addr ? 'Paid to ' + shortHex(addr, 6, 4) : '');
var d = daysIn(s.ladder, c.windowS, now), rankLine = (c.best ? 'rank ' + withCommas(c.best.rank) + ' of ' + withCommas(c.listed) + ' keys' : 'rank: not in the table yet') + ' · ' + (d.unit === 'days' ? 'day ' : 'hour ') + Math.max(1, d.have) + ' of ' + d.of;
return { kind: m.kind, count: m.count, eyebrow: eyebrow, title: title, when: m.at, cardLine: cardLine, ignLine: ignLine, rankLine: rankLine, hash: m.hash || '', hashShort: m.hash ? shortHex(m.hash, 8, 6) : 'hash: waiting for the node’s line', url: m.hash ? 'https://igneum.network/block/' + m.hash : '', key: 'card:' + m.count + ':' + Math.floor(m.at || 0), fields: { card: FIELDS.found, ign: FIELDS.reward, rank: FIELDS.rank + ' · ' + FIELDS.days } };
return { kind: m.kind, count: m.count, eyebrow: eyebrow, title: title, when: m.at, cardLine: cardLine, card: { name: name, vendor: cd.vendor, kind: cd.kind, code: cd.code }, ignLine: ignLine, rankLine: rankLine, hash: m.hash || '', hashShort: m.hash ? shortHex(m.hash, 8, 6) : 'hash: waiting for the node’s line', url: m.hash ? 'https://igneum.network/block/' + m.hash : '', key: 'card:' + m.count + ':' + Math.floor(m.at || 0), fields: { card: FIELDS.found, ign: FIELDS.reward, rank: FIELDS.rank + ' · ' + FIELDS.days } };
}
// Earnings (reinvent 3.4): IGN first, the window, a typed price, the share and rank, IGN per kWh. price = £ per IGN
// typed by the user (null = none), pence = electricity, now = unix s
@ -1079,7 +1157,7 @@ var View = (function () {
}
function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; }
function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; }
return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, driverWords: driverWords, vendorWord: vendorWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords };
return { VENDORS: VENDORS, MARKS: MARKS, WELL_ALPHA_LIGHT: WELL_ALPHA_LIGHT, WELL_ALPHA_DARK: WELL_ALPHA_DARK, vendorOf: vendorOf, seriesLine: seriesLine, vendorMark: vendorMark, markHtml: markHtml, nameHtml: nameHtml, rowHead: rowHead, vendorContrast: vendorContrast, contrast: contrast, hexRgb: hexRgb, PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, driverWords: driverWords, vendorWord: vendorWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords };
})();
/* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ----------
One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned
@ -1920,7 +1998,7 @@ if (typeof document !== 'undefined') (function () {
if (show) {
card.dataset.key = bc.key; card.dataset.url = bc.url;
setText('bc-eyebrow', bc.eyebrow); setText('bc-when', bc.when > 0 ? clock(bc.when) : ''); setText('bc-title', bc.title);
setText('bc-card', bc.cardLine); $('bc-card').title = bc.fields.card;
$('bc-card').innerHTML = View.markHtml(bc.card, 'mini') + '<span>' + esc(bc.cardLine) + '</span>'; $('bc-card').title = bc.fields.card;
setText('bc-ign', bc.ignLine); $('bc-ign').title = bc.fields.ign;
setText('bc-rank', bc.rankLine); $('bc-rank').title = bc.fields.rank;
setText('bc-hash', 'hash ' + bc.hashShort); $('bc-hash').title = bc.hash;
@ -1948,10 +2026,7 @@ if (typeof document !== 'undefined') (function () {
setText('tl-note', 'Times from install (' + (tl.base > 0 ? whenWords(tl.base) : 'not recorded') + '). The site’s /miner page shows the same steps measured on fresh installs.');
}
// ---------- render ----------
function badgeHtml(cd) {
if (cd.vendor === 'apple') return '<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M16.4 12.6c0-2.5 2-3.6 2.1-3.7-1.2-1.7-3-1.9-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.8-1.7 0-3.2 1-4.1 2.5-1.8 3-.5 7.6 1.3 10.1.9 1.2 1.9 2.6 3.2 2.5 1.3 0 1.8-.8 3.3-.8 1.6 0 2 .8 3.3.8 1.4 0 2.3-1.2 3.1-2.5 1-1.4 1.4-2.8 1.4-2.9 0 0-2.7-1-2.9-4.1zM13.9 5.3c.7-.8 1.2-2 1-3.2-1 0-2.2.7-2.9 1.5-.6.7-1.2 1.9-1 3 1.1.1 2.2-.5 2.9-1.3z"/></svg>';
return cd.vendor === 'nvidia' ? 'NV' : cd.vendor === 'amd' ? 'AMD' : cd.vendor === 'intel' ? 'INTEL' : 'GPU';
}
// the vendor mark and the name block come from View.rowHead (one mark per row, never two)
function vramText(cd) { if (!cd.vram_mb) return ''; var gb = cd.vram_mb / 1024; return (gb >= 10 ? Math.round(gb) : gb.toFixed(gb < 4 ? 1 : 0)) + ' GB' + (cd.kind === 'apple' ? ' unified' : ''); }
// driver-check: the first-run row's driver line (the same words as the dashboard strip, the same buttons)
function setupDriverHtml(cd) {
@ -1970,8 +2045,8 @@ if (typeof document !== 'undefined') (function () {
d.className = 'gpu-row' + (cd.enabled && !removed && !unusable ? '' : ' off') + (removed ? ' removed' : '') + (unusable ? ' unusable' : '');
d.dataset.key = cd.key;
var meta = (vramText(cd) ? '<span>' + esc(vramText(cd)) + '</span>' : '') + (cd.detail && cd.kind !== 'apple' ? '<span>' + esc(cd.detail) + '</span>' : '') + (cd.detail && cd.kind === 'apple' ? '<span>' + esc(cd.detail.split(',')[0]) + '</span>' : '');
d.innerHTML = '<div class="badge ' + esc(cd.vendor) + '">' + badgeHtml(cd) + '</div>' +
'<div class="info"><div class="name" title="' + esc(cardTitle(cd)) + '">' + esc(cd.name) + (unusable ? ': ' + esc(cd.message || 'not usable (' + cd.problem + ')') : removed ? ': removed' : '') + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div><div class="meta">' + meta + '</div>' +
d.innerHTML = View.markHtml(cd) +
'<div class="info">' + View.nameHtml(cd, unusable ? ': ' + esc(cd.message || 'not usable (' + cd.problem + ')') : removed ? ': removed' : '') + '<div class="meta">' + meta + '</div>' +
(cd.reason ? '<div class="reason">' + esc(cd.reason) + '</div>' : '') + (cd.message && !unusable && !removed ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + setupDriverHtml(cd) + '</div>' +
'<label class="switch" title="' + (unusable ? 'no worker can drive this card' : removed ? 'the card was removed' : cd.enabled ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox" aria-label="Mine on ' + esc(cd.name) + '"' + (cd.enabled && !removed && !unusable ? ' checked' : '') + (cd.kind === 'unknown' || unusable || removed ? ' disabled' : '') + '><span class="track"></span></label>';
container.appendChild(d);
@ -2058,8 +2133,8 @@ if (typeof document !== 'undefined') (function () {
(dw.sub ? '<span class="n dim">' + esc(dw.sub) + '</span>' : '') + (dw.dry ? '<span class="n dim">dry run: the installer is not run</span>' : '') + '</div>';
var tune = em.kind === 'none' ? '' : '<div class="gl-tune ' + em.kind + '">' + flameHtml(em.fill, em.kind) + '<span class="t">' + esc(em.label) + '</span>' + (em.sub ? '<span class="n dim">' + esc(em.sub) + '</span>' : '') + (em.savingText ? '<span class="n save">' + esc(em.savingText) + '</span>' : '') + (em.heat ? '<span class="n heat">' + esc(em.heat) + '</span>' : '') + (em.kind === 'running' ? '<i class="bar"><b style="width:' + Math.round(em.fill * 100) + '%"></b></i>' : '') + '</div>';
return '<div class="gpu-line' + (r.on ? ' on' : ' off') + (r.removed ? ' removed' : '') + (r.unusable ? ' unusable' : '') + '" data-key="' + esc(r.key) + '">' +
'<div class="gl-main"><div class="badge ' + esc(r.vendor) + '">' + badgeHtml({ vendor: r.vendor }) + '</div>' +
'<div class="who"><div class="name" title="' + esc(r.title) + '">' + esc(r.name) + ' <span class="kind ' + esc(r.kind) + '">' + esc(r.kindWord) + '</span></div>' +
'<div class="gl-main">' + View.markHtml(cd) +
'<div class="who">' + View.nameHtml(cd) +
'<span class="st ' + r.tone + '"><i></i>' + esc(word) + (r.meta ? ' · ' + esc(r.meta) : '') + '</span>' + subs.join('') + '</div>' +
'<div class="nums">' + nums + '</div>' +
'<div class="acts">' + tuneBtn + sw + chev + '</div></div>' + drv + tune +

View file

@ -429,7 +429,7 @@
<h3>Prove on this machine</h3>
<p class="help">Every block is turned into a short proof, in pieces called shards. Your cards prove shards and earn IGN for each one.</p>
</div>
<label class="switch lg" title="Prove on this machine"><input type="checkbox" id="s-prove" aria-label="Prove on this machine"><span class="track"></span></label>
<label class="switch big" title="Prove on this machine"><input type="checkbox" id="s-prove" aria-label="Prove on this machine"><span class="track"></span></label>
</div>
<ul class="tier-list" id="pv-tiers"></ul>
<p class="line-text" id="pv-line"></p>

View file

@ -725,3 +725,150 @@ test('the block card on the four thresholds and on a new card; the first-hour ti
assert.equal(V.plainEvent('block 1,000 of this machine: found by RTX 4070 (block card)').text, 'Block 1,000 of this machine, found by RTX 4070');
assert.equal(V.plainEvent('first block on Apple M5 Max (block card)').text, 'First block on Apple M5 Max');
});
// ---------- vendor marks (gpu-logos, 7 October 2026) ----------
const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.css'), 'utf8');
const marks = (html) => (html.match(/data-mark="/g) || []).length;
test('every vendor renders its own mark: one inline SVG glyph per row, tinted by its vendor class, under 2 KB each', () => {
const rows = {
nvidia: card(),
amd: card({ key: 'amd:gfx1201', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd', vram_mb: 16384 }),
intel: card({ key: 'intel:Intel(R) Arc(TM) B580 Graphics', name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel', worker: 'OpenCL', vram_mb: 12208 }),
apple: card({ key: 'apple::Apple M5 Max', name: 'Apple M5 Max', vendor: 'apple', kind: 'apple', worker: 'Metal', vram_mb: 131072 }),
};
for (const [v, cd] of Object.entries(rows)) {
const mk = V.vendorMark(cd);
assert.equal(mk.vendor, v);
assert.match(mk.svg, /^<svg viewBox="0 0 24 24"/);
assert.ok(Buffer.byteLength(mk.svg) < 2048, v + ' glyph is ' + Buffer.byteLength(mk.svg) + ' bytes');
assert.ok(!/<image|href=|data:/.test(mk.svg), v + ' glyph is drawn, never a raster or a file');
assert.ok(/currentColor/.test(mk.svg), v + ' glyph takes the vendor token through currentColor');
const html = V.markHtml(cd);
assert.ok(html.startsWith('<div class="badge ' + v + '" data-mark="' + v + '"'), html.slice(0, 60));
assert.equal((html.match(/<svg/g) || []).length, 1);
}
assert.notEqual(V.MARKS.nvidia, V.MARKS.amd); assert.notEqual(V.MARKS.intel, V.MARKS.apple);
});
test('an unknown vendor renders the neutral fallback glyph; a vendor "other" row whose name says Intel keeps the Intel glyph', () => {
const mali = card({ key: 'other:0:Mali', name: 'Mali-G78 MP20', vendor: 'other', kind: 'integrated', worker: 'OpenCL', vram_mb: 0 });
const mk = V.vendorMark(mali);
assert.equal(mk.vendor, 'gpu'); assert.equal(mk.label, 'GPU'); assert.equal(mk.svg, V.MARKS.gpu);
assert.ok(V.markHtml(mali).startsWith('<div class="badge gpu" data-mark="gpu"'));
assert.equal(V.vendorOf(card({ name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated' })), 'intel');
assert.equal(V.vendorOf(card({ name: 'AMD Radeon(TM) Graphics', vendor: 'other', kind: 'integrated' })), 'amd');
assert.equal(V.vendorOf({}), 'gpu');
});
test('the mark never appears twice on one row: the head of a first-run row and of a Cards row carries one data-mark, and only View draws a badge', () => {
const amd = card({ key: 'amd:gfx1201', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd' });
assert.equal(marks(V.rowHead(amd)), 1);
assert.equal(marks(V.rowHead(amd, ': removed')), 1);
assert.equal(marks(V.markHtml(amd) + V.nameHtml(amd)), 1);
assert.equal(marks(V.nameHtml(amd)), 0, 'the name block carries no mark of its own');
// the renderers: the first-run rows, the Cards rows and the block card all go through View.markHtml; no other
// string in app.js opens a badge
assert.equal((src.match(/class="badge /g) || []).length, 1, 'one place in app.js opens a badge');
assert.equal((src.match(/View\.markHtml\(/g) || []).length, 3, 'first-run rows, Cards rows, the block card');
assert.equal(src.includes('badgeHtml('), false);
});
test('the series line under the name is mono text per generation; an integrated row (Intel iGPU, Apple) ends in "integrated" and keeps one mark', () => {
assert.equal(V.seriesLine(card()), 'RTX 50 series · Blackwell');
assert.equal(V.seriesLine(card({ name: 'NVIDIA GeForce RTX 4070' })), 'RTX 40 series · Ada Lovelace');
assert.equal(V.seriesLine(card({ name: 'NVIDIA GeForce RTX 3080' })), 'RTX 30 series · Ampere');
assert.equal(V.seriesLine(card({ name: 'NVIDIA GeForce GTX 1660 Super' })), 'GTX 16 series · Turing');
assert.equal(V.seriesLine(card({ name: 'AMD Radeon RX 9070 XT', vendor: 'amd' })), 'RX 9000 series · RDNA 4');
assert.equal(V.seriesLine(card({ name: 'AMD Radeon RX 7800 XT', vendor: 'amd' })), 'RX 7000 series · RDNA 3');
assert.equal(V.seriesLine(card({ name: 'gfx1201', code: 'gfx1201', vendor: 'amd' })), 'RDNA 4');
assert.equal(V.seriesLine(card({ name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel' })), 'Arc B series · Battlemage');
assert.equal(V.seriesLine(card({ name: 'Intel Arc A770', vendor: 'intel' })), 'Arc A series · Alchemist');
assert.equal(V.seriesLine(card({ name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated' })), 'UHD Graphics · integrated');
assert.equal(V.seriesLine(card({ name: 'Intel Iris Xe Graphics', vendor: 'other', kind: 'integrated' })), 'Iris Xe · integrated');
assert.equal(V.seriesLine(card({ name: 'AMD Radeon(TM) Graphics', vendor: 'amd', kind: 'integrated' })), 'Radeon · integrated');
assert.equal(V.seriesLine(card({ name: 'Apple M5 Max', vendor: 'apple', kind: 'apple' })), 'M5 series · integrated');
const igpu = card({ name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated' });
const head = V.rowHead(igpu);
assert.equal(marks(head), 1); assert.match(head, /data-mark="intel"/); assert.match(head, /<div class="gen">UHD Graphics · integrated<\/div>/);
assert.equal(V.vendorMark(igpu).svg, V.MARKS.intel, 'an integrated Intel row keeps the Intel glyph, never a second mark');
assert.equal(V.nameHtml(card({ name: '<b>x</b>', vendor: 'zzz', kind: 'unknown' })).includes('<b>'), false, 'the name is escaped');
});
test('the light theme contrast ratio of every vendor colour on its well is at least 3:1, and app.css carries the same hex values and wells', () => {
for (const v of Object.keys(V.VENDORS)) {
const light = V.vendorContrast(v, 'light'), dark = V.vendorContrast(v, 'dark');
assert.ok(light >= 3, v + ' light: ' + light.toFixed(2) + ':1 on its well');
assert.ok(dark >= 3, v + ' dark: ' + dark.toFixed(2) + ':1 on its well');
const [r, g, b] = V.hexRgb(V.VENDORS[v].light), [dr, dg, db] = V.hexRgb(V.VENDORS[v].dark);
assert.ok(css.includes('--mark-' + v + ':' + V.VENDORS[v].dark + ';'), v + ' dark hex in app.css');
assert.equal((css.match(new RegExp('--mark-' + v + ':' + V.VENDORS[v].light + ';', 'g')) || []).length, 2, v + ' light hex in both light blocks');
assert.ok(css.includes('--mark-' + v + '-well:rgba(' + dr + ',' + dg + ',' + db + ',' + String(V.WELL_ALPHA_DARK).replace(/^0/, '') + ')'), v + ' dark well');
assert.equal((css.match(new RegExp('--mark-' + v + '-well:rgba\\(' + r + ',' + g + ',' + b + ',' + String(V.WELL_ALPHA_LIGHT).replace(/^0/, '') + '\\)', 'g')) || []).length, 2, v + ' light well in both light blocks');
}
// the ember accent is for state, never a brand
for (const v of Object.keys(V.VENDORS)) { assert.notEqual(V.VENDORS[v].dark, '#F2541B'); assert.notEqual(V.VENDORS[v].light, '#E04A14'); }
assert.equal(/\.badge\.(nvidia|amd|intel|apple|gpu)\{[^}]*var\(--ember/.test(css), false, 'no badge rule tints with ember');
assert.equal(/\.badge[^{]*\{[^}]*animation/.test(css), false, 'nothing on the mark animates');
});
// ---------- the custom switch (the Prove page fix, 7 October 2026) ----------
// the project lead's screenshot from the live 0.3.19 Mac app: a thin white rectangle above-left of the "Prove on this machine"
// switch and the knob drawn outside the track's left edge at off. Two generic classes reached the switch (the DAG
// legend swatch .lg hit label.switch.lg, the shard track .track hit .switch .track) and the native input was an
// unpositioned absolute. This test reads app.css: no bare .lg or .track rule may exist (every use is scoped), the
// native input is hidden the accessible way (1 px clip, still focusable), and the knob's box sits inside the track's
// box at off and at on for both sizes, in the base rules and untouched by both theme blocks.
const cssRules = (text) => { const out = []; const re = /([^{}]+)\{([^{}]*)\}/g; let m; text = text.replace(/\/\*[\s\S]*?\*\//g, ''); while ((m = re.exec(text))) out.push({ sel: m[1].trim(), body: m[2] }); return out; };
const ruleOf = (sel) => cssRules(css).filter((r) => r.sel.split(',').map((x) => x.trim()).includes(sel)).map((r) => r.body).join(';');
const px = (body, prop) => { const m = new RegExp('(?:^|;)\\s*' + prop + ':\\s*(-?[\\d.]+)px').exec(body); return m ? parseFloat(m[1]) : null; };
const translate = (body) => { const m = /transform:\s*translateX\((-?[\d.]+)px\)/.exec(body); return m ? parseFloat(m[1]) : 0; };
test('no generic .lg or .track rule reaches a custom switch: every use of those class names is scoped (known-failed on the 0.3.19 CSS)', () => {
const bare = cssRules(css).flatMap((r) => r.sel.split(',').map((x) => x.trim())).filter((sel) => /^\.(lg|track)(\.[\w-]+)*(\s+[\w.:-]+)*$/.test(sel) && !/^\.switch\b/.test(sel));
assert.deepEqual(bare, [], 'selectors that start with a bare .lg or .track: ' + bare.join(' | '));
assert.equal(/<label class="switch lg"/.test(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'index.html'), 'utf8')), false, 'the Prove switch no longer shares the legend swatch class');
});
test('the native input of every custom switch is hidden the accessible way: 1 px clipped, still in the tree and focusable, the label kept', () => {
const sw = ruleOf('.switch'), input = ruleOf('.switch input');
assert.match(sw, /position:\s*relative/, '.switch is the positioned ancestor of its input');
assert.match(input, /position:\s*absolute/);
assert.equal(px(input, 'width'), 1); assert.equal(px(input, 'height'), 1);
assert.match(input, /clip:\s*rect\(0 0 0 0\)|clip-path:\s*inset\(50%\)/, 'the clip pattern');
assert.match(input, /overflow:\s*hidden/);
assert.equal(/display:\s*none|visibility:\s*hidden/.test(input), false, 'display none would take the input out of the tab order');
assert.match(ruleOf('.switch input:focus-visible+.track'), /outline/, 'focus stays visible on the track');
});
test('the knob sits inside the track at off and at on, both sizes, and neither theme block touches the switch geometry', () => {
const track = ruleOf('.switch .track'), knob = ruleOf('.switch .track::after'), on = ruleOf('.switch input:checked+.track::after');
const big = ruleOf('.switch.big .track'), bigKnob = ruleOf('.switch.big .track::after'), bigOn = ruleOf('.switch.big input:checked+.track::after');
const inside = (tw, th, kw, kh, left, top, dx, label) => {
assert.ok(left >= 0 && top >= 0, label + ': the knob starts inside (left ' + left + ', top ' + top + ')');
assert.ok(left + kw <= tw && top + kh <= th, label + ' off: knob ' + kw + 'x' + kh + ' at ' + left + ',' + top + ' in a ' + tw + 'x' + th + ' track');
assert.ok(left + dx + kw <= tw, label + ' on: knob right edge ' + (left + dx + kw) + ' in a ' + tw + ' track');
assert.ok(left + dx >= 0, label + ' on: knob left edge ' + (left + dx));
};
assert.match(track, /position:\s*relative/, 'the track positions its knob');
inside(px(track, 'width'), px(track, 'height'), px(knob, 'width'), px(knob, 'height'), px(knob, 'left'), px(knob, 'top'), translate(on), 'the 40 px switch');
inside(px(big, 'width'), px(big, 'height'), px(bigKnob, 'width'), px(bigKnob, 'height'), px(knob, 'left'), px(knob, 'top'), translate(bigOn), 'the big switch');
// the two theme blocks (prefers-color-scheme light, data-theme light) only set tokens: no .switch or .track rule inside them
for (const m of css.matchAll(/@media \(prefers-color-scheme:light\)\{:root:not\(\[data-theme="dark"\]\)\{[^}]*\}\}|:root\[data-theme="light"\]\{[^}]*\}/g)) assert.equal(/\.switch|\.track/.test(m[0]), false, 'a theme block touches the switch');
assert.equal(cssRules(css).some((r) => /\.switch|\.track/.test(r.sel) && /@media \(prefers-color-scheme/.test(r.sel)), false);
});
// ---------- the shared marks module (brand/marks/vendor-marks.mjs, for the site) ----------
test('brand/marks/vendor-marks.mjs carries the app\'s marks and tokens verbatim, plus the Windows mark in the same treatment', async () => {
const mod = await import(join(dirname(fileURLToPath(import.meta.url)), '../../../brand/marks/vendor-marks.mjs'));
assert.deepEqual(mod.MARKS, V.MARKS, 'the glyph strings (regenerate with node brand/marks/regen.mjs)');
assert.deepEqual(mod.VENDORS, V.VENDORS);
assert.deepEqual(mod.WELL_ALPHA, { dark: V.WELL_ALPHA_DARK, light: V.WELL_ALPHA_LIGHT });
assert.equal(mod.OS_MARKS.macos, V.MARKS.apple);
assert.match(mod.OS_MARKS.windows, /^<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false" fill="currentColor"><path /);
assert.ok(Buffer.byteLength(mod.OS_MARKS.windows) < 2048);
assert.equal(mod.markHtml('amd'), V.markHtml({ vendor: 'amd' }));
assert.equal(mod.markHtml('zzz'), V.markHtml({ vendor: 'zzz' }));
// the tokens the module prints are the ones app.css carries
for (const line of mod.tokensCss().split('\n')) { const inner = /\{([^{}]*)\}\}?$/.exec(line)[1]; assert.ok(css.includes(inner), 'app.css carries: ' + inner.slice(0, 60)); }
});

14
brand/marks/regen.mjs Normal file
View file

@ -0,0 +1,14 @@
#!/usr/bin/env node
// Regenerates brand/marks/vendor-marks.mjs from app/igneum-app/ui/app.js (View.MARKS, View.VENDORS): run after any
// change to the marks in the app, then commit both. Keeps the header and the OS marks; only the data blocks move.
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const m = { exports: {} }; new Function('module', readFileSync(join(here, '../../app/igneum-app/ui/app.js'), 'utf8'))(m);
const V = m.exports.View, p = join(here, 'vendor-marks.mjs');
let s = readFileSync(p, 'utf8');
s = s.replace(/export const VENDORS = [\s\S]*?;\n/, 'export const VENDORS = ' + JSON.stringify(V.VENDORS, null, 2) + ';\n');
s = s.replace(/export const WELL_ALPHA = [^\n]*\n/, 'export const WELL_ALPHA = { dark: ' + V.WELL_ALPHA_DARK + ', light: ' + V.WELL_ALPHA_LIGHT + ' };\n');
s = s.replace(/export const MARKS = [\s\S]*?\n\};\n/, 'export const MARKS = ' + JSON.stringify(V.MARKS, null, 2) + ';\n');
writeFileSync(p, s); console.log('brand/marks/vendor-marks.mjs regenerated');

View file

@ -0,0 +1,65 @@
// Igneum vendor and OS marks (gpu-logos, 7 October 2026): the strings the miner app ships in app/igneum-app/ui/app.js
// (View.MARKS and View.VENDORS), exported verbatim for the site and anything else that names the hardware.
// view.test.mjs fails when this file and app.js drift apart, so edit app.js first and regenerate this file with
// `node brand/marks/regen.mjs` (or copy the strings by hand; the test says which one moved).
//
// Each glyph: a hand-drawn simplified monochrome mark of the vendor's public geometry (never a copied logo file,
// never a raster), 24 x 24 viewBox at 22 px, under 460 bytes, fill or stroke through currentColor so the element's
// colour tints it. Nominative use that names the hardware; the ember accent is for state and never tints a brand.
//
// The treatment in the app (app.css, the block at the end): a 44 x 44 well, radius 12, background the vendor colour
// at .14 alpha (dark) or .10 (light), a 1 px ring in the vendor colour at .45 alpha, the glyph in the full colour;
// hover and focus-within add a 3 px halo of the well colour; nothing animates. The light hex of every vendor reads at
// 3:1 or better on its well over white (nvidia 3.87, amd 5.01, intel 4.29, apple 7.52, gpu 4.65).
//
// Class names in the app: .badge.<vendor> (nvidia | amd | intel | apple | gpu), .badge.mini for a 26 px inline mark,
// .gen for the series line under the name. Tokens: --mark-<vendor>, --mark-<vendor>-well, --mark-<vendor>-ring.
export const VENDORS = {
"nvidia": {
"label": "NVIDIA",
"dark": "#8BE37A",
"light": "#2F8A22"
},
"amd": {
"label": "AMD Radeon",
"dark": "#FF5A5A",
"light": "#C41E2A"
},
"intel": {
"label": "Intel",
"dark": "#7CC4FF",
"light": "#1C6FD6"
},
"apple": {
"label": "Apple",
"dark": "#E6E3DD",
"light": "#4A4A50"
},
"gpu": {
"label": "GPU",
"dark": "#9A9A9E",
"light": "#6B6B70"
}
};
export const WELL_ALPHA = { dark: 0.14, light: 0.1 };
export const MARKS = {
"nvidia": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M2.6 12C5 7.9 8.3 5.8 12 5.8s7 2.1 9.4 6.2c-2.4 4.1-5.7 6.2-9.4 6.2S5 16.1 2.6 12z\"/><path d=\"M15.6 12A3.6 3.6 0 1 0 12 15.6\"/><circle cx=\"12\" cy=\"12\" r=\"1.2\" fill=\"currentColor\" stroke=\"none\"/></svg>",
"amd": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\"><path d=\"M8 3h13v13l-4-4V7h-5z\"/><path d=\"M3 8l4 4v5h5l4 4H3z\"/></svg>",
"intel": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\"><path d=\"M7.4 7.6C4.9 8.8 3.4 10.5 3.4 12.3c0 3.4 4.3 5.9 9.8 5.9 4.5 0 7.6-1.6 7.6-3.9 0-1.4-1.3-2.6-3.5-3.3\"/><path d=\"M11.2 9.6v6.2\"/><circle cx=\"11.2\" cy=\"6.6\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\"/></svg>",
"apple": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\"><path d=\"M16.4 12.6c0-2.5 2-3.6 2.1-3.7-1.2-1.7-3-1.9-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.8-1.7 0-3.2 1-4.1 2.5-1.8 3-.5 7.6 1.3 10.1.9 1.2 1.9 2.6 3.2 2.5 1.3 0 1.8-.8 3.3-.8 1.6 0 2 .8 3.3.8 1.4 0 2.3-1.2 3.1-2.5 1-1.4 1.4-2.8 1.4-2.9 0 0-2.7-1-2.9-4.1zM13.9 5.3c.7-.8 1.2-2 1-3.2-1 0-2.2.7-2.9 1.5-.6.7-1.2 1.9-1 3 1.1.1 2.2-.5 2.9-1.3z\"/></svg>",
"gpu": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\"><rect x=\"5.5\" y=\"5.5\" width=\"13\" height=\"13\" rx=\"2.2\"/><rect x=\"9.5\" y=\"9.5\" width=\"5\" height=\"5\" rx=\"1\"/><path d=\"M9 2.5v3M12 2.5v3M15 2.5v3M9 18.5v3M12 18.5v3M15 18.5v3M2.5 9h3M2.5 12h3M2.5 15h3M18.5 9h3M18.5 12h3M18.5 15h3\"/></svg>"
};
// OS marks in the same treatment: Apple is the vendor glyph; Windows is the four slanted panes
export const OS_MARKS = {
macos: MARKS.apple,
windows: "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\"><path d=\"M3 5.6l7.3-1v7.1H3zM11.4 4.4L21 3v8.7h-9.6zM3 12.3h7.3v7.1L3 18.4zM11.4 12.3H21V21l-9.6-1.4z\"/></svg>"
};
export const OS_COLOURS = { macos: VENDORS.apple, windows: { label: 'Windows', dark: '#7CC4FF', light: '#1C6FD6' } };
// the CSS tokens for both themes, as the app declares them
export function tokensCss() {
const line = (theme) => Object.entries(VENDORS).map(([v, c]) => { const h = c[theme], r = parseInt(h.slice(1, 3), 16), g = parseInt(h.slice(3, 5), 16), b = parseInt(h.slice(5, 7), 16), a = String(WELL_ALPHA[theme]).replace(/^0/, ''); return `--mark-${v}:${h};--mark-${v}-well:rgba(${r},${g},${b},${a});--mark-${v}-ring:rgba(${r},${g},${b},.45)`; }).join(';');
return `:root{${line('dark')}}\n@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){${line('light')}}}\n:root[data-theme="light"]{${line('light')}}`;
}
// the well: <div class="badge nvidia"><svg…></svg></div>
export function markHtml(vendor, size) { const v = MARKS[vendor] ? vendor : 'gpu'; return '<div class="badge ' + (size ? size + ' ' : '') + v + '" data-mark="' + v + '" title="' + VENDORS[v].label + '">' + MARKS[v] + '</div>'; }

View file

@ -0,0 +1,13 @@
# the gpu-logos review shots: file, page query, height (1280 wide, 2x)
01-overview-live-dark.png scenario=live&page=overview&theme=dark 900
02-overview-live-light.png scenario=live&page=overview&theme=light 900
03-overview-firstblock-dark.png scenario=firstblock&page=overview&theme=dark 900
04-cards-mac-dark.png scenario=live&page=cards&theme=dark 900
05-cards-mac-light.png scenario=live&page=cards&theme=light 900
06-cards-rig-dark.png scenario=rig&page=cards&theme=dark 1100
07-cards-rig-light.png scenario=rig&page=cards&theme=light 1100
08-firstrun-rig-dark.png scenario=rig&screen=cards&theme=dark 1000
09-firstrun-rig-light.png scenario=rig&screen=cards&theme=light 1000
10-prove-dark.png scenario=ladder&page=prove&theme=dark 900
11-prove-light.png scenario=ladder&page=prove&theme=light 900
12-prove-off-dark.png scenario=live&page=prove&theme=dark 900

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

33
tools/ui-mock/capture.sh Executable file
View file

@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Headless captures of the miner UI on the mock engine, for a review folder (gpu-logos, 7 October 2026).
# Runs on igneum-build-2 (the Mac captures nothing: CLAUDE.md "Nothing heavy on the Mac"), one headless chromium at a
# time, against tools/ui-mock/server.mjs on a port of its own. Each line of the shot list is "<file> <query> <height>".
#
# tools/ui-mock/capture.sh <out dir> [<chromium binary>]
#
# The binary defaults to Playwright's headless shell in ~/.cache/ms-playwright (the box has it); no npm package is needed.
set -euo pipefail
OUT="${1:?out dir}"; CHROME="${2:-}"
HERE="$(cd "$(dirname "$0")" && pwd)"
if [ -z "$CHROME" ]; then CHROME="$(ls -d "$HOME"/.cache/ms-playwright/chromium_headless_shell-*/chrome-linux/headless_shell 2>/dev/null | sort | tail -1 || true)"; fi
[ -x "$CHROME" ] || { echo "no chromium headless shell (pass its path as the second argument)" >&2; exit 2; }
PORT="${IGNEUM_MOCK_PORT:-$((4400 + RANDOM % 400))}"
mkdir -p "$OUT"
node "$HERE/server.mjs" "$PORT" > "$OUT/mock.log" 2>&1 &
MOCK=$!
trap 'kill "$MOCK" 2>/dev/null || true' EXIT
for i in $(seq 1 40); do curl -fsS "http://127.0.0.1:$PORT/t/mock/api/state" > /dev/null 2>&1 && break; sleep 0.25; done
curl -fsS "http://127.0.0.1:$PORT/t/mock/api/state" > /dev/null || { echo "the mock did not answer on $PORT" >&2; cat "$OUT/mock.log" >&2; exit 3; }
WIDTH="${IGNEUM_SHOT_WIDTH:-1280}"
shot() { # <file> <query> <height>
local file="$1" query="$2" height="$3"
"$CHROME" --headless --no-sandbox --disable-gpu --hide-scrollbars --force-device-scale-factor=2 \
--force-prefers-reduced-motion --window-size="$WIDTH,$height" --virtual-time-budget=10000 --run-all-compositor-stages-before-draw \
--screenshot="$OUT/$file" "http://127.0.0.1:$PORT/t/mock/?$query" > "$OUT/$file.log" 2>&1 || { echo "FAILED $file"; cat "$OUT/$file.log"; return 1; }
echo "$file $(stat -c %s "$OUT/$file" 2>/dev/null || stat -f %z "$OUT/$file") B ?$query"
}
while read -r file query height; do
[ -n "$file" ] && [ "${file#\#}" = "$file" ] || continue
shot "$file" "$query" "$height"
done
echo "done: $OUT"

View file

@ -25,6 +25,7 @@
// drivers-reboot, drivers-error, drivers-mac)
// GET /api/ladder answers the chain facts in src/chainfacts.rs's shape (the devnet's params, this key ranked);
// POST /api/card accepts the block card's PNG and answers a path.
// rig gpu-logos: one card per vendor (RTX 5090, RX 9070 XT, Arc B580, a UHD 770 iGPU off), every mark on one page
// heat Ember Heat (mission item 7): a 5090 and a 4070 in heat mode holding 20 °C (heating, 60% duty), the
// region United Kingdom at the Ofgem price; ?page=cards|earnings|settings for the three lines
// The log grows by a few lines a second in every scenario, so follow and scroll-to-bottom can be exercised.
@ -225,6 +226,16 @@ function stateNow() {
if (scenario === 'drivers-error') s.drivers = { ...s.drivers, status: 'error', vendor: 'amd', version: '26.9.2', progress: 1, message: 'download: sha256 mismatch: the file is not the one the table names (page https://www.amd.com/en/support/download/drivers.html)', error: 'download: sha256 mismatch: the file is not the one the table names (page https://www.amd.com/en/support/download/drivers.html)', started_at: t - 120, finished_at: t - 5 };
if (scenario === 'drivers-mac') { s.mining.cards = [{ ...recState.mining.cards[0], driver_offer: { vendor: 'apple', status: 'none', installed: '', wanted: '', min_version: '', size: 0, url: '', hash_source: '', text: 'Apple silicon: no driver step, macOS carries it.', installable: false } }]; s.mining.hash_total = recState.mining.hash_total; s.display_name = recState.display_name; s.drivers.platform = 'macos'; }
}
if (scenario === 'rig') {
// gpu-logos (7 October 2026): a mixed rig, one card per vendor, every mark on one page; the Intel numbers are the
// intel scenario's expectation, the others the heat and nvidia scenarios' readings
s.mining.cards = [{ ...recState.mining.cards[0], key: 'nvidia:0:NVIDIA GeForce RTX 5090', name: 'NVIDIA GeForce RTX 5090', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '21760 cores', vram_mb: 32768, device: '0', identities: 8, ids: ['8fafda27', '1b2c3d4e', '5f6a7b8c'], power_default_w: 575, power_pct: 80, power_applied: true, power_limit_w: 460, power_w: 291, temp_gpu: 63, temp_mem: 76, telemetry_at: t, hash_now: 122.3, hash_avg: 121.8, accepted: 102, state: 'mining', tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'full', tune_control: true, sweep_pct: 80, sweep_supported: true, sweep_state: 'idle' },
{ ...recState.mining.cards[0], key: 'amd:gfx1201', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', kind: 'discrete', vendor: 'amd', worker: 'OpenCL', detail: '64 compute units', platform: 'AMD Accelerated Parallel Processing', vram_mb: 16384, device: '1', bus: '0000:03:00.0', identities: 4, ids: ['a1b2c3d4'], power_default_w: 304, power_pct: 100, power_applied: false, power_w: 198, temp_gpu: 61, temp_mem: 0, telemetry_at: t, hash_now: 18.9, hash_avg: 18.7, accepted: 20, state: 'mining', sweep_supported: true, sweep_state: 'idle', sweep_note: 'measure only: no cap through OpenCL' },
{ ...recState.mining.cards[0], key: 'intel:Intel(R) Arc(TM) B580 Graphics', name: 'Intel(R) Arc(TM) B580 Graphics', code: 'Intel(R) Arc(TM) B580 Graphics', platform: 'Intel(R) OpenCL Graphics (OpenCL 3.0 ), driver 32.0.101.9034', kind: 'discrete', vendor: 'intel', worker: 'OpenCL', detail: '160 compute units', vram_mb: 12208, device: '2', bus: '05:00.0', identities: 4, ids: ['c0ffee01'], enabled: true, state: 'mining', hash_now: 15.2, hash_avg: 15.0, accepted: 3, rejected: 0, power_default_w: 0, power_pct: 0, power_applied: false, power_w: 0, temp_gpu: 0, temp_mem: 0, eff_mhw: 0, sweep_supported: false, sweep_state: 'unsupported', sweep_note: 'not available: Intel Arc exposes no power cap or reading through OpenCL (the driver\'s IGCL counters come next)' },
{ ...recState.mining.cards[0], key: 'intel::Intel UHD 770', name: 'Intel UHD Graphics 770', kind: 'integrated', vendor: 'other', worker: 'OpenCL', detail: '32 EUs', vram_mb: 0, enabled: false, state: 'off', hash_now: 0, hash_avg: 0, accepted: 0, ids: [], reason: 'integrated graphics: off by default', identities: 1 }];
s.mining.hash_total = 156.4; s.display_name = 'rig-01';
s.settings = { ...s.settings, sweep: true, power_control: true, tune_goal: 'balanced', power_price_pence: 26.32, region: 'gb' };
}
if (scenario === 'nvidia') {
s.mining.cards = [{ ...recState.mining.cards[0], key: 'nvidia:0:NVIDIA GeForce RTX 5090', name: 'NVIDIA GeForce RTX 5090', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '21760 cores', vram_mb: 32768, device: '0', identities: 8, ids: ['8fafda27', '1b2c3d4e', '5f6a7b8c'], power_default_w: 575, power_pct: 80, power_applied: true, power_limit_w: 460, power_w: 441, temp_gpu: 71, temp_mem: 92, telemetry_at: t, hash_now: 412.5, hash_avg: 405.2 },
{ ...recState.mining.cards[0], key: 'nvidia:1:NVIDIA GeForce RTX 4070', name: 'NVIDIA GeForce RTX 4070', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '5888 cores', vram_mb: 12288, device: '1', identities: 2, ids: ['9a8b7c6d'], power_default_w: 200, power_pct: 80, power_applied: false, power_note: 'nvidia-smi: wanted 160 W, card reports 200 W', power_limit_w: 200, power_w: 188, temp_gpu: 64, temp_mem: 0, telemetry_at: t, hash_now: 98.1, hash_avg: 97.4, state: 'restarting', restart_in_s: 4 },