ember-heat (0.3.19): the region and price prompt, the heat cards and lines, the cost-against-rent row

First run gains step 2 of 3 (the region list, the price per kWh prefilled from the public table and typed, never
fetched, the restricted line for Russia's regions and China from future.md 4.4 and 8.3 with the standing sentence);
Settings gains Electricity and Heat mode; the Cards strip and every row's Ember line show the duty and the heat in
watts; a resting card, the pill and the big button say heat mode; Earnings gains the miner's cost per MH/s-hour at
their price beside the bench log's measured rental rate with the verdict. The money symbol follows the region.
heat-region.test.mjs checks the Russian entry, the bench-log rate and the words; the mock gains heat and heat-rest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 09:31:55 +00:00
parent 85c619f578
commit 8099bbfd46
5 changed files with 402 additions and 21 deletions

View file

@ -151,7 +151,7 @@ main{position:absolute;top:var(--top);bottom:var(--bottom);left:0;right:0;overfl
body.has-rail main{left:var(--rail)}
body.drawer-open main{bottom:var(--drawer-h)}
.screen{display:none;max-width:1080px;margin:0 auto;animation:rise .45s ease}
body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block}
body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="region"] #screen-region,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block}
@keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){.screen,.page{animation:none}}
#screen-dashboard{padding:22px 0 32px}
@ -395,6 +395,18 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
.addr-input{width:100%;margin-top:var(--s-2);background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;color:var(--bone);font-size:var(--t-md);letter-spacing:.02em;user-select:text;-webkit-user-select:text}
.addr-input.short{width:100px;flex:0 0 100px}
.addr-input:focus{outline:none;border-color:var(--ember)}
/* Ember Heat (mission item 7): the region list, the restricted line, the heat line on the strip and the rows, the rent row */
.addr-input.select{appearance:none;-webkit-appearance:none;max-width:360px;padding-right:36px;background-image:linear-gradient(45deg,transparent 50%,var(--ash) 50%),linear-gradient(135deg,var(--ash) 50%,transparent 50%);background-position:calc(100% - 20px) 50%,calc(100% - 14px) 50%;background-size:6px 6px,6px 6px;background-repeat:no-repeat;cursor:pointer}
.addr-input.select option{background:var(--graphite);color:var(--bone)}
.restricted{font-size:var(--t-md);line-height:1.5;color:var(--bone);background:var(--ember-12);border:1px solid var(--ember-40);border-left:3px solid var(--ember);border-radius:10px;padding:10px 14px;max-width:64ch;margin:0}
.heat-line{font-family:var(--mono);font-size:var(--t-sm)}
.heat-line.heat,.line-text.ok{color:var(--molten)}
.heat-line.rest,.line-text.rest{color:var(--ash)}
.field.grow{flex:1 1 260px;min-width:0}
.gl-tune .n.heat{color:var(--molten)}
.money-row.rent .v{font-size:var(--t-num)}
.money-row.rent.under .v{color:var(--ember)}
.money-row.rent.ask .v{color:var(--ash)}
.option:not(.on) .addr-input{display:none}
.err{font-size:var(--t-base);color:var(--ember)}

View file

@ -311,7 +311,7 @@ var View = (function () {
var on = !!cd.enabled && !removed && !unusable, st = removed ? 'removed' : unusable ? 'unusable' : (cd.state || 'off');
// the rule (docs/plans/ember-tune.md, 6 October 2026): a card never shows 0 MH/s without a reason word
var tuning = st === 'tuning', held = st === 'held';
var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : tuning ? tuneWord(cd) : held ? (cd.message || 'paused for a job from the team') : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated errors' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st;
var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : tuning ? tuneWord(cd) : held ? (cd.message || 'paused for a job from the team') : st === 'resting' ? 'resting (heat mode)' : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated errors' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st;
var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : (st === 'mining' || tuning) ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : '';
var hash = on && (st === 'mining' || tuning) ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : '';
var temp = cd.temp_gpu > 0 ? Math.round(cd.temp_gpu) + ' °C' : '';
@ -340,7 +340,9 @@ var View = (function () {
// ---- money (miner-ui-3): £ a day from watts at pence per kWh; null when either is unknown ----
function poundsPerDay(watts, pence) { return watts > 0 && pence > 0 ? watts * 24 / 1000 * pence / 100 : null; }
function money(v) { if (v === null || v === undefined || isNaN(v)) return ''; return '£' + (v >= 100 ? String(Math.round(v)) : v.toFixed(2)); }
// the currency symbol follows the region (Ember Heat, setRegion); £ until a region is chosen
var currency = { code: 'GBP', symbol: '£', minor: 'p', usd: 1.32 };
function money(v) { if (v === null || v === undefined || isNaN(v)) return ''; return currency.symbol + (v >= 100 ? String(Math.round(v)) : v.toFixed(2)); }
// MH per watt: the engine's live figure, else rate over draw
function effOf(cd) { if (cd.eff_mhw > 0) return cd.eff_mhw; return cd.power_w > 0 && cd.hash_now > 0 ? cd.hash_now / cd.power_w : 0; }
function effText(e) { return e > 0 ? (e >= 10 ? e.toFixed(1) : e.toFixed(2)) + ' MH/W' : ''; }
@ -448,6 +450,7 @@ var View = (function () {
if (tc) return { label: 'Tuning', sub: 'mining again in ' + tuneEta(tc.tune_eta_s) + ' · ' + tc.name, cls: 'stop', disabled: true, act: '' };
var heldCards = cards.filter(function (c) { return c.state === 'held'; }).length;
if (heldCards && !mining) return { label: 'Held', sub: 'a job from the team is using the cards. Mining restarts when it ends', cls: 'stop', disabled: true, act: '' };
if (s && s.heat && s.heat.on && s.heat.phase === 'resting' && !mining && !m.paused) return { label: 'Stop mining', sub: 'resting · heat mode holds ' + s.heat.set_c.toFixed(1) + ' °C, heats again in ' + (s.heat.until_s < 45 ? 'under a minute' : Math.round(s.heat.until_s / 60) + ' min'), cls: 'stop', disabled: false, act: 'pause' };
if (!on) return { label: 'Start mining', sub: cards.length ? 'switch a card on in Cards first' : 'no card this app can use', cls: '', disabled: true, act: '' };
if (m.paused) return { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' };
if (m.state === 'mining') return { label: 'Stop mining', sub: 'mining on ' + mining + ' of ' + on + ' card' + (on === 1 ? '' : 's'), cls: 'stop', disabled: false, act: 'pause' };
@ -666,6 +669,7 @@ var View = (function () {
if (tuningCard(m.cards)) return { text: 'Tuning', tone: 'on' };
if (m.state === 'mining') return { text: 'Mining · ' + (m.hash_total >= 100 ? m.hash_total.toFixed(0) : (m.hash_total || 0).toFixed(1)) + ' MH/s', tone: 'on' };
if (m.state === 'paused') return { text: 'Paused', tone: '' };
if (s.heat && s.heat.on && s.heat.phase === 'resting') return { text: 'Resting · heat mode', tone: '' };
if (n.state === 'syncing') return { text: 'Syncing the node', tone: '' };
if (n.state === 'starting') return { text: 'Starting the node', tone: '' };
if (n.state === 'restarting') return { text: 'Node restarting', tone: 'bad' };
@ -698,6 +702,7 @@ var View = (function () {
out.next = nextCheck(cd.sweep_at, st.tune_period_s, now).replace(/^next check /, '');
out.points = (cd.tune_curve || []).filter(function (r) { return r.watts > 0 && r.mhs > 0; }).map(function (r) { return { w: r.watts, mhs: r.mhs, eff: r.eff || (r.mhs / r.watts), marked: !!r.mark, chosen: cd.sweep_watts > 0 && Math.abs(r.watts - cd.sweep_watts) < 0.5 && Math.abs((r.mhs || 0) - (cd.sweep_mhs || 0)) < 0.05 }; });
if (cd.tune_floor) out.floor = true;
out.heat = heatRowWords(s && s.heat);
if (tw.kind === 'running') { out.fill = tw.progress >= 0 ? tw.progress : 0.1; out.label = cd.tune_steps > 0 ? 'Tuning: step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : 'Tuning'; out.sub = cd.tune_eta_s > 0 ? tuneEta(cd.tune_eta_s) + ' left' : ''; return out; }
if (tw.kind === 'paused') { out.label = 'Tuning paused by Igneum'; out.sub = ''; return out; }
if (tw.kind === 'stopped') { out.label = tw.text; return out; }
@ -724,6 +729,85 @@ var View = (function () {
var ppd = poundsPerDay(w, pence);
return 'Ember saves ' + Math.round(w) + ' W' + (ppd !== null ? ', ' + money(ppd) + ' a day' : '') + ' across ' + n + ' tuned card' + (n === 1 ? '' : 's');
}
// ---- Ember Heat (mission item 7, 7 October 2026; docs/plans/ember-heat.md): the region table and the price prompt,
// the restricted line, the cost-against-rent line, the heat words ----
// The prices are the public table's rows (docs/analysis/mission/future.md 4.1: Ofgem, Eurostat, EIA), typed into the
// app as defaults the miner edits; nothing is fetched. The dollar rates are the ones future.md 3.4 implies (USD 1.32 per
// pound, 1.08 per euro), approximate. The restricted entries are future.md 4.4 and 8.3 item 7: Russia (ten regions, the
// Moscow region, the seasonal three) and China. Nothing is added to that list here.
var RESTRICTED_SENTENCE = 'Mining may be restricted where you are. You are responsible for checking.';
var CURRENCIES = { GBP: { code: 'GBP', symbol: '£', minor: 'p', usd: 1.32 }, EUR: { code: 'EUR', symbol: '€', minor: 'c', usd: 1.08 }, USD: { code: 'USD', symbol: '$', minor: 'c', usd: 1 } };
var EUROSTAT = 'Eurostat, second half of 2025';
var REGIONS = [
{ code: 'gb', name: 'United Kingdom', cur: 'GBP', price: 26.32, source: 'Ofgem cap, 1 October to 31 December 2026' },
{ code: 'ie', name: 'Ireland', cur: 'EUR', price: 40.42, source: EUROSTAT },
{ code: 'de', name: 'Germany', cur: 'EUR', price: 38.69, source: EUROSTAT },
{ code: 'be', name: 'Belgium', cur: 'EUR', price: 34.99, source: EUROSTAT },
{ code: 'eu', name: 'European Union, other', cur: 'EUR', price: 28.96, source: EUROSTAT + ', the EU average' },
{ code: 'hu', name: 'Hungary', cur: 'EUR', price: 10.82, source: EUROSTAT + ', in euro cents' },
{ code: 'mt', name: 'Malta', cur: 'EUR', price: 12.82, source: EUROSTAT },
{ code: 'bg', name: 'Bulgaria', cur: 'EUR', price: 13.55, source: EUROSTAT + ', in euro cents' },
{ code: 'us', name: 'United States', cur: 'USD', price: 17.7, source: 'EIA average, second half of 2025' },
{ code: 'kz', name: 'Kazakhstan', cur: 'USD', price: 4, source: 'about 4 c, a secondary source, approximate' },
{ code: 'py', name: 'Paraguay', cur: 'USD', price: 4.4, source: 'ANDE crypto tariff, 4.4 to 6 c; every crypto contract ends 31 December 2027' },
{ code: 'ir', name: 'Iran', cur: 'USD', price: 1, source: 'licensed mining tariff, about 1 c, a secondary source, approximate' },
{ code: 'et', name: 'Ethiopia', cur: 'USD', price: 2, source: '2 to 5.3 c, a secondary source' },
{ code: 'ng', name: 'Nigeria', cur: 'USD', price: 4.8, source: 'hosted, 4.8 c, a secondary source' },
{ code: 'ru-mow', name: 'Russia, Moscow region', cur: 'USD', price: 0, restricted: 'Mining is banned in the Moscow region from 15 August 2026 to 2032.' },
{ code: 'ru-irk', name: 'Russia, Irkutsk', cur: 'USD', price: 0, restricted: 'Irkutsk has a seasonal mining ban.' },
{ code: 'ru-bur', name: 'Russia, Buryatia', cur: 'USD', price: 0, restricted: 'Buryatia has a seasonal mining ban.' },
{ code: 'ru-zab', name: 'Russia, Zabaikalsky', cur: 'USD', price: 0, restricted: 'Zabaikalsky has a seasonal mining ban.' },
{ code: 'ru', name: 'Russia, other region', cur: 'USD', price: 0, restricted: 'Mining is banned in ten regions from 1 January 2025 to 15 March 2031, in the Moscow region from 15 August 2026, and seasonally in Irkutsk, Buryatia and Zabaikalsky. This app does not carry the list of ten.' },
{ code: 'cn', name: 'China', cur: 'USD', price: 0, restricted: 'Mining is illegal in China. The joint Notice of 6 February 2026 reaffirmed it.' },
{ code: 'other', name: 'Somewhere else', cur: 'USD', price: 0 }
];
function regionOf(code) { for (var i = 0; i < REGIONS.length; i++) if (REGIONS[i].code === code) return REGIONS[i]; return null; }
function currencyOf(code) { var r = regionOf(code); return CURRENCIES[r ? r.cur : 'GBP'] || CURRENCIES.GBP; }
// the money symbol follows the region from here on (every £ figure on every page)
function setRegion(code) { currency = currencyOf(code); return currency; }
// the restricted line for a region: the standing sentence plus the entry, or '' for a region with no entry
function restrictedLine(code) { var r = regionOf(code); return r && r.restricted ? RESTRICTED_SENTENCE + ' ' + r.restricted : ''; }
// the price note under the field: where the default came from, or that the miner types their own
function priceNote(code) {
var r = regionOf(code), c = currencyOf(code);
if (!r) return 'Choose a region to see a typical price. Every ' + c.symbol + ' figure uses what you type here. Nothing is fetched.';
return (r.price > 0 ? 'Typical: ' + r.price + ' ' + c.minor + ' per kWh (' + r.source + '). ' : 'No table price for this region. ') + 'Type what you pay, in ' + (c.code === 'GBP' ? 'pence' : c.code === 'EUR' ? 'euro cents' : 'US cents') + ' per kWh. Nothing is fetched.';
}
// the measured rental rate of hash (docs/bench-log.md, "Rental cost of hash, 6 October 2026"); the test
// heat-region.test.mjs reads the bench log and fails when this number drifts from it
var RENT = { usd_per_mhs_hour: 0.0117, measured: '6 October 2026', source: 'RunPod community pods, 38 pods at list price, 1,748 MH/s for USD 20.44 an hour' };
// the miner's own cost of hash: watts per MH/s at the price, in the region's minor unit per MH/s-hour, against the rent.
// Live numbers while mining; the last tune's point otherwise; nothing without a draw reading or a price.
function rentLine(cards, price, code) {
var c = currencyOf(code), w = 0, h = 0, live = false;
(cards || []).filter(present).forEach(function (cd) {
if (cd.state === 'mining' && cd.power_w > 0 && cd.hash_now > 0) { w += cd.power_w; h += cd.hash_now; live = true; }
});
if (!(h > 0)) { w = 0; (cards || []).filter(present).forEach(function (cd) { if (cd.sweep_watts > 0 && cd.sweep_mhs > 0) { w += cd.sweep_watts; h += cd.sweep_mhs; } }); }
var out = { kind: 'none', text: '', sub: '', cost: 0, usd: 0, ratio: 0, live: live, rentUsd: RENT.usd_per_mhs_hour };
if (!(h > 0 && w > 0)) { out.text = 'Your cost of hash: no reading yet'; out.sub = 'needs a card mining with a power reading, or a finished tune. Rented hash costs USD ' + RENT.usd_per_mhs_hour + ' per MH/s-hour (' + RENT.source + ', ' + RENT.measured + ').'; return out; }
if (!(price > 0)) { out.kind = 'price'; out.text = 'Your cost of hash: set a price'; out.sub = Math.round(w) + ' W for ' + (h >= 100 ? h.toFixed(0) : h.toFixed(1)) + ' MH/s is ' + (w / h).toFixed(2) + ' W per MH/s. Set your electricity price to compare it with rented hash at USD ' + RENT.usd_per_mhs_hour + ' per MH/s-hour.'; return out; }
var cost = w / h / 1000 * price; // minor units per MH/s-hour
var usd = cost / 100 * c.usd, ratio = RENT.usd_per_mhs_hour / usd;
out.kind = 'line'; out.cost = cost; out.usd = usd; out.ratio = ratio;
var costText = cost < 0.1 ? cost.toFixed(3) : cost.toFixed(2);
out.text = costText + ' ' + c.minor + ' per MH/s-hour';
var verdict = ratio >= 1.05 ? 'Yours is ' + (ratio >= 10 ? Math.round(ratio) : ratio.toFixed(1)) + 'x cheaper than rented hash.' : ratio <= 0.95 ? 'Rented hash undercuts your card by ' + ((1 / ratio) >= 10 ? Math.round(1 / ratio) : (1 / ratio).toFixed(1)) + 'x.' : 'Level with rented hash.';
out.verdict = verdict;
out.sub = 'your cost of hash at ' + price + ' ' + c.minor + '/kWh and ' + (w / h).toFixed(2) + ' W per MH/s' + (live ? '' : ' (the last tune)') + (c.code === 'USD' ? '' : ', about USD ' + usd.toFixed(4)) + '. Rented hash: USD ' + RENT.usd_per_mhs_hour + ' per MH/s-hour (' + RENT.source + ', ' + RENT.measured + '). ' + verdict;
return out;
}
// heat mode on the Ember strip and the card rows: h = state.heat
function heatLine(h) {
if (!h || !h.on) return { text: '', tone: '' };
var room = h.room_source === 'typed' ? 'room ' + h.room_c.toFixed(1) + ' °C' : h.room_source === 'card' ? 'room about ' + Math.round(h.room_c) + ' °C' : 'no room reading yet';
var heat = h.heat_w > 0 ? Math.round(h.heat_w) + ' W of heat' : h.phase === 'resting' ? 'resting' + (h.full_w > 0 ? ', ' + Math.round(h.full_w) + ' W when heating' : '') : h.full_w > 0 ? Math.round(h.full_w) + ' W when heating' : 'no draw reading';
var duty = Math.round((h.duty_hour > 0 ? h.duty_hour : h.duty) * 100);
return { text: 'Heat mode: holding ' + h.set_c.toFixed(1) + ' °C · ' + room + ' · heating ' + duty + '% of the time · ' + heat, tone: h.phase === 'resting' ? 'rest' : h.phase === 'heating' ? 'heat' : '' };
}
// the short form on a card row's Ember line
function heatRowWords(h) { if (!h || !h.on) return ''; return 'heat ' + Math.round((h.duty_hour > 0 ? h.duty_hour : h.duty) * 100) + '% · ' + (h.phase === 'resting' ? 'resting' : h.heat_w > 0 ? Math.round(h.heat_w) + ' W of heat' : 'heating'); }
// ---- the engine's 0.3.16 fields (ember-tune fd03f35): mining.watts_total, mining.pounds_per_day, address.balance_wei
// (string | null), address.balance_age_s (-1 until read), address.balance_note, address.price_gbp_per_ign
// (null until a signed manifest carries a price), the chain scene's reply.source ("node" | "site") ----
@ -943,7 +1027,7 @@ var View = (function () {
// the confirmations' words (in place, never a dialog)
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, 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, 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 };
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, 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, 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
@ -1046,7 +1130,7 @@ if (typeof document !== 'undefined') (function () {
document.querySelectorAll('#theme-seg .seg-b').forEach(function (b) { var on = b.dataset.theme === t; b.classList.toggle('on', on); b.setAttribute('aria-checked', on ? 'true' : 'false'); });
store('igneum.theme', t === 'system' ? null : t);
}
applyTheme(stored('igneum.theme') || 'system');
applyTheme(params.get('theme') || stored('igneum.theme') || 'system'); // ?theme=light|dark for screenshots
$('theme-seg').addEventListener('click', function (e) { var b = e.target.closest('.seg-b'); if (b) applyTheme(b.dataset.theme); });
// ---------- screens (setup) and pages (the dashboard) ----------
@ -1060,6 +1144,8 @@ if (typeof document !== 'undefined') (function () {
$('page-title').hidden = !dash;
if (!dash && drawerOpen()) setDrawer(false);
if (p === 'cards' && !cardsSeen) { cardsSeen = true; api('api/detect', {}); }
// the region step; ?region=<code> preselects one for screenshots
if (p === 'region') { fillRegions($('region-select')); var rc = params.get('region') || regionNow(); $('region-select').value = rc; regionWords(rc, $('region-price'), $('region-unit'), $('region-note'), $('region-restricted'), !(priceNow() > 0)); if (priceNow() > 0) $('region-price').value = String(priceNow()); }
if (dash) { showPage(page); mountDag(); }
api('api/phase', { phase: p });
}
@ -1090,7 +1176,35 @@ if (typeof document !== 'undefined') (function () {
$('btn-begin').addEventListener('click', function () { show('cards'); });
$('btn-cards-next').addEventListener('click', function () {
var choices = readCardRows($('cards-list'));
if (choices.length) api('api/cards', { cards: choices }).then(function () { show('address'); }); else show('address');
if (choices.length) api('api/cards', { cards: choices }).then(function () { show('region'); }); else show('region');
});
// ---------- the region and price step (Ember Heat): the list, a typed price never fetched, the restricted line ----------
function fillRegions(sel) {
if (sel.options.length) return;
sel.innerHTML = '<option value="">Choose a region</option>' + View.REGIONS.map(function (r) { return '<option value="' + esc(r.code) + '">' + esc(r.name) + '</option>'; }).join('');
}
// the price field, unit, note and restricted line follow the chosen region; a typed price is kept unless `prefill`
function regionWords(code, priceEl, unitEl, noteEl, restrictedEl, prefill) {
var r = View.regionOf(code), c = View.currencyOf(code);
if (prefill && priceEl) priceEl.value = r && r.price > 0 ? String(r.price) : '';
if (priceEl) priceEl.placeholder = r && r.price > 0 ? String(r.price) : '';
if (unitEl) setText(unitEl.id, (c.code === 'GBP' ? 'pence' : c.code === 'EUR' ? 'euro cents' : 'US cents') + ' per kWh');
if (noteEl) setText(noteEl.id, View.priceNote(code));
var line = View.restrictedLine(code);
if (restrictedEl) { restrictedEl.hidden = !line; setText(restrictedEl.id, line); }
}
function regionNow() { return (state && state.settings && state.settings.region) || stored('igneum.region') || ''; }
function saveRegion(code, price) {
store('igneum.region', code || null); store('igneum.price', price > 0 ? price : null);
return api('api/region', { region: code, price_pence: price }).catch(function () { return api('api/tune/goal', { price_pence: price }); });
}
$('region-select').addEventListener('change', function () { regionWords(this.value, $('region-price'), $('region-unit'), $('region-note'), $('region-restricted'), true); });
$('btn-region-back').addEventListener('click', function () { show('cards'); });
$('btn-region-skip').addEventListener('click', function () { show('address'); });
$('btn-region-next').addEventListener('click', function () {
var code = $('region-select').value, p = parseFloat($('region-price').value);
if (!(p >= 0)) p = 0;
saveRegion(code, p).then(function () { show('address'); }).catch(function () { show('address'); });
});
$('btn-cards-retry').addEventListener('click', function () { cardsSeen = false; $('cards-detecting').hidden = false; api('api/detect', {}); });
$('btn-address-back').addEventListener('click', function () { show('cards'); });
@ -1246,13 +1360,30 @@ if (typeof document !== 'undefined') (function () {
$('s-sweep').addEventListener('change', function () { api('api/sweep/enable', { on: this.checked }).then(function (r) { if (r.ok) toast($('s-sweep').checked ? 'Ember Tune on. Once after install, then every 7 days' : 'Ember Tune off'); }); });
$('s-climb').addEventListener('change', function () { var on = this.checked; api('api/tune/goal', { climb: on }).then(function (r) { if (!r || !r.ok) { toast('Needs the Ember Tune update'); $('s-climb').checked = !on; } }); });
function savePrice() {
var p = parseFloat($('s-price').value);
if (!(p >= 0)) { toast('Type a price in pence per kWh, for example 28'); return; }
store('igneum.price', p > 0 ? p : null);
api('api/tune/goal', { price_pence: p }).then(function () { toast(p > 0 ? 'Price saved: ' + p + 'p per kWh' : 'Price cleared'); if (state) renderPage(state); }).catch(function () { toast(p > 0 ? 'Price saved on this window: ' + p + 'p per kWh' : 'Price cleared'); if (state) renderPage(state); });
var p = parseFloat($('s-price').value), code = $('s-region').value, c = View.currencyOf(code);
if (!(p >= 0)) { toast('Type a price in ' + c.minor + ' per kWh, for example ' + (View.regionOf(code) && View.regionOf(code).price || 26.32)); return; }
saveRegion(code, p).then(function () { toast(p > 0 ? 'Price saved: ' + p + ' ' + c.minor + ' per kWh' : 'Price cleared'); if (state) renderPage(state); }).catch(function () { toast(p > 0 ? 'Price saved on this window: ' + p + ' ' + c.minor + ' per kWh' : 'Price cleared'); if (state) renderPage(state); });
}
$('s-price-save').addEventListener('click', savePrice);
$('s-price').addEventListener('keydown', function (e) { if (e.key === 'Enter') savePrice(); });
$('s-region').addEventListener('change', function () { regionWords(this.value, $('s-price'), $('s-price-unit'), $('s-price-note'), $('s-region-restricted'), true); });
// ---------- Heat mode (Ember Heat) ----------
$('s-heat').addEventListener('change', function () { var on = this.checked; api('api/heat', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Heat mode on. The cards heat for a share of every 10 minutes' : 'Heat mode off'); else { toast(r.error || 'could not change'); $('s-heat').checked = !on; } }); });
function saveHeat() {
var set = parseFloat($('s-heat-set').value), body = { schedule: $('s-heat-schedule').value.trim(), tz_min: -new Date().getTimezoneOffset() };
if ($('s-heat-set').value !== '') { if (!(set >= 5 && set <= 30)) { toast('The set point is 5 to 30 degrees'); return; } body.set_c = set; }
api('api/heat', body).then(function (r) { toast(r.ok ? 'Heat settings saved' : (r.error || 'could not save')); if (state) renderPage(state); });
}
$('s-heat-save').addEventListener('click', saveHeat);
$('s-heat-schedule').addEventListener('keydown', function (e) { if (e.key === 'Enter') saveHeat(); });
$('s-heat-set').addEventListener('keydown', function (e) { if (e.key === 'Enter') saveHeat(); });
function saveRoom() {
var c = parseFloat($('s-heat-room').value);
if (!(c >= -20 && c <= 50)) { toast('A room reading is -20 to 50 degrees'); return; }
api('api/heat', { room_c: c }).then(function (r) { toast(r.ok ? 'Room reading used: ' + c + ' °C for the next two hours' : (r.error || 'could not save')); $('s-heat-room').value = ''; if (state) renderPage(state); });
}
$('s-heat-room-save').addEventListener('click', saveRoom);
$('s-heat-room').addEventListener('keydown', function (e) { if (e.key === 'Enter') saveRoom(); });
$('s-trust').addEventListener('change', function () { var on = this.checked; $('s-trust').checked = !on; $('ask-trust-text').textContent = View.trustAsk(on); $('ask-trust').hidden = false; $('ask-trust').dataset.on = on ? '1' : ''; });
$('ask-trust-no').addEventListener('click', function () { $('ask-trust').hidden = true; });
$('ask-trust-yes').addEventListener('click', function () {
@ -1850,7 +1981,7 @@ if (typeof document !== 'undefined') (function () {
var subs = [];
if (r.sub) subs.push('<div class="msg' + (r.tone === 'bad' ? '' : ' dim') + '">' + esc(r.sub) + '</div>');
if (cells.noReading && r.on) subs.push('<div class="msg dim">' + esc(cells.noReading) + '</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.kind === 'running' ? '<i class="bar"><b style="width:' + Math.round(em.fill * 100) + '%"></b></i>' : '') + '</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>' +
@ -1927,11 +2058,11 @@ if (typeof document !== 'undefined') (function () {
var mining = presentCards.filter(function (c) { return c.state === 'mining'; }).length;
// the live total: rate, watts, £ a day, blocks
setText('t-hash', m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1));
setText('t-hash-sub', m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + presentCards.length + ' card' + (presentCards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'card starting' : 'waiting for the node') : m.state === 'idle' ? 'no card mining' : m.state);
setText('t-hash-sub', m.state === 'paused' ? 'paused' : (s.heat && s.heat.on && s.heat.phase === 'resting' && !mining) ? 'resting · heat mode' : mining ? (mining + ' of ' + presentCards.length + ' card' + (presentCards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'card starting' : 'waiting for the node') : m.state === 'idle' ? 'no card mining' : m.state);
var watts = View.fleetWatts(m, m.cards), price = priceNow(), eff = watts > 0 && m.hash_total > 0 ? View.effText(m.hash_total / watts) : '';
setHidden('t-power-cell', !(watts > 0)); setText('t-power', String(Math.round(watts))); setText('t-power-sub', eff || 'drawn now');
var ppd = View.fleetPounds(m, m.cards, price);
setHidden('t-money-cell', !(watts > 0)); setText('t-money', ppd === null ? 'Set a price' : money(ppd)); $('t-money').classList.toggle('ask-price', ppd === null); setText('t-money-sub', ppd === null ? 'in Settings, pence per kWh' : 'electricity at ' + price + 'p/kWh');
setHidden('t-money-cell', !(watts > 0)); setText('t-money', ppd === null ? 'Set a price' : money(ppd)); $('t-money').classList.toggle('ask-price', ppd === null); setText('t-money-sub', ppd === null ? 'in Settings, per kWh' : 'electricity at ' + price + View.currencyOf(regionNow()).minor + '/kWh');
setText('t-blocks', withCommas(m.accepted_session)); setText('t-blocks-sub', 'this run · ' + withCommas(m.accepted_total) + ' lifetime' + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : ''));
// the rows: every card, on or off; rebuilt only when their words change and never under a hand on a slider
var rows = shown.map(View.cardRow), now = Date.now();
@ -1969,6 +2100,8 @@ if (typeof document !== 'undefined') (function () {
var sched = View.schedule(s.mining.cards, s, s.now);
setText('tune-schedule', sched); setText('s-tune-schedule', sched);
setText('fleet-saving', View.fleetSaving(s.mining.cards, s, s.now, priceNow()));
var hl = View.heatLine(s.heat);
setHidden('heat-line', !hl.text); setText('heat-line', hl.text); $('heat-line').className = 'line-text heat-line' + (hl.tone ? ' ' + hl.tone : '');
var nvOff = cards.some(function (c) { return c.vendor === 'nvidia' && View.present(c); }) && !st.power_control;
setHidden('m-power-row', !nvOff);
if (document.activeElement !== $('m-power-control')) $('m-power-control').checked = !!st.power_control;
@ -2026,8 +2159,11 @@ if (typeof document !== 'undefined') (function () {
if (bw.text) { setText('e-earned', bw.pounds !== null ? money(bw.pounds) : bw.text); setText('e-earned-sub', bw.pounds !== null ? 'at the signed price · ' + bw.text + ' ' + bw.sub : (bw.text === 'not read yet' ? 'balance ' + bw.sub : bw.sub + '. Nothing is bought or sold on devnet')); $('e-earned').title = 'address.balance_wei: the node\u2019s eth_getBalance of the rewards address'; }
else { setText('e-earned', 'balance: not on this engine'); setText('e-earned-sub', 'the wallet shows it'); }
setText('e-cost', ppd === null ? (watts > 0 ? Math.round(watts) + ' W' : 'no draw') : money(ppd) + ' a day');
setText('e-cost-sub', ppd === null ? (watts > 0 ? 'electricity: set a price to see the cost' : 'electricity: no card reports its draw') : 'electricity at ' + price + 'p/kWh for ' + Math.round(watts) + ' W');
setText('e-cost-sub', ppd === null ? (watts > 0 ? 'electricity: set a price to see the cost' : 'electricity: no card reports its draw') : 'electricity at ' + price + View.currencyOf(regionNow()).minor + '/kWh for ' + Math.round(watts) + ' W');
setHidden('e-price-btn', !(watts > 0 && ppd === null));
var rent = View.rentLine(m.cards, price, regionNow());
setText('e-rent', rent.text); setText('e-rent-sub', rent.sub);
$('e-rent-row').classList.toggle('under', rent.kind === 'line' && rent.ratio <= 0.95); $('e-rent-row').classList.toggle('ask', rent.kind === 'price');
if (document.activeElement !== $('s-devfee')) $('s-devfee').checked = !!(s.settings && s.settings.dev_fee);
setText('s-devfee-text', s.settings.dev_fee ? 'Dev fee: 1 block in 100 pays the people who make this app' : 'Dev fee off. Every block pays your address');
setText('r-devfee-line', s.settings.dev_fee ? 'The network itself takes nothing. This is the app’s fee, the same way every mining app takes one' + (m.fee_total ? ' (' + withCommas(m.fee_total) + ' block' + (m.fee_total === 1 ? '' : 's') + ' so far)' : '') + '.' : 'The network itself takes nothing either.');
@ -2074,7 +2210,18 @@ if (typeof document !== 'undefined') (function () {
setText('s-profile-text', View.profileWords(!!st.profile_public));
setHidden('s-climb-row', st.tune_climb === undefined);
setText('s-power-note', st.tuning_off ? ' ' + (st.tuning_note || '') : (st.power_note ? ' ' + st.power_note : ''));
fillRegions($('s-region'));
var rc = regionNow();
if (document.activeElement !== $('s-region') && $('s-region').value !== rc) { $('s-region').value = rc; }
if (document.activeElement !== $('s-price')) { var p = priceNow(); $('s-price').value = p > 0 ? String(p) : ''; }
if (document.activeElement !== $('s-region')) regionWords($('s-region').value, null, $('s-price-unit'), $('s-price-note'), $('s-region-restricted'), false);
var h = s.heat || {};
sw('s-heat', st.heat_on);
setText('s-heat-line', h.note || '');
$('s-heat-line').classList.toggle('ok', h.phase === 'heating'); $('s-heat-line').classList.toggle('rest', h.phase === 'resting');
if (document.activeElement !== $('s-heat-set')) $('s-heat-set').value = st.heat_set_c > 0 ? String(st.heat_set_c) : '';
if (document.activeElement !== $('s-heat-schedule')) $('s-heat-schedule').value = st.heat_schedule || '';
setText('s-heat-room-note', (st.heat_room_at > 0 ? 'Your reading ' + st.heat_room_c + ' °C, ' + View.ago(s.now - st.heat_room_at) + (s.now - st.heat_room_at > 7200 ? ' (older than two hours, so the card reads the room now)' : '') + '. ' : '') + 'A reading you type is the room for two hours. Without one the card\'s own sensor reads the room after 3 minutes of rest, within about 3 degrees' + (h.offset_learned ? ' (idle offset learned: ' + h.offset_c.toFixed(1) + ' degrees)' : '') + '. Typing a reading while the cards rest teaches the app the card\'s idle offset.');
renderTuneCard(s);
if (document.activeElement !== $('s-name')) $('s-name').value = s.display_name || '';
setText('s-mid', 'machine id ' + (s.machine_id || '').slice(0, 8));
@ -2287,6 +2434,7 @@ if (typeof document !== 'undefined') (function () {
}
function render(s) {
state = s; stateAt = performance.now();
View.setRegion((s.settings && s.settings.region) || stored('igneum.region') || '');
if (forcedUpdate) { s.update = sampleUpdate(forcedUpdate); if (forcedUpdate === 'updated') { s.version = '0.3.14'; s.uptime_s = 20; } }
if (forcedJob) s.jobs = sampleJob(forcedJob, s.now || 0);
if (params.get('tune')) sampleTune(s, params.get('tune'));

View file

@ -0,0 +1,150 @@
// node --test app/igneum-app/ui/heat-region.test.mjs (no dependencies; the pre-push gate runs it)
// Ember Heat (mission item 7, docs/plans/ember-heat.md): the region prompt shows the right restricted entry for a Russian
// region and nothing for the rest; the rent line reads the bench log's measured rate; the heat words name the duty and
// the watts; the money symbol follows the region.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(join(here, 'app.js'), 'utf8');
const mod = { exports: {} };
new Function('module', src)(mod);
const V = mod.exports.View;
const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over });
test('the region prompt shows the restricted entry for a Russian region, with the standing sentence first', () => {
const line = V.restrictedLine('ru-mow');
assert.ok(line.startsWith(V.RESTRICTED_SENTENCE), line);
assert.equal(V.RESTRICTED_SENTENCE, 'Mining may be restricted where you are. You are responsible for checking.');
assert.ok(line.includes('Moscow region from 15 August 2026 to 2032'), line);
assert.ok(V.restrictedLine('ru').includes('ten regions from 1 January 2025 to 15 March 2031'), 'the other-region entry carries the ten-region ban');
assert.ok(V.restrictedLine('ru').includes('does not carry the list of ten'), 'the app says what it does not know');
for (const seasonal of ['ru-irk', 'ru-bur', 'ru-zab']) assert.ok(V.restrictedLine(seasonal).includes('seasonal mining ban'), seasonal);
assert.ok(V.restrictedLine('cn').includes('illegal in China'), 'China is the other entry of future.md 8.3 item 7');
});
test('no region outside the restricted list of future.md 8.3 gets a line', () => {
const restricted = V.REGIONS.filter((r) => r.restricted).map((r) => r.code).sort();
assert.deepEqual(restricted, ['cn', 'ru', 'ru-bur', 'ru-irk', 'ru-mow', 'ru-zab'], 'Russia (the ten regions, Moscow, the seasonal three) and China, nothing else');
for (const code of ['gb', 'de', 'us', 'kz', 'py', 'ir', 'other', '']) assert.equal(V.restrictedLine(code), '', code);
});
// the research file and the bench log are internal (tools/ci/export-exclude.txt): a tree without them skips the read
const research = join(here, '../../../docs/analysis/mission/future.md');
const benchLog = join(here, '../../../docs/bench-log.md');
test('the restricted entries are the ones the research file states (future.md section 4.4 and 8.3)', (t) => {
if (!existsSync(research)) { t.skip('docs/analysis/mission/future.md is not in this tree'); return; }
const future = readFileSync(research, 'utf8');
assert.ok(future.includes('mining banned in 10 regions 1 Jan 2025 to 15 Mar 2031'), 'the ten-region ban and its dates');
assert.ok(future.includes('Moscow region from 15 Aug 2026 to 2032'), 'the Moscow region entry and its dates');
assert.ok(future.includes('seasonal bans in Irkutsk, Buryatia, Zabaikalsky'), 'the seasonal three');
assert.ok(/China \| illegal; joint Notice 6 Feb 2026/.test(future), 'China');
assert.ok(future.includes('"mining may be restricted where you are; you are responsible for checking"'), 'the standing sentence');
});
test('the price prompt defaults from the public table and says it is typed, never fetched', () => {
assert.equal(V.regionOf('gb').price, 26.32);
assert.ok(V.regionOf('gb').source.includes('Ofgem'));
assert.equal(V.regionOf('de').price, 38.69);
assert.equal(V.regionOf('us').price, 17.7);
assert.equal(V.regionOf('ru-mow').price, 0, 'no table price for a Russian region: the miner types one');
assert.ok(V.priceNote('gb').includes('Typical: 26.32 p per kWh'));
assert.ok(V.priceNote('gb').endsWith('Nothing is fetched.'));
assert.ok(V.priceNote('ru').startsWith('No table price for this region.'));
assert.ok(V.priceNote('').includes('Nothing is fetched.'));
assert.ok(!src.includes('fetch(\'https://') && !src.includes('exchangerate'), 'the UI fetches no price and no rate');
});
test('the money symbol follows the region: pounds, euros, dollars', () => {
V.setRegion('gb'); assert.equal(V.money(1.5), '£1.50');
V.setRegion('de'); assert.equal(V.money(1.5), '€1.50'); assert.equal(V.currencyOf('de').minor, 'c');
V.setRegion('us'); assert.equal(V.money(1.5), '$1.50');
V.setRegion('ru-mow'); assert.equal(V.currencyOf('ru-mow').code, 'USD', 'a region without a table currency types US cents');
V.setRegion(''); assert.equal(V.money(1.5), '£1.50', 'no region chosen: pounds, as before');
});
test('the rent line reads the bench log: the measured USD per MH/s-hour in the app is the bench log\'s number', (t) => {
if (!existsSync(benchLog)) { t.skip('docs/bench-log.md is not in this tree'); return; }
const log = readFileSync(benchLog, 'utf8');
const section = log.slice(log.lastIndexOf('## Rental cost of hash'));
assert.ok(section.length > 0, 'the bench log carries a Rental cost of hash entry');
const m = section.match(/USD ([0-9.]+) per MH\/s-hour/);
assert.ok(m, 'the entry states USD x per MH/s-hour');
assert.equal(V.RENT.usd_per_mhs_hour, parseFloat(m[1]), 'the app carries the bench log\'s measured rate; update RENT when the log moves');
const date = section.match(/## Rental cost of hash, ([0-9]+ [A-Za-z]+ [0-9]{4})/);
assert.ok(date, 'the entry is dated');
assert.equal(V.RENT.measured, date[1]);
});
test('the cost-against-rent line: a UK card at the Ofgem price is about 10x cheaper than rented hash (future.md 3.4)', () => {
V.setRegion('gb');
// 3.27 W per MH/s at 26.32 p: 0.0861 p per MH/s-hour, about USD 0.00114, 10x under 0.0117
const r = V.rentLine([card({ power_w: 327, hash_now: 100 })], 26.32, 'gb');
assert.equal(r.kind, 'line');
assert.ok(Math.abs(r.cost - 0.0861) < 0.001, r.cost);
assert.ok(Math.abs(r.usd - 0.00114) < 0.0001, r.usd);
assert.equal(r.text, '0.086 p per MH/s-hour');
assert.ok(r.ratio > 9.5 && r.ratio < 11, r.ratio);
assert.ok(r.sub.includes('Rented hash: USD 0.0117 per MH/s-hour'), r.sub);
assert.ok(r.sub.includes('6 October 2026'), r.sub);
assert.ok(r.verdict.startsWith('Yours is 10'), r.verdict);
assert.ok(r.sub.includes('about USD 0.0011'), r.sub);
});
test('the cost-against-rent line says when rented hash undercuts the card, and what it needs when it cannot say', () => {
// a 3080-class card at 12 GB and a dear tariff: 6 W per MH/s at 60 c (euro) = 0.36 c = USD 0.0039: still under the rent
const under = V.rentLine([card({ power_w: 300, hash_now: 50 })], 60, 'de');
assert.equal(under.kind, 'line');
assert.ok(under.ratio > 2.5 && under.ratio < 3.5, under.ratio);
// the day idle datacentre hash sets the rent at 0.00016 the line flips (the table in future.md 3.4): here, a card at
// 20 W per MH/s on 100 c power = 2 c = USD 0.0216, dearer than the rent
const dear = V.rentLine([card({ power_w: 400, hash_now: 20 })], 100, 'us');
assert.equal(dear.kind, 'line');
assert.ok(dear.ratio < 1, dear.ratio);
assert.ok(dear.verdict.startsWith('Rented hash undercuts your card by'), dear.verdict);
assert.equal(dear.sub.includes('about USD'), false, 'a dollar region needs no conversion');
// no price: the W per MH/s and the ask
const ask = V.rentLine([card()], 0, 'gb');
assert.equal(ask.kind, 'price');
assert.ok(ask.sub.includes('W per MH/s'), ask.sub);
// not mining and never tuned: nothing to say, the rent still stated
const none = V.rentLine([card({ state: 'off', hash_now: 0, power_w: 0 })], 26.32, 'gb');
assert.equal(none.kind, 'none');
assert.ok(none.sub.includes('USD 0.0117'));
// not mining but tuned: the tune's point stands in, and the line says so
const tuned = V.rentLine([card({ state: 'off', hash_now: 0, power_w: 0, sweep_watts: 290, sweep_mhs: 122.3 })], 26.32, 'gb');
assert.equal(tuned.kind, 'line');
assert.ok(tuned.sub.includes('(the last tune)'), tuned.sub);
});
test('the heat words: the strip line names the set point, the room, the share of the hour and the watts', () => {
const heating = { on: true, phase: 'heating', set_c: 20, room_c: 19.6, room_source: 'typed', duty: 0.6, duty_hour: 0.55, heat_w: 410, full_w: 410, until_s: 300 };
assert.deepEqual(V.heatLine(heating), { text: 'Heat mode: holding 20.0 °C · room 19.6 °C · heating 55% of the time · 410 W of heat', tone: 'heat' });
const resting = { ...heating, phase: 'resting', heat_w: 0, room_source: 'card', room_c: 19.2 };
assert.deepEqual(V.heatLine(resting), { text: 'Heat mode: holding 20.0 °C · room about 19 °C · heating 55% of the time · resting, 410 W when heating', tone: 'rest' });
assert.equal(V.heatLine({ on: false }).text, '');
assert.ok(V.heatLine({ ...heating, room_source: 'none', duty_hour: 0 }).text.includes('no room reading yet · heating 60% of the time'));
assert.equal(V.heatRowWords(heating), 'heat 55% · 410 W of heat');
assert.equal(V.heatRowWords(resting), 'heat 55% · resting');
assert.equal(V.heatRowWords({ on: false }), '');
});
test('a resting card and the big button say heat mode, never a bare off', () => {
const r = V.cardRow(card({ state: 'resting', hash_now: 0, message: 'resting: the room is 20.1 °C, holding 20.0 °C (heat mode)' }));
assert.equal(r.word, 'resting (heat mode)');
assert.equal(r.sub, 'resting: the room is 20.1 °C, holding 20.0 °C (heat mode)', 'the engine\'s line is the row\'s second line');
assert.equal(r.hash, '');
const m = { state: 'waiting', paused: false, cards: [card({ state: 'resting', hash_now: 0 })], found: [] };
const t = V.toggle(m, { synced: true }, { heat: { on: true, phase: 'resting', set_c: 20, until_s: 240 } });
assert.equal(t.label, 'Stop mining');
assert.equal(t.sub, 'resting · heat mode holds 20.0 °C, heats again in 4 min');
assert.equal(t.act, 'pause');
assert.deepEqual(V.pill({ setup_done: true, mining: m, node: { state: 'synced' }, heat: { on: true, phase: 'resting' } }), { text: 'Resting · heat mode', tone: '' });
const e = V.ember(card({ tune_before_watts: 0 }), { settings: { sweep: true }, heat: { on: true, phase: 'heating', duty: 0.5, duty_hour: 0.5, heat_w: 410 } }, 1);
assert.equal(e.heat, 'heat 50% · 410 W of heat');
});

View file

@ -104,7 +104,7 @@
<!-- 2. cards -->
<section class="screen" id="screen-cards">
<div class="step">
<div class="eyebrow">step 1 of 2</div>
<div class="eyebrow">step 1 of 3</div>
<h2>Your graphics card</h2>
<p class="sub" id="cards-sub">Asking the graphics cards to report in.</p>
<div class="cards" id="cards-list">
@ -126,10 +126,36 @@
</div>
</section>
<!-- 3. address -->
<!-- 3. region and price (Ember Heat, mission item 7): the region list, a typed price never fetched, the restricted line -->
<section class="screen" id="screen-region">
<div class="step">
<div class="eyebrow">step 2 of 3</div>
<h2>Your electricity</h2>
<p class="sub">Every £ figure in the app comes from the price you type here. Nothing is fetched.</p>
<div class="field">
<div class="k">region</div>
<select class="addr-input select" id="region-select" aria-label="Region"></select>
</div>
<div class="field">
<div class="k">price per kWh</div>
<div class="row">
<input type="number" class="addr-input mono short" id="region-price" min="0" max="500" step="0.01" placeholder="26.32" aria-label="Electricity price per kWh"><span class="note" id="region-unit">pence per kWh</span>
</div>
<p class="help" id="region-note">Choose a region to see a typical price.</p>
</div>
<p class="restricted" id="region-restricted" hidden></p>
<div class="cta">
<button class="btn primary" id="btn-region-next">Continue</button>
<button class="btn ghost" id="btn-region-back">Back</button>
<button class="btn ghost" id="btn-region-skip">Skip for now</button>
</div>
</div>
</section>
<!-- 4. address -->
<section class="screen" id="screen-address">
<div class="step">
<div class="eyebrow">step 2 of 2</div>
<div class="eyebrow">step 3 of 3</div>
<h2>Where should rewards go?</h2>
<p class="sub">Every block this machine finds pays one address. Pick one way.</p>
<div class="options">
@ -160,7 +186,7 @@
</div>
</section>
<!-- 4. the dashboard: four pages behind the rail -->
<!-- 5. the dashboard: four pages behind the rail -->
<section class="screen" id="screen-dashboard">
<!-- Overview: the fleet hero, the chain scene, the node line, activity -->
@ -310,6 +336,7 @@
<button class="btn small" id="btn-tune-all" hidden>Tune all</button>
</div>
<p class="line-text" id="tune-schedule"></p>
<p class="line-text heat-line" id="heat-line" hidden></p>
<label class="switch" id="m-power-row" hidden><input type="checkbox" id="m-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once.</span></span></label>
</div>
<div class="card">
@ -332,6 +359,7 @@
<div class="money-row price-row"><span class="v small" id="e-price">Type a price</span><span class="s" id="e-price-sub"></span><span class="row"><span class="mono dim">£</span><input type="number" class="addr-input mono short" id="e-price-input" min="0" step="0.0001" placeholder="0.00" aria-label="Your price for one IGN in pounds"><button class="btn tiny" id="e-price-save">Use it</button></span></div>
<div class="money-row"><span class="v tiny" id="e-share">reading</span><span class="s" id="e-share-sub"></span></div>
<div class="money-row"><span class="v small" id="e-cost">£0.00 a day</span><span class="s" id="e-cost-sub">electricity</span><span class="s" id="e-kwh"></span><button class="btn tiny ghost" id="e-price-btn">Set the price</button></div>
<div class="money-row rent" id="e-rent-row"><span class="v small" id="e-rent">Your cost of hash</span><span class="s" id="e-rent-sub">against the measured rental rate</span></div>
<div class="money-row"><span class="v small" id="e-blocks">0 blocks</span><span class="s" id="e-blocks-sub">lifetime</span><span class="s" id="e-ign-sub"></span></div>
</div>
<label class="switch"><input type="checkbox" id="s-devfee"><span class="track"></span><span class="sw-text" id="s-devfee-text">Dev fee: 1 block in 100 pays the people who make this app</span></label>
@ -441,14 +469,46 @@
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span class="sw-text"><b>Ember Tune</b> <span class="dim">Tunes every card for the most hashes per watt. Once after install, then every 7 days, and after a driver or program change.</span></span></label>
<label class="switch"><input type="checkbox" id="s-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once. Off, NVIDIA cards are only measured.</span><span class="dim" id="s-power-note"></span></span></label>
<label class="switch" id="s-climb-row" hidden><input type="checkbox" id="s-climb"><span class="track"></span><span class="sw-text"><b>Fine tuning</b> <span class="dim">Searches around the best point instead of stepping down a ladder.</span></span></label>
<p class="line-text" id="s-tune-schedule"></p>
</div>
<div class="card">
<div class="card-head"><h3>Electricity</h3><span class="eyebrow">typed, never fetched</span></div>
<div class="field">
<div class="k">electricity price</div>
<div class="k">region</div>
<select class="addr-input select" id="s-region" aria-label="Region"></select>
</div>
<div class="field">
<div class="k">price per kWh</div>
<div class="row">
<input type="number" class="addr-input mono short" id="s-price" min="0" max="200" step="0.1" placeholder="28" aria-label="Electricity price in pence per kWh"><span class="note">pence per kWh. Every £ figure uses this.</span>
<input type="number" class="addr-input mono short" id="s-price" min="0" max="500" step="0.01" placeholder="26.32" aria-label="Electricity price per kWh"><span class="note" id="s-price-unit">pence per kWh</span>
<button class="btn small" id="s-price-save">Save</button>
</div>
<p class="help" id="s-price-note"></p>
</div>
<p class="restricted" id="s-region-restricted" hidden></p>
</div>
<div class="card">
<div class="card-head"><h3>Heat mode</h3><span class="eyebrow">Ember Heat</span></div>
<label class="switch"><input type="checkbox" id="s-heat"><span class="track"></span><span class="sw-text"><b>Hold a room temperature</b> <span class="dim">The cards heat for a share of every 10 minutes and rest for the rest. The hash follows. In heat mode your card is an electric heater that also earns; whether it beats your boiler depends on the network and the price, shown live.</span></span></label>
<p class="line-text" id="s-heat-line"></p>
<div class="row wrap">
<div class="field">
<div class="k">hold</div>
<div class="row"><input type="number" class="addr-input mono short" id="s-heat-set" min="5" max="30" step="0.5" placeholder="19" aria-label="Set point in degrees"><span class="note">°C</span></div>
</div>
<div class="field grow">
<div class="k">schedule</div>
<div class="row"><input type="text" class="addr-input mono" id="s-heat-schedule" placeholder="06:00 20, 22:00 16" spellcheck="false" autocomplete="off" aria-label="Schedule"><button class="btn small" id="s-heat-save">Save</button></div>
</div>
</div>
<p class="help">Blank holds one temperature all day. A schedule is a list of times and temperatures; each holds until the next. Times are this window's clock.</p>
<div class="field">
<div class="k">room now</div>
<div class="row"><input type="number" class="addr-input mono short" id="s-heat-room" min="-20" max="50" step="0.1" placeholder="19.5" aria-label="Room temperature now"><span class="note">°C from your own thermometer</span><button class="btn small" id="s-heat-room-save">Use it</button></div>
<p class="help" id="s-heat-room-note">A reading you type is the room for two hours. Without one the card's own sensor reads the room after 3 minutes of rest, within about 3 degrees. Typing a reading while the cards rest teaches the app the card's idle offset.</p>
</div>
<p class="line-text" id="s-tune-schedule"></p>
</div>
<div class="card">

View file

@ -23,6 +23,8 @@
// ladder miner-ui-5: every rung walked (vote, signature, full window, rank 3, streak, a paid shard)
// 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.
// 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.
import http from 'node:http';
@ -185,6 +187,15 @@ function stateNow() {
if (scenario === 'clock') { s.clock = { ...s.clock, severity: 'block', skew_s: -412, message: 'This machine\'s clock is 6 min 52 s behind the network. Blocks it finds are rejected until the clock is right.', hint: 'System Settings > General > Date & Time: turn on "Set time and date automatically"' }; s.mining.state = 'waiting'; s.mining.hash_total = 0; }
if (scenario === 'fresh') { s.setup_done = false; s.phase = 'welcome'; s.address = { display: '', key_saved: false, source: '', value: '', wallet_file: '' }; }
if (scenario === 'job') { s.jobs = { ...s.jobs, active: true, id: 'job-2026-10-04-17', kind: 'shard-benchmark', title: 'shard benchmark', stage: 'proving shard 3 of 8', started_at: t - 400, results: ['RESULT shard=1 prove_s=41.2', 'RESULT shard=2 prove_s=39.8'], message: '' }; }
if (scenario === 'heat' || scenario === 'heat-rest') {
const resting = scenario === 'heat-rest';
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: resting ? 22 : 290, temp_gpu: resting ? 31 : 64, temp_mem: resting ? 36 : 78, telemetry_at: t, hash_now: resting ? 0 : 122.3, hash_avg: 121.8, state: resting ? 'resting' : 'mining', message: resting ? 'resting: the room is 20.1 °C, holding 20.0 °C (heat mode)' : '', tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'full', tune_control: true, tune_clock_mhz: 2470, sweep_pct: 80, sweep_at: t - 7200, sweep_watts: 290, sweep_mhs: 122.3, sweep_eff: 0.422, tune_before_watts: 374, tune_before_mhs: 122.5, tune_curve: [{ clock_mhz: 0, power_pct: 100, watts: 374, mhs: 122.5, eff: 0.328 }, { clock_mhz: 0, power_pct: 90, watts: 340, mhs: 122.4, eff: 0.36 }, { clock_mhz: 0, power_pct: 80, watts: 302, mhs: 122.3, eff: 0.405 }, { clock_mhz: 2470, power_pct: 80, watts: 290, mhs: 122.3, eff: 0.422 }] },
{ ...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: true, power_limit_w: 160, power_w: resting ? 12 : 158, temp_gpu: resting ? 29 : 58, temp_mem: 0, telemetry_at: t, hash_now: resting ? 0 : 61.4, hash_avg: 60.9, state: resting ? 'resting' : 'mining', message: resting ? 'resting: the room is 20.1 °C, holding 20.0 °C (heat mode)' : '', tune_line: 'Tuned: 61.4 MH/s at 158 W (0.389 MH/W)', tune_source: 'confirm', tune_control: true, sweep_pct: 80, sweep_at: t - 3600, sweep_watts: 158, sweep_mhs: 61.4, sweep_eff: 0.389 }];
s.mining.hash_total = resting ? 0 : 183.7; s.mining.state = resting ? 'waiting' : 'mining'; s.display_name = 'study-pc';
s.settings = { ...s.settings, sweep: true, power_control: true, tune_goal: 'balanced', power_price_pence: 26.32, tune_climb: false, tune_period_s: 604800, region: 'gb', heat_on: true, heat_set_c: 20, heat_schedule: '06:00 20, 22:00 16', heat_tz_min: 60, heat_room_c: 19.6, heat_room_at: t - 2400, heat_offset_c: 7.5 };
s.heat = { on: true, phase: resting ? 'resting' : 'heating', set_c: 20, room_c: resting ? 20.1 : 19.6, room_source: 'typed', room_error_c: 0, room_age_s: 2400, duty: 0.6, duty_hour: 0.58, heat_w: resting ? 0 : 448, full_w: 448, heat_avg_w: 269, until_s: resting ? 230 : 312, note: resting ? 'Holding 20.0 °C: room 20.1 °C from your reading 40 min ago. resting, heats again in 4 min. Heat 60% of the time.' : 'Holding 20.0 °C: room 19.6 °C from your reading 40 min ago. heating, 5 min to go. Heat 60% of the time.', period_s: 600, offset_c: 7.5, offset_learned: true };
s.events = [{ kind: 'info', t: t - 312, text: 'heat mode: heating again' }, { kind: 'info', t: t - 2400, text: 'heat mode on: holding 20.0 °C (schedule 06:00 20, 22:00 16); the cards heat for a share of every 10 minutes and rest for the rest' }].concat(s.events).slice(0, 40);
}
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 },