miner-ui-3 polish: the strip, the feed and the pill in the app's voice; three Mine layouts to pick from

No raw job, manifest or relay text on a user surface: the job strip reads 'A job from the team ran: update check,
0 min.' with the technical line behind a chevron; 'Updated to 0.3.14 at 18:52.'; the Activity feed maps every engine
event string to a sentence (View.plainEvent, 80 patterns, the engine line as the tooltip) with a kind dot. The header
puts the title, subtitle and pill on one baseline; the pill is a sentence with a coloured dot (Mining · 511 MH/s,
Paused, Syncing the node, Node restarting, Engine not answering). Three Mine layouts behind ?variant=a|b|c (quieter,
denser, hero number) for the project lead to pick. 390 px strip on one line. 36 UI tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 18:01:47 +00:00
parent b603e0ef0f
commit 40528b4cb5
18 changed files with 269 additions and 38 deletions

View file

@ -99,13 +99,15 @@ body.has-rail .top{left:var(--rail)}
.brand{display:flex;align-items:center;gap:10px;min-width:0}
.brand .word{font-family:var(--head);font-weight:900;font-size:20px;letter-spacing:.06em}
.brand .miner{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.22em;color:var(--ash);margin-left:var(--s-1);padding-top:3px}
body.has-rail .top{align-items:baseline;padding-top:19px}
.page-title{display:flex;align-items:baseline;gap:12px;min-width:0}
.page-title h1{font-size:19px;font-weight:700;letter-spacing:0;white-space:nowrap}
.page-sub{font-size:var(--t-base);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.top-right{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto;min-width:0}
.pill{display:inline-flex;align-items:center;gap:var(--s-2);font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:6px 12px 6px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;min-width:112px;justify-content:center}
.pill.on{color:var(--molten);border-color:var(--molten-40)}
.pill.warn{color:var(--ember)}
.pill{display:inline-flex;align-items:baseline;gap:var(--s-2);font-family:var(--sans);font-size:var(--t-base);font-weight:600;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:5px 12px 5px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;justify-content:center;line-height:1.3}
.pill .dot{position:relative;top:-1px}
.pill.on{color:var(--molten);border-color:var(--molten-40);background:var(--molten-10)}
.pill.warn{color:var(--ember);border-color:var(--ember-40);background:var(--ember-12)}
.dot{width:8px;height:8px;border-radius:50%;background:var(--ash);display:inline-block;flex:0 0 8px}
.on .dot,.dot.live{background:var(--molten);animation:pulse 2s ease-in-out infinite}
.warn .dot,.dot.bad{background:var(--ember);animation:none}
@ -126,7 +128,11 @@ body.has-rail .notices{left:var(--rail)}
.notice-close{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--ash);font-size:20px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}
.notice-close:hover{color:var(--bone);border-color:var(--line-2)}
.notice.update-urgent .notice-close{color:#fff}
.notice-detail{flex-basis:100%;font-size:var(--t-sm);color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:-3px}
.notice-more{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--ash);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}
.notice-more:hover{color:var(--bone);border-color:var(--line-2)}
.notice-more .chev{width:8px;height:8px;transform:rotate(45deg) translateY(-2px)}
.notice-more.open .chev{transform:rotate(225deg) translateY(-2px)}
.notice-detail{flex-basis:100%;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);white-space:normal;word-break:break-word;margin-top:-2px;user-select:text;-webkit-user-select:text}
.notice .prog{flex-basis:100%;height:3px;background:rgba(127,127,127,.2);border-radius:2px;overflow:hidden;margin-top:-3px}
.notice .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear}
@ -246,9 +252,10 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.feed>div:last-child{border-bottom:0}
.feed>div>span:first-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)}
.feed .k{color:var(--molten);margin-right:6px}
.feed .k.error,.feed .k.warn,.feed .k.block{color:var(--ember)}
.feed .k.build,.feed .k.info{color:var(--ash)}
.feed .k{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--molten);margin-right:10px;position:relative;top:-1px}
.feed .k.error,.feed .k.warn,.feed .k.block{background:var(--ember)}
.feed .k.build,.feed .k.info{background:var(--line-2)}
.feed .k.proving{background:var(--nvidia)}
/* the card rows on Mine (the hero object): badge, name and state, the numbers, Tune, the switch, the chevron;
the tune line under; the details under that */
@ -590,6 +597,8 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
.step{padding:32px 0}
.money-row .v{font-size:var(--t-num)}
.drawer-head{padding-left:var(--s-4);padding-right:var(--s-4)}
.notice{padding-left:var(--s-4);padding-right:var(--s-2);gap:var(--s-2)}
.notice-text{flex-basis:160px}
.log,.log-ruler .t0,.log-ruler .t1{padding-left:var(--s-4);padding-right:var(--s-4)}
}
/* short windows (the 600 px floor): tighter paddings, a smaller canvas, so the drawer always has room */
@ -605,3 +614,59 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
.drawer-head{padding-bottom:6px}
.toggle-big{min-height:96px}
}
/* variant A, quieter: fewer borders, type carries the hierarchy */
body[data-variant="a"] .card{border-color:transparent;padding:28px}
body[data-variant="a"] .card h3{font-size:20px}
body[data-variant="a"] .gpu-list{gap:0}
body[data-variant="a"] .gpu-line{border:0;border-radius:0;background:transparent;border-bottom:1px solid var(--line)}
body[data-variant="a"] .gpu-line:last-child{border-bottom:0}
body[data-variant="a"] .gl-main{padding:16px 0}
body[data-variant="a"] .gl-tune,body[data-variant="a"] .gl-details{border-top:0;padding-left:60px;padding-right:0;padding-top:0}
body[data-variant="a"] .gl-details{padding-bottom:16px}
body[data-variant="a"] .totals{border-color:transparent}
body[data-variant="a"] .toggle-big.stop{border-color:transparent}
body[data-variant="a"] .tot{border-left:0}
body[data-variant="a"] .num .v{font-size:22px}
body[data-variant="a"] .seg{border-color:transparent}
body[data-variant="a"] #dag{border:0}
/* variant B, denser: one line per card, numbers right-aligned in fixed tabular columns, a slim total bar */
body[data-variant="b"] .hero-row{grid-template-columns:auto 1fr;gap:var(--s-3)}
body[data-variant="b"] .toggle-big{min-height:64px;flex-direction:row;align-items:center;gap:14px;padding:12px 18px}
body[data-variant="b"] .toggle-big .ring{margin:0}
body[data-variant="b"] .toggle-big .ts{display:none}
body[data-variant="b"] .totals{padding:10px 20px;align-items:center}
body[data-variant="b"] .tot .v{font-size:22px}
body[data-variant="b"] .tot .k{display:inline;margin:0 0 0 6px}
body[data-variant="b"] .tot .s{margin-top:0}
body[data-variant="b"] .card{padding:16px 20px}
body[data-variant="b"] .card-head{margin-bottom:8px}
body[data-variant="b"] .gpu-list{gap:0;border:1px solid var(--line);border-radius:var(--row-r);overflow:hidden}
body[data-variant="b"] .gpu-line{border:0;border-radius:0;border-top:1px solid var(--line)}
body[data-variant="b"] .gpu-line:first-child{border-top:0}
body[data-variant="b"] .gl-main{padding:8px 12px;gap:var(--s-3);grid-template-columns:32px minmax(140px,1fr) auto auto}
body[data-variant="b"] .badge{width:32px;height:32px;flex-basis:32px;border-radius:8px;font-size:9px}
body[data-variant="b"] .gpu-line .name{font-size:var(--t-base)}
body[data-variant="b"] .gpu-line .msg{display:none}
body[data-variant="b"] .nums{gap:0;flex-wrap:nowrap}
body[data-variant="b"] .num{min-width:92px;padding-left:8px}
body[data-variant="b"] .num .v{font-size:16px;font-family:var(--mono);font-weight:500}
body[data-variant="b"] .num .k{font-size:9px}
body[data-variant="b"] .gl-tune{padding:0 12px 6px 56px;border-top:0;font-size:var(--t-xs)}
body[data-variant="b"] .gl-tune .bar{height:2px}
body[data-variant="b"] .gl-details{padding-left:56px}
body[data-variant="b"] #dag{height:72px}
body[data-variant="b"] .feed>div{padding:4px 0}
/* variant C, the hero number: the fleet total big, the button a bar under it, the cards below */
body[data-variant="c"] .hero-row{grid-template-columns:1fr}
body[data-variant="c"] .totals{order:1;grid-template-columns:2fr 1fr 1fr 1fr;align-items:end;padding:28px 28px 24px;border-color:transparent;background:linear-gradient(180deg,var(--graphite),var(--obsidian))}
body[data-variant="c"] .tot:first-child .v{font-size:84px;line-height:.95;letter-spacing:-.03em}
body[data-variant="c"] .tot:first-child .k{font-size:var(--t-sm);margin-top:10px}
body[data-variant="c"] .tot:first-child .s{font-size:var(--t-md)}
body[data-variant="c"] .tot .v{font-size:30px}
body[data-variant="c"] .tot{border-left:0}
body[data-variant="c"] .toggle-big{order:2;min-height:64px;flex-direction:row;align-items:center;gap:14px;padding:12px 20px}
body[data-variant="c"] .toggle-big .ring{margin:0}
body[data-variant="c"] .toggle-big .ts{margin-left:auto}
body[data-variant="c"] .gl-main{padding:10px 14px}
body[data-variant="c"] .num .v{font-size:18px}

View file

@ -49,12 +49,27 @@ var Notices = (function () {
}
if (u.updated_from && s.uptime_s >= 0 && s.uptime_s < INSTALLED_S) {
var now = s.version || ver;
return notice('update-installed', 'update:installed:' + now, 'Updated to Igneum Miner ' + now + ' from ' + u.updated_from + '.');
var at = s.now > 0 && s.uptime_s >= 0 ? ' at ' + hhmm(s.now - s.uptime_s) : '';
return notice('update-installed', 'update:installed:' + now, 'Updated to ' + now + at + '.', { title: 'Igneum Miner ' + now + ', from ' + u.updated_from });
}
return null;
}
function jobTitle(j) { return j.title && j.title !== j.kind ? j.title : (j.kind === 'shard-benchmark' ? 'shard benchmark' : j.kind === 'build' ? 'build (mining continues)' : j.kind); }
// the job in a user's words (miner-ui-3 polish, 6 October 2026): from its kind, else from its id's prefix
function jobWord(kind, id) {
var k = String(kind || ''), i = String(id || '');
var t = k || i;
if (/^update-now/.test(t) || /^update-now/.test(i)) return 'update check';
if (/^restart/.test(t) || /^restart/.test(i)) return 'restart';
if (/^collect/.test(t) || /^collect/.test(i)) return 'log collection';
if (/shard-benchmark|^shard/.test(t) || /shard/.test(i)) return 'shard benchmark';
if (/^build/.test(t) || /^build/.test(i)) return 'build';
if (/ember|tune|sweep/.test(t) || /ember|tune|sweep/.test(i)) return 'tuning check';
if (/script|playbook|run/.test(t)) return 'a script';
return k || 'a task';
}
function hhmm(t) { var d = new Date(t * 1000); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); }
function firstErrorLine(results, summary) {
for (var i = 0; i < results.length; i++) if (/BUILD FAILED|error|failed|panic/i.test(results[i])) return firstLine(results[i]);
return firstLine(summary);
@ -65,18 +80,20 @@ var Notices = (function () {
if (j.active) {
var m = Math.max(0, Math.floor((now - j.started_at) / 60));
var results = (j.results || []).filter(function (x) { return x.indexOf('RESULT') === 0; });
return notice('job-running', 'job:run:' + j.id, 'Job: ' + jobTitle(j) + ' running, ' + m + ' min.' + (j.stage ? ' ' + endDot(cap(j.stage)) : ''), { detail: results.length ? results[results.length - 1] : '', title: j.message || '' });
var tech = [jobTitle(j), results.length ? results[results.length - 1] : ''].filter(Boolean).join(' · ');
return notice('job-running', 'job:run:' + j.id, 'A job from the team is running: ' + jobWord(j.kind, j.id) + ', ' + m + ' min.' + (j.stage ? ' ' + endDot(cap(j.stage)) : ''), { detail: tech, title: j.message || '' });
}
var l = j.last;
if (!l || !l.id) return null;
var mins = Math.max(0, Math.round((l.finished_at - l.started_at) / 60)), t = l.title || l.kind;
var report = l.uploaded ? 'Report uploaded.' : 'Report not uploaded.';
var report = l.uploaded ? '' : ' The report did not upload.', word = jobWord(l.kind, l.id), tech = t + (l.summary ? ': ' + l.summary : '');
if (l.status === 'done') {
if (l.finished_at && now - l.finished_at > JOB_DONE_S) return null;
return notice('job-done', 'job:done:' + l.id, 'Job: ' + t + ' done after ' + mins + ' min. ' + report, { title: l.summary || '' });
return notice('job-done', 'job:done:' + l.id, 'A job from the team ran: ' + word + ', ' + mins + ' min.' + report, { detail: tech, title: l.summary || '' });
}
var word = l.status === 'timeout' ? 'hit its time cap' : l.status === 'aborted' ? 'was stopped' : 'failed';
return notice('job-failed', 'job:failed:' + l.id, 'Job: ' + t + ' ' + word + ' after ' + mins + ' min, exit ' + l.exit + '. ' + report, { tone: 'bad', detail: firstErrorLine(j.last_results || [], l.summary), title: l.summary || '' });
var verb = l.status === 'timeout' ? 'ran out of time' : l.status === 'aborted' ? 'was stopped' : 'failed';
var cause = firstErrorLine(j.last_results || [], l.summary);
return notice('job-failed', 'job:failed:' + l.id, 'A job from the team ' + verb + ': ' + word + ', after ' + mins + ' min.' + report, { tone: 'bad', detail: [tech, cause !== l.summary ? cause : ''].filter(Boolean).join(' · ') + ' · exit ' + l.exit, title: l.summary || '' });
}
// c = state.clock: the engine's words, Sync clock, the manual hint underneath
@ -136,7 +153,7 @@ var Notices = (function () {
}
return best;
}
return { tuneNotice: tuneNotice, LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord };
return { tuneNotice: tuneNotice, jobWord: jobWord, hhmm: hhmm, LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord };
})();
/* ---------- the update card (pure; update-card.test.mjs loads this block) ----------
@ -539,10 +556,111 @@ var View = (function () {
if (text.length > max) { var cut = text.lastIndexOf(' ', max - 2); text = text.slice(0, cut > 40 ? cut : max - 2) + '…'; }
return text;
}
// the engine's event line in a user's voice (src/engine.rs event strings, 6 October 2026): {text, tech}; tech is
// the engine's own line when it differs, for the tooltip. Unknown lines keep their words with a JSON tail cut.
var EVENT_MAP = [
[/^updated to Igneum Miner (\S+) from (\S+)/, function (m) { return 'Updated to ' + m[1]; }],
[/^Igneum Miner (\S+) is available: downloading/, function (m) { return 'Downloading ' + m[1]; }],
[/^Igneum Miner (\S+) is ready/, function (m) { return m[1] + ' is ready to install'; }],
[/^installing Igneum Miner (\S+)/, function (m) { return 'Installing ' + m[1]; }],
[/^update to (\S+) failed/, function (m) { return 'The update to ' + m[1] + ' failed'; }],
[/^the update to (\S+) did not take/, function (m) { return 'The update to ' + m[1] + ' did not take'; }],
[/^Igneum Miner (\S+) did not stay up twice; restoring (\S+)/, function (m) { return m[1] + ' did not stay up; going back to ' + m[2]; }],
[/^update downloaded/, function () { return 'Update downloaded'; }],
[/^OTA: administrator approval not given/, function () { return 'The update waits for the next time someone is at this PC'; }],
[/^job (\S+) \((.+)\) starts: /, function (m) { return 'A job from the team started: ' + Notices.jobWord(m[2], m[1]); }],
[/^job (\S+) \((.+)\) (done|failed|timeout|aborted) after (\d+) min/, function (m) { return 'A job from the team ' + (m[3] === 'done' ? 'ran' : m[3] === 'timeout' ? 'ran out of time' : m[3] === 'aborted' ? 'was stopped' : 'failed') + ': ' + Notices.jobWord('', m[1]) + ', ' + m[4] + ' min'; }],
[/^(\d+) remote jobs? received from Igneum/, function (m) { return m[1] + ' job' + (m[1] === '1' ? '' : 's') + ' from the team received'; }],
[/^remote job: update check now/, function () { return 'The team asked for an update check'; }],
[/^remote job: the app restarts/, function () { return 'The team asked the app to restart'; }],
[/^remote job asked for a node restart, but the node is external/, function () { return 'The team asked for a node restart; this node is not the app’s, so nothing was done'; }],
[/^remote jobs allowed/, function () { return 'Jobs from the team can run here'; }],
[/^remote jobs switched off/, function () { return 'Jobs from the team are off'; }],
[/^job finished; the miners restart/, function () { return 'The job finished; mining restarts'; }],
[/^node synced: (\d+) blocks, (\d+) peer/, function (m) { return 'Node synced: ' + withCommas(+m[1]) + ' blocks, ' + m[2] + ' peer' + (m[2] === '1' ? '' : 's'); }],
[/^a node already answers on/, function () { return 'Using the node already running on this machine'; }],
[/^consensus parameters from the signed manifest/, function () { return 'Rule settings read from the team'; }],
[/^this node build does not understand the consensus parameters/, function () { return 'This node is too old for the team’s rule settings; an app update carries the newer node'; }],
[/^restarting the node with the new consensus parameters/, function () { return 'Restarting the node with the new rule settings'; }],
[/^kernel tuning from the signed manifest/, function () { return 'Tuning starting points read from the team'; }],
[/^block accepted by the node \((.+)\)/, function (m) { return 'Block found by ' + m[1]; }],
[/^igneumd exited with code (\d+) after (\d+) s; restarting/, function (m) { return 'The node stopped (code ' + m[1] + '); restarting'; }],
[/^igneumd exited at once \(code (\d+)\)/, function (m) { return 'The node stopped at once (code ' + m[1] + ')'; }],
[/^igneumd could not start/, function () { return 'The node could not start'; }],
[/^miner (\S+) exited with code (\d+); restarting in (\d+) s/, function (m) { return 'A worker stopped (code ' + m[2] + '); restarting in ' + m[3] + ' s'; }],
[/^no reading from the node for (\d+) s/, function (m) { return 'No reading from the node for ' + m[1] + ' s; restarting it'; }],
[/^no peer after 90 s/, function () { return 'No other node found after 90 s; still looking'; }],
[/^no payout address/, function () { return 'No rewards address yet: finish the setup'; }],
[/^mining paused; the node keeps running/, function () { return 'Mining paused; the node keeps running'; }],
[/^mining resumed/, function () { return 'Mining resumed'; }],
[/^GPU power cap in force: (.+)/, function (m) { return 'Power cap in force: ' + m[1]; }],
[/^GPU power cap NOT applied/, function () { return 'Power cap not applied: the administrator prompt was refused (Retry on the card)'; }],
[/^power control on:/, function () { return 'Power control on'; }],
[/^rewards go to (\S+)/, function (m) { return 'Rewards go to ' + shortHex(m[1], 6, 4); }],
[/^settings changed \((.+)\); the miners restart/, function (m) { return 'Settings changed (' + m[1] + '); mining restarts'; }],
[/^(.+): tune queued; it starts once/, function (m) { return m[1] + ': tune queued'; }],
[/^(.+): tuning started: /, function (m) { return m[1] + ': tuning started'; }],
[/^(.+): best point (.+?) \((.+?)\); your setting stays pinned/, function (m) { return m[1] + ': best point ' + m[2] + ' (' + m[3] + '); your cap stays pinned'; }],
[/^(.+): measured as it runs: (.+)/, function (m) { return m[1] + ': measured ' + m[2]; }],
[/^(.+): no tune: (.+)/, function (m) { return m[1] + ': no tune (' + m[2] + ')'; }],
[/^(.+): worker fault: (.+); the miner restarts the worker/, function (m) { return m[1] + ': worker fault; restarting it'; }],
[/^(.+): the worker could not compile the next program/, function (m) { return m[1] + ': the next program compiles at the hour boundary instead'; }],
[/^(.+) worker ready/, function (m) { return m[1] + ': worker ready'; }],
[/^(.+) kernel race: (.+?) at ([\d.]+) MH\/s/, function (m) { return m[1] + ': program variant chosen, ' + m[3] + ' MH/s'; }],
[/^(.+): switched on, (\d+) identit/, function (m) { return m[1] + ': on'; }],
[/^(.+): switched off/, function (m) { return m[1] + ': off'; }],
[/^(.+): (\d+) identit(y|ies); its worker restarts/, function (m) { return m[1] + ': ' + m[2] + ' identit' + m[3] + '; its worker restarts'; }],
[/^next hourly program compiled and ready/, function () { return 'Next hourly program ready'; }],
[/^the hourly program changed; the worker restarts/, function () { return 'New hourly program; the worker restarts on it'; }],
[/^hourly program changed: /, function () { return 'New hourly program'; }],
[/^program pack out of date, rebuilding/, function () { return 'Rebuilding the program pack'; }],
[/^GPU worker built from source/, function () { return 'Worker built'; }],
[/^block (\d+) shard (\d+) paid (.+) IGN/, function (m) { return 'Proof paid: ' + m[3] + ' IGN'; }],
[/^block (\d+) shard (\d+) proven and submitted in (\d+) s/, function (m) { return 'Proof sent in ' + m[3] + ' s'; }],
[/^segment .+ paid (.+) IGN to the aggregator/, function (m) { return 'Segment paid: ' + m[1] + ' IGN'; }],
[/^segment (\S+)\.\.(\S+) aggregated and submitted in (\d+) s/, function (m) { return 'Segment proven in ' + m[3] + ' s'; }],
[/^segment (\S+)\.\.(\S+): (\d+) shards proven, aggregated and submitted in (\d+) s/, function (m) { return 'Segment proven: ' + m[3] + ' pieces in ' + m[4] + ' s'; }],
[/^segment (\S+)\.\.(\S+): (\d+) shards proven and submitted in (\d+) s; the segment record is held/, function (m) { return 'Segment proven: ' + m[3] + ' pieces in ' + m[4] + ' s; its record waits for the chain rule'; }],
[/^segment (\S+)\.\.(\S+) record accepted on retry/, function () { return 'Segment record accepted'; }],
[/^WSL2 prover setup started/, function () { return 'Prover setup started in its own window'; }],
[/^Card removed: (.+); its worker stopped/, function (m) { return 'Card removed: ' + m[1]; }],
[/^(.+): not usable \((.+?)\); its worker stopped/, function (m) { return m[1] + ': not usable (' + m[2] + ')'; }],
[/^clock agrees with the network again/, function () { return 'Clock agrees with the network again'; }],
[/^asking the system to set its clock/, function () { return 'Setting the clock from a time server'; }],
[/^clock: (.+)/, function (m) { return 'Clock: ' + m[1]; }],
[/^devnet only: the node trusts proof records/, function () { return 'Devnet trust mode: proof records are not verified (Settings)'; }],
[/^a sweep is setting the caps right now/, function () { return 'A tune is setting the caps now; retry when it is done'; }],
[/^updates are off for this engine/, function () { return 'Updates are off for this engine (a measurement run)'; }],
[/^(.+); the node restarts$/, function (m) { return cap(m[1]) + '; the node restarts'; }],
[/^(.+); the node is external, so the app cannot restart it/, function (m) { return cap(m[1]) + '; the node is not the app’s, so it is not restarted'; }]
];
function plainEvent(text) {
text = String(text || '');
for (var i = 0; i < EVENT_MAP.length; i++) { var m = EVENT_MAP[i][0].exec(text); if (m) { var out = EVENT_MAP[i][1](m); return { text: out, tech: out === text ? '' : text }; } }
var cut = eventLine(text);
return { text: cap(cut), tech: cut === text ? '' : text };
}
// the pill in the top bar: a verb a user understands, and its tone (on = molten, bad = ember, '' = grey)
function pill(s) {
if (s.quitting) return { text: 'Stopping', tone: '' };
if (!s.setup_done) return { text: s.detecting ? 'Detecting cards' : 'Setting up', tone: '' };
var m = s.mining || {}, n = s.node || {};
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 (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' };
if (n.state === 'failed') return { text: 'Node failed', tone: 'bad' };
if (n.state === 'stopped') return { text: 'Node stopped', tone: 'bad' };
if (m.state === 'waiting') return { text: 'Starting the worker', tone: '' };
if (m.state === 'idle') return { text: 'Not mining', tone: '' };
return { text: cap(m.state || 'starting'), tone: '' };
}
// the confirmations' words (in place, never a dialog)
function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? The miner restarts.'; }
function trustAsk(on) { return on ? 'Include proof records the node never checked? Devnet only; the node restarts.' : 'Verify proof records 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, goalWords: goalWords, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, addressAsk: addressAsk, trustAsk: trustAsk };
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, goalWords: goalWords, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, addressAsk: addressAsk, trustAsk: trustAsk };
})();
/* ---------- 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
@ -589,6 +707,7 @@ if (typeof document !== 'undefined') (function () {
var forcedUpdate = params.get('update'); // a sample update state for screenshots: available|downloading|ready|waiting|applying|urgent|manual|error|updated
var forcedJob = params.get('job'); // a sample remote job for screenshots: running|done|failed
if (params.get('host') === 'mac') document.body.classList.add('mac');
if (params.get('variant')) document.body.dataset.variant = params.get('variant'); // ?variant=a|b|c: the Mine layouts of the 6 October polish round
// ?debug=1 prints a budget line every 5 s (ms of JS per second in the canvas, the state render and the log) and
// exposes the counters as window.__igneumPerf
var debug = params.get('debug') === '1';
@ -1311,7 +1430,7 @@ if (typeof document !== 'undefined') (function () {
var key = s.events.length ? s.events[0].t + ':' + s.events.length : '';
if (key !== lastEventsKey) {
lastEventsKey = key;
$('d-events').innerHTML = s.events.length ? s.events.slice(0, 8).map(function (e) { return '<div><span><span class="k ' + esc(e.kind) + '">' + esc(e.kind) + '</span>' + esc(View.eventLine(e.text)) + '</span><span class="t">' + clock(e.t) + '</span></div>'; }).join('') : '<div class="empty">No events yet. The node and the miner report here as they start.</div>';
$('d-events').innerHTML = s.events.length ? s.events.slice(0, 8).map(function (e) { var pe = View.plainEvent(e.text); return '<div' + (pe.tech ? ' title="' + esc(pe.tech) + '"' : '') + '><span><i class="k ' + esc(e.kind) + '"></i>' + esc(pe.text) + '</span><span class="t">' + clock(e.t) + '</span></div>'; }).join('') : '<div class="empty">No events yet. The node and the miner report here as they start.</div>';
}
}
// the Tuning card on Mine and the Tuning rows on Settings share the goal, the schedule and the Power control switch
@ -1442,18 +1561,10 @@ if (typeof document !== 'undefined') (function () {
else if (page === 'settings') renderSettings(s);
}
function renderPill(s) {
var pill = $('pill'), text;
var pill = $('pill'), pw = View.pill(s);
pill.classList.remove('on', 'warn');
if (s.quitting) text = 'stopping';
else if (!s.setup_done) text = s.detecting ? 'detecting' : 'set up';
else if (View.tuningCard(s.mining.cards)) { text = 'tuning'; pill.classList.add('on'); }
else if (s.mining.state === 'mining') { text = s.mining.hash_total.toFixed(1) + ' MH/s'; pill.classList.add('on'); }
else if (s.mining.state === 'paused') text = 'paused';
else if (s.node.state === 'syncing' || s.node.state === 'starting') text = 'node ' + s.node.state;
else if (s.node.state === 'failed' || s.node.state === 'restarting') { text = 'node ' + s.node.state; pill.classList.add('warn'); }
else if (s.mining.state === 'idle' && s.node.state === 'synced') { text = 'node synced'; pill.classList.add('on'); }
else text = s.mining.state;
$('pill-text').textContent = text;
if (pw.tone === 'on') pill.classList.add('on'); else if (pw.tone === 'bad') pill.classList.add('warn');
$('pill-text').textContent = pw.text;
var foot = (s.display_name ? '<b title="' + esc(s.display_name) + '">' + esc(s.display_name) + '</b><br>' : '') + (s.address.display ? 'pays ' + esc(s.address.display.slice(0, 6) + '…' + s.address.display.slice(-4)) : 'no address yet') + '<br>up ' + uptime(s.uptime_s);
if (foot !== lastFoot) { lastFoot = foot; $('rail-status').innerHTML = foot; }
var fv = 'v' + esc(s.version) + '<span class="chain"> · ' + esc(s.chain || s.network || '') + '</span>';
@ -1492,7 +1603,8 @@ if (typeof document !== 'undefined') (function () {
el.className = 'notice ' + n.kind + (n.tone ? ' ' + n.tone : '');
el.dataset.key = n.key;
$('notice-text').textContent = n.text; $('notice-text').title = n.title || '';
$('notice-detail').textContent = n.detail; $('notice-detail').hidden = !n.detail; $('notice-detail').title = n.detail;
$('notice-detail').textContent = n.detail; $('notice-detail').hidden = true; $('notice-detail').title = n.detail;
$('notice-more').hidden = !n.detail; $('notice-more').setAttribute('aria-expanded', 'false'); $('notice-more').classList.remove('open');
$('notice-actions').innerHTML = n.actions.map(function (a) { return '<button class="btn small ' + (a.primary ? 'primary' : 'ghost') + '" data-act="' + esc(a.act) + '"' + (a.disabled ? ' disabled' : '') + '>' + esc(a.label) + '</button>'; }).join('');
$('notice-prog').hidden = !(n.progress >= 0);
strip.hidden = false;
@ -1514,6 +1626,7 @@ if (typeof document !== 'undefined') (function () {
var b = e.target.closest('[data-act]'); if (!b || b.disabled) return;
var act = b.dataset.act, key = $('notice').dataset.key;
if (act === 'close') { dismissed[key] = true; renderNotices(state || {}); }
else if (act === 'more') { var open = $('notice-detail').hidden; $('notice-detail').hidden = !open; b.setAttribute('aria-expanded', open ? 'true' : 'false'); b.classList.toggle('open', open); layoutStrip(); }
else if (act === 'install') { api('api/update/install', {}); toast('Installing at once'); }
else if (act === 'retry') { api('api/update/install', {}); toast('Trying the update again'); }
else if (act === 'open') api('api/update/open', {});
@ -1659,7 +1772,7 @@ if (typeof document !== 'undefined') (function () {
}).catch(function () {
failures++;
if (failures >= 4) {
$('pill').classList.add('warn', 'away'); $('pill-text').textContent = state && state.quitting ? 'stopped' : 'engine away';
$('pill').classList.add('warn', 'away'); $('pill-text').textContent = state && state.quitting ? 'Stopped' : 'Engine not answering';
if (phase === 'dashboard') {
setText('node-line-text', 'Node: no answer'); setText('node-sub', 'the engine is not answering; the window reconnects by itself'); $('node-card').classList.add('bad');
setText('btn-toggle-sub', 'the engine is not answering'); $('btn-toggle').disabled = true;

View file

@ -50,6 +50,7 @@
<div class="notice" id="notice" role="status" aria-live="polite">
<span class="notice-text" id="notice-text"></span>
<span class="notice-actions" id="notice-actions"></span>
<button class="notice-more" id="notice-more" data-act="more" title="The technical line" aria-label="Show the technical line" aria-expanded="false" hidden><span class="chev"></span></button>
<button class="notice-close" id="notice-close" data-act="close" title="Close" aria-label="Close">&times;</button>
<span class="notice-detail mono" id="notice-detail" hidden></span>
<span class="prog" id="notice-prog" hidden><i></i></span>

View file

@ -96,7 +96,7 @@ test('update wording: available, downloading with percent, installing, failed wi
assert.equal(updateNotice(upd({ status: 'deferred' }), s()).text, 'Igneum Miner 0.3.6 is waiting for permission. It installs the next time someone is at this PC. Mining continues.');
// updated: gone within 60 s of the new version starting
const cur = upd({ status: 'current', available: false, ready: false, downloaded: false, updated_from: '0.3.4' });
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 59 })).text, 'Updated to Igneum Miner 0.3.6 from 0.3.4.');
assert.match(updateNotice(cur, s({ version: '0.3.6', uptime_s: 59, now: 1_800_000_000 })).text, /^Updated to 0\.3\.6 at \d\d:\d\d\.$/);
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 60 })), null);
assert.equal(updateNotice(upd({ status: 'current', available: false }), s()), null);
// a build with no manifest reports an error the user cannot act on: no notice (the Settings note still says it)
@ -106,22 +106,26 @@ test('update wording: available, downloading with percent, installing, failed wi
test('job wording: running with minutes and stage, done goes after 5 minutes, failed stays with the first error line', () => {
const r = jobNotice(run(), NOW);
assert.equal(r.text, 'Job: shard benchmark running, 6 min. Proving shard 3 of 8.');
assert.equal(r.detail, 'RESULT shard=2 prove_s=39.8');
assert.equal(jobNotice(run({ stage: '' }), NOW).text, 'Job: shard benchmark running, 6 min.');
assert.equal(r.text, 'A job from the team is running: shard benchmark, 6 min. Proving shard 3 of 8.');
assert.equal(r.detail, 'shard benchmark · RESULT shard=2 prove_s=39.8', 'the technical line goes behind the chevron');
assert.equal(jobNotice(run({ stage: '' }), NOW).text, 'A job from the team is running: shard benchmark, 6 min.');
const d = jobNotice(fin('done'), NOW);
assert.equal(d.text, 'Job: build done after 32 min. Report uploaded.');
assert.equal(d.text, 'A job from the team ran: build, 32 min.');
assert.equal(d.key, 'job:done:job-6');
assert.ok(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S));
assert.equal(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S + 1), null);
const f = jobNotice(fin('failed'), NOW);
assert.equal(f.text, 'Job: build failed after 32 min, exit 1. Report not uploaded.');
assert.equal(f.detail, 'BUILD FAILED: error[E0425]: cannot find value `foo`');
assert.equal(f.text, 'A job from the team failed: build, after 32 min. The report did not upload.');
assert.match(f.detail, /BUILD FAILED: error\[E0425\]: cannot find value `foo` · exit 1$/);
assert.equal(f.tone, 'bad');
assert.ok(jobNotice(fin('failed'), NOW + 86400 * 7), 'a failed job stays until closed');
// no error line among the results: the summary is the cause
assert.equal(jobNotice(fin('failed', { last_results: ['STAGE build'] }), NOW).detail, 'build exited with code 1');
assert.equal(jobNotice(fin('timeout'), NOW).text, 'Job: build hit its time cap after 32 min, exit 1. Report not uploaded.');
assert.match(jobNotice(fin('failed', { last_results: ['STAGE build'] }), NOW).detail, /^build: build exited with code 1 · exit 1$/);
assert.equal(jobNotice(fin('timeout'), NOW).text, 'A job from the team ran out of time: build, after 32 min. The report did not upload.');
assert.equal(N.jobWord('', 'update-now-0313-switch-d937c69d'), 'update check');
assert.equal(N.jobWord('restart', 'restart-miners-0312'), 'restart');
assert.equal(N.jobWord('collect', ''), 'log collection');
assert.equal(N.jobWord('', 'ember-tune-pc1-5'), 'tuning check');
assert.equal(jobNotice({ active: false, last: {} }, NOW), null);
assert.equal(jobNotice(null, NOW), null);
});

View file

@ -315,3 +315,34 @@ test('the node line, the activity line, the asks', () => {
assert.match(V.trustAsk(false), /^Verify proof records again\?/);
assert.equal(V.ago(30), 'just now'); assert.equal(V.ago(7200), '2 h ago'); assert.equal(V.ago(200000), '2 d ago');
});
test('the activity feed and the pill speak in the user\'s voice; the engine line is kept as the tooltip', () => {
const pe = V.plainEvent('job update-now-0313-switch-d937c69d (Exec restart: re-read the manifest (exec_restart_number 27276) and restart the node with it) done after 0 min, exit 0, report uploaded: update check and install asked');
assert.equal(pe.text, 'A job from the team ran: update check, 0 min');
assert.match(pe.tech, /^job update-now/);
assert.equal(V.plainEvent('updated to Igneum Miner 0.3.14 from 0.3.13').text, 'Updated to 0.3.14');
assert.equal(V.plainEvent('consensus parameters from the signed manifest: {"difficulty_v2_activation_daa":33000}').text, 'Rule settings read from the team');
assert.equal(V.plainEvent('node synced: 151512 blocks, 6 peer(s)').text, 'Node synced: 151,512 blocks, 6 peers');
assert.equal(V.plainEvent('block accepted by the node (Apple M5 Max)').text, 'Block found by Apple M5 Max');
assert.equal(V.plainEvent('1 remote job received from Igneum').text, '1 job from the team received');
assert.equal(V.plainEvent('remote job: update check now; a newer version installs at once').text, 'The team asked for an update check');
assert.equal(V.plainEvent('a node already answers on 127.0.0.1:26610; using it (it is not stopped by this app)').text, 'Using the node already running on this machine');
assert.equal(V.plainEvent('igneumd exited with code 101 after 300 s; restarting').text, 'The node stopped (code 101); restarting');
assert.equal(V.plainEvent('miner apple:0 exited with code 1; restarting in 8 s').text, 'A worker stopped (code 1); restarting in 8 s');
assert.equal(V.plainEvent('NVIDIA GeForce RTX 5090 kernel race: l128w16 at 122.3 MH/s (+4.1% over base, 6 variants, 40 s)').text, 'NVIDIA GeForce RTX 5090: program variant chosen, 122.3 MH/s');
assert.equal(V.plainEvent('block 59199 shard 0 paid 0.0125 IGN').text, 'Proof paid: 0.0125 IGN');
assert.equal(V.plainEvent('rewards go to 0xdd442fcbb964a3afdc90d49b408e8dd296fa86e8').text, 'Rewards go to 0xdd44…86e8');
assert.equal(V.plainEvent('mining resumed').text, 'Mining resumed');
assert.equal(V.plainEvent('something new the engine says').text, 'Something new the engine says');
assert.equal(V.plainEvent('something new the engine says').tech, '');
const base = { setup_done: true, quitting: false, mining: { state: 'mining', hash_total: 510.6, cards: [] }, node: { state: 'synced' } };
assert.deepEqual(V.pill(base), { text: 'Mining · 511 MH/s', tone: 'on' });
assert.deepEqual(V.pill({ ...base, mining: { state: 'mining', hash_total: 17.04, cards: [] } }), { text: 'Mining · 17.0 MH/s', tone: 'on' });
assert.deepEqual(V.pill({ ...base, mining: { state: 'paused', hash_total: 0, cards: [] } }), { text: 'Paused', tone: '' });
assert.deepEqual(V.pill({ ...base, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'syncing' } }), { text: 'Syncing the node', tone: '' });
assert.deepEqual(V.pill({ ...base, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'restarting' } }), { text: 'Node restarting', tone: 'bad' });
assert.deepEqual(V.pill({ ...base, mining: { state: 'mining', hash_total: 1, cards: [{ state: 'tuning' }] } }), { text: 'Tuning', tone: 'on' });
assert.deepEqual(V.pill({ ...base, setup_done: false, detecting: true }), { text: 'Detecting cards', tone: '' });
assert.deepEqual(V.pill({ ...base, quitting: true }), { text: 'Stopping', tone: '' });
assert.deepEqual(V.pill({ ...base, mining: { state: 'idle', hash_total: 0, cards: [] } }), { text: 'Not mining', tone: '' });
});

View file

@ -275,3 +275,20 @@ the quit ask, paused, syncing, the clock, the update card, the job strip, the lo
| A Windows and a PC screenshot of the row with a real NVIDIA card (the telemetry cells, the cap slider, Retry) | the 0.3.14 cut, first thing to look at |
| The row's state word while tuning carries the meta after it ("tuning: step 7 of 9 · measuring 441 W · 32 GB · ...") and is cut on a narrow row | a later pass: drop the meta while tuning |
| The merge with `ember-tune`: `app.js` takes this branch's file (it already carries ember-tune's UI words and test); the engine files merge by themselves | the 0.3.14 shipper |
## 14. Polish round (6 October 2026, after the 0.3.14 cut)
the project lead, on the live Mac app: "I do think this could look better", at the job strip reading "Job: 0.3.14 is published:
re-read the manifest and install now (...) done after 0 min. Report uploaded." Branch rebased on master 630b537.
| What | Where |
|---|---|
| No raw job, manifest or relay text on a user surface. The strip reads "A job from the team ran: update check, 0 min." / "A job from the team is running: shard benchmark, 6 min. Proving shard 3 of 8." / "A job from the team failed: build, after 32 min." / "Updated to 0.3.14 at 18:52."; the technical line (the job's title, summary, RESULT line, exit code) sits behind a chevron; the × stays. `Notices.jobWord` maps a kind or an id prefix to a user's word (update check, restart, log collection, shard benchmark, build, tuning check, a script). | app.js Notices |
| The Activity feed speaks in the app's voice: `View.plainEvent` maps every engine event string (`src/engine.rs` et al., 80 patterns) to a sentence ("Block found by Apple M5 Max", "Rule settings read from the team", "The node stopped (code 101); restarting", "A job from the team ran: update check, 0 min"); the engine's own line is the row's tooltip; the kind word becomes a coloured dot. Unknown lines keep their words with a JSON tail cut. | app.js View, renderMine |
| The header: title, grey subtitle and the pill share one baseline; the pill is a sentence with a coloured dot: "Mining · 511 MH/s" (molten), "Tuning" (molten), "Paused", "Syncing the node", "Starting the worker", "Not mining" (grey), "Node restarting", "Node failed", "Engine not answering" (ember), "Stopping", "Detecting cards", "Setting up". `View.pill`. | app.js, app.css |
| Three Mine layouts behind `?variant=a|b|c` for the project lead to pick: A quieter, B denser, C the hero number. The chosen one becomes the default. | app.css |
| 390 px: the strip's text, chevron and × on one line. Light and dark checked. | app.css |
Screenshots: `p01-mine-variant-a-quieter.png`, `p02-mine-variant-b-denser.png`, `p03-mine-variant-c-hero.png` (full
page, the rig at 28p/kWh, the 5090 tuned), `p04`/`p05` the plain job strip closed and open, `p06` the updated strip,
`p07` the header paused, `p08` the activity feed, `p09`/`p12` light, `p10`/`p11` 390 px. Tests: 36 pass.

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB