diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 4428c6c9e..ca4f63c7b 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -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} @@ -818,3 +813,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} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index f314f738b..661954fc1 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -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 = '', + // the arrow: the corner bracket pointing up and right, the bracket below it + amd: SVG_OPEN + ' fill="currentColor">', + // 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">', + // the apple: the bitten fruit with its leaf + apple: SVG_OPEN + ' fill="currentColor">', + // the neutral fallback: a chip with its pins + gpu: SVG_OPEN + ' fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">' + }; + function escH(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } + // 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 '
' + mk.svg + '
'; } + // 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 '
' + escH(cd.name) + (extra || '') + ' ' + escH(kindWord(cd.kind)) + '
' + (mk.series ? '
' + escH(mk.series) + '
' : ''); } + // 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') + '' + esc(bc.cardLine) + ''; $('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 ''; - 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) ? '' + esc(vramText(cd)) + '' : '') + (cd.detail && cd.kind !== 'apple' ? '' + esc(cd.detail) + '' : '') + (cd.detail && cd.kind === 'apple' ? '' + esc(cd.detail.split(',')[0]) + '' : ''); - d.innerHTML = '
' + badgeHtml(cd) + '
' + - '
' + esc(cd.name) + (unusable ? ': ' + esc(cd.message || 'not usable (' + cd.problem + ')') : removed ? ': removed' : '') + ' ' + kindWord(cd.kind) + '
' + meta + '
' + + d.innerHTML = View.markHtml(cd) + + '
' + View.nameHtml(cd, unusable ? ': ' + esc(cd.message || 'not usable (' + cd.problem + ')') : removed ? ': removed' : '') + '
' + meta + '
' + (cd.reason ? '
' + esc(cd.reason) + '
' : '') + (cd.message && !unusable && !removed ? '
' + esc(cd.message) + '
' : '') + setupDriverHtml(cd) + '
' + ''; container.appendChild(d); @@ -2058,8 +2133,8 @@ if (typeof document !== 'undefined') (function () { (dw.sub ? '' + esc(dw.sub) + '' : '') + (dw.dry ? 'dry run: the installer is not run' : '') + '
'; var tune = em.kind === 'none' ? '' : '
' + flameHtml(em.fill, em.kind) + '' + esc(em.label) + '' + (em.sub ? '' + esc(em.sub) + '' : '') + (em.savingText ? '' + esc(em.savingText) + '' : '') + (em.heat ? '' + esc(em.heat) + '' : '') + (em.kind === 'running' ? '' : '') + '
'; return '
' + - '
' + badgeHtml({ vendor: r.vendor }) + '
' + - '
' + esc(r.name) + ' ' + esc(r.kindWord) + '
' + + '
' + View.markHtml(cd) + + '
' + View.nameHtml(cd) + '' + esc(word) + (r.meta ? ' · ' + esc(r.meta) : '') + '' + subs.join('') + '
' + '
' + nums + '
' + '
' + tuneBtn + sw + chev + '
' + drv + tune + diff --git a/app/igneum-app/ui/view.test.mjs b/app/igneum-app/ui/view.test.mjs index 3feddfe3b..216680f2b 100644 --- a/app/igneum-app/ui/view.test.mjs +++ b/app/igneum-app/ui/view.test.mjs @@ -725,3 +725,89 @@ 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, /^ { + 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('
{ + 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, /
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: 'x', vendor: 'zzz', kind: 'unknown' })).includes(''), 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'); +}); diff --git a/docs/plans/gpu-logos-shots.txt b/docs/plans/gpu-logos-shots.txt new file mode 100644 index 000000000..0c102d9fe --- /dev/null +++ b/docs/plans/gpu-logos-shots.txt @@ -0,0 +1,10 @@ +# 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 diff --git a/tools/ui-mock/capture.sh b/tools/ui-mock/capture.sh new file mode 100755 index 000000000..4d2a6e797 --- /dev/null +++ b/tools/ui-mock/capture.sh @@ -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 " ". +# +# tools/ui-mock/capture.sh [] +# +# 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() { # + local file="$1" query="$2" height="$3" + "$CHROME" --headless --no-sandbox --disable-gpu --hide-scrollbars --force-device-scale-factor=2 \ + --window-size="$WIDTH,$height" --virtual-time-budget=8000 --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" diff --git a/tools/ui-mock/server.mjs b/tools/ui-mock/server.mjs index c448ed14a..81134e940 100644 --- a/tools/ui-mock/server.mjs +++ b/tools/ui-mock/server.mjs @@ -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 },