Miner app 0.3.7: the update card, one centred card over the window for an update

The strip under the header stays; the card is the first sight of an update. The mark with a progress ring, "Igneum
Ember 0.3.7", one line (is available, is downloading with the percent, is ready to install, Installing. The app
restarts itself., did not install with the one-line cause and Try again), up to three lines of release notes from the
manifest with the rest behind "What changed", the size, Install now and Later. Escape and the backdrop are Later.
Reduced motion is honoured.

Rules (UpdateCard, pure, app/igneum-app/ui/update-card.test.mjs): the card never opens while a job runs, in the
engine's first 60 s, while the key sheet is up or while the app quits; it waits and comes once the block lifts.
Later hides this version at this stage and leaves the strip; the card comes back for a newer version, or when the
download is ready and automatic updates are off (with them on it installs by itself). An open card follows its
update through downloading, ready, installing and failed; installing and failed never open a card by themselves.

?update=<kind>[&auto=0][&card=1] and ?uptime= on the page show every state without an engine. CI runs the new test
file next to notices.test.mjs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-05 09:33:17 +00:00
parent 65d6882edc
commit 1cef7e4ee6
5 changed files with 345 additions and 8 deletions

View file

@ -65,5 +65,5 @@ jobs:
run: bash tools/ci/copied-sources-check.sh
- name: relay unit tests (parsers, secret compare, the wake endpoint)
run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs
- name: miner app notice strip (ordering, keys, wording, timers)
run: node --test app/igneum-app/ui/notices.test.mjs
- name: miner app notice strip and update card (ordering, keys, wording, timers, when the card shows)
run: node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs

View file

@ -298,6 +298,40 @@ td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(--
.feed .k.block{color:var(--ember)}
.feed .k.build{color:var(--ink-2)}
/* the update card (app.js, UpdateCard; the wallet carries the same block): the mark with its ring, the name, one
line, up to three note lines, the size, Install now and Later. Over the key sheet and the settings panel. */
.upd-wrap{position:fixed;inset:0;z-index:35;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px;animation:fade .25s ease}
@keyframes fade{from{opacity:0}to{opacity:1}}
.upd-card{position:relative;width:100%;max-width:500px;max-height:calc(100vh - 48px);overflow:auto;background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:34px 32px 28px;display:flex;flex-direction:column;align-items:center;text-align:center;gap:var(--s-2);box-shadow:0 30px 80px rgba(0,0,0,.6),0 0 0 1px rgba(242,84,27,.08);animation:rise .3s ease;outline:none}
.upd-card::before{content:"";position:absolute;left:50%;top:-80px;width:360px;height:260px;transform:translateX(-50%);border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.22) 0,rgba(242,84,27,0) 62%);pointer-events:none}
.upd-mark{position:relative;width:96px;height:96px;display:flex;align-items:center;justify-content:center;margin-bottom:var(--s-3)}
.upd-mark img{position:relative;display:block;filter:drop-shadow(0 6px 18px rgba(242,84,27,.35))}
.upd-ring{position:absolute;inset:0;transform:rotate(-90deg)}
.upd-ring .track{fill:none;stroke:var(--line-2);stroke-width:3}
.upd-ring .arc{fill:none;stroke:var(--ember);stroke-width:3;stroke-linecap:round;stroke-dasharray:276.5;stroke-dashoffset:276.5;transition:stroke-dashoffset .4s linear,stroke .3s ease}
.upd-mark.none .track{stroke:var(--line)}
.upd-mark.full .arc{stroke:var(--molten);stroke-dashoffset:0}
.upd-mark.busy .arc{stroke-dasharray:69 207.5;stroke-dashoffset:0;animation:spin 1.1s linear infinite;transform-origin:50% 50%}
.upd-mark.failed .arc{stroke:var(--ember);stroke-dashoffset:0;opacity:.55}
.upd-pct{position:absolute;left:50%;bottom:-11px;transform:translateX(-50%);font-size:var(--t-xs);font-weight:500;letter-spacing:.06em;color:var(--molten);background:var(--obsidian);border:1px solid var(--line-2);border-radius:999px;padding:2px 8px;font-variant-numeric:tabular-nums}
.upd-name{font-size:24px;font-weight:700;letter-spacing:-.01em;position:relative}
.upd-line{font-size:var(--t-xl);color:var(--ink-2);line-height:1.4;position:relative}
.upd-cause{font-size:var(--t-sm);color:var(--ember);line-height:1.5;max-width:40ch;word-break:break-word}
.upd-notes{list-style:none;margin:var(--s-2) auto 0;padding:0;width:max-content;max-width:100%;display:flex;flex-direction:column;gap:6px;align-items:flex-start;font-size:var(--t-md);color:var(--bone);line-height:1.4}
.upd-notes:empty{display:none}
.upd-notes li{display:flex;align-items:baseline;gap:10px;text-align:left}
.upd-notes li::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--ember);flex:0 0 6px;position:relative;top:-2px}
.upd-more{font:inherit;font-size:var(--t-sm);font-family:var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);background:transparent;border:0;padding:4px 8px;cursor:pointer;border-radius:6px;margin-top:2px}
.upd-more:hover{color:var(--bone)}
.upd-all{list-style:none;margin:0;padding:10px 14px;width:100%;max-height:150px;overflow:auto;text-align:left;font-size:var(--t-base);color:var(--ink-2);line-height:1.5;background:var(--obsidian);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;gap:4px;user-select:text;-webkit-user-select:text}
.upd-meta{font-size:var(--t-sm);color:var(--ash);letter-spacing:.04em;margin-top:var(--s-2);min-height:18px;line-height:1.5;max-width:44ch}
.upd-meta:empty{display:none}
.upd-actions{display:flex;gap:var(--s-3);align-items:center;justify-content:center;flex-wrap:wrap;margin-top:var(--s-4);position:relative}
.upd-actions:empty{display:none}
.upd-actions .btn.primary{min-width:160px}
@media (prefers-reduced-motion:reduce){.upd-wrap,.upd-card{animation:none}.upd-ring .arc{transition:none}.upd-mark.busy .arc{animation:none;stroke-dasharray:207.5 69}}
@media (max-height:620px){.upd-card{padding:24px 24px 20px}.upd-mark{width:72px;height:72px;margin-bottom:var(--s-2)}.upd-mark img{width:32px;height:32px}.upd-name{font-size:20px}}
/* sheet (the key) */
.sheet-wrap,.panel-wrap{position:fixed;inset:0;z-index:30;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px}
.sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;max-height:calc(100vh - 48px);overflow:auto;display:flex;flex-direction:column;gap:14px;box-shadow:0 30px 80px rgba(0,0,0,.6);animation:rise .3s ease}

View file

@ -112,7 +112,110 @@ var Notices = (function () {
}
return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot };
})();
if (typeof module === 'object' && module && module.exports) module.exports = Notices;
/* ---------- the update card (pure; update-card.test.mjs loads this block) ----------
A centred card over the window for one update: the mark with a progress ring, "Igneum Ember 0.3.7", one line
(is available, is downloading, is ready to install, Installing, did not install), up to three lines of release
notes with the rest behind "What changed", the size, Install now and Later. The same card, the same rules and the
same words sit in the wallet (app/igneum-wallet/ui/update-card.js).
model() turns state.update into what the card says. decide() says whether it shows now, given the window
(ctx) and what the card already did (mem: open, later, seen):
deferred while a job runs, in the engine's first 60 s, while the key sheet is up, while the app quits
later Later, Escape or the backdrop hides this version at this stage; the strip (Notices) keeps it
back a newer version, or the download ready while automatic updates are off (with them on it installs
by itself, so a dismissed download stays dismissed); an open card follows its update to the end
installing and failed show only on an open card (Install now was pressed here); the strip carries the rest */
var UpdateCard = (function () {
'use strict';
var NAME = 'Igneum Ember', LINE_MAX = 70, LINES = 3, FIRST_S = 60;
var INSTALL = { act: 'install', label: 'Install now', primary: true }, LATER = { act: 'later', label: 'Later' };
var RETRY = { act: 'retry', label: 'Try again', primary: true }, OPEN = { act: 'open', label: 'Open the download', primary: true };
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '…' : t; }
// the manifest's notes as sentences: split on line breaks, then after . ! ? ; (no lookbehind: the Mac WebView)
function sentences(text) {
var out = [], parts = String(text || '').split(/\n+/);
for (var i = 0; i < parts.length; i++) {
var p = parts[i], at = 0;
for (var j = 0; j < p.length; j++) {
var ch = p.charAt(j), next = p.charAt(j + 1);
if ((ch === '.' || ch === '!' || ch === '?' || ch === ';') && (next === ' ' || next === '')) { push(p.slice(at, j + 1)); at = j + 1; }
}
push(p.slice(at));
}
function push(s) { s = s.trim().replace(/[;:,]+$/, ''); if (s) out.push(cap(s)); }
return out;
}
// at most LINES lines, each under LINE_MAX characters (cut at a word); more = something is left for "What changed"
function splitNotes(text) {
var all = sentences(text), lines = [], more = all.length > LINES;
for (var i = 0; i < all.length && lines.length < LINES; i++) {
var s = all[i].replace(/\.$/, '');
if (s.length >= LINE_MAX) { var cut = s.lastIndexOf(' ', LINE_MAX - 2); s = s.slice(0, cut > 20 ? cut : LINE_MAX - 2).replace(/[,;:]$/, '') + '…'; more = true; }
lines.push(s);
}
return { lines: lines, more: more, all: all };
}
function size(bytes) { if (!(bytes > 0)) return ''; return bytes < 1e6 ? (bytes / 1e6).toFixed(1) + ' MB' : Math.round(bytes / 1e6) + ' MB'; }
function card(stage, u, over) {
var ver = u.version || '';
var m = { stage: stage, version: ver, key: 'update:' + ver + ':' + (stage === 'available' || stage === 'downloading' || stage === 'staging' ? 'pending' : stage), name: NAME + ' ' + ver, line: '', note: '', cause: '', size: size(u.size), pct: -1, ring: 'none', actions: [], dismissable: true, auto: !!u.auto };
var n = splitNotes(u.notes); m.lines = n.lines; m.more = n.more; m.all = n.all;
if (over) for (var k in over) m[k] = over[k];
return m;
}
// u = state.update; s = { version, uptime_s, quitting }
function model(u, s) {
if (!u || !u.version) return null;
s = s || {};
var pct = u.progress > 0 ? Math.min(100, Math.round(u.progress * 100)) : 0;
if (u.status === 'error') {
if (/no update manifest configured/.test(u.error || '')) return null;
return card('failed', u, { line: u.rolled_back ? 'did not stay up and was rolled back.' : 'did not install.', cause: firstLine(u.error, 120), ring: 'failed', actions: [RETRY, LATER], lines: [], more: false });
}
if (u.applying || u.status === 'applying' || (u.status === 'ready' && u.wait === 'installing now')) {
return card('installing', u, { line: 'Installing. The app restarts itself.', note: s.quitting ? 'A moment.' : 'Mining continues until then.', ring: 'busy', dismissable: false, lines: [], more: false });
}
var m = null;
switch (u.status) {
case 'available': m = card('available', u, { line: 'is available.', actions: [INSTALL, LATER] }); break;
case 'downloading': m = card('downloading', u, { line: 'is downloading.', pct: pct, ring: 'progress', actions: [INSTALL, LATER] }); break;
case 'staging': m = card('staging', u, { line: 'is being checked.', pct: 100, ring: 'progress', actions: [INSTALL, LATER] }); break;
case 'ready':
var why = /failed to install before/.test(u.wait || '') ? 'It failed to install before.' : u.auto ? 'It installs by itself at a quiet moment.' : '';
m = card('ready', u, { line: 'is ready to install.', note: why, ring: 'full', actions: [INSTALL, LATER] }); break;
case 'deferred': m = card('deferred', u, { line: 'is waiting for permission.', note: 'It installs the next time someone is at this PC. Mining continues.', ring: 'full', actions: [INSTALL, LATER] }); break;
case 'manual': m = card('manual', u, { line: 'is downloaded.', note: 'Open the disk image and drag the app over the old one.', ring: 'full', actions: [OPEN, LATER] }); break;
}
if (!m) return null;
if (u.urgent && u.urgent_text) { m.note = u.urgent_text; m.dismissable = false; m.actions = m.actions.filter(function (a) { return a.act !== 'later'; }); }
return m;
}
// why the card may not open now: ctx = { jobActive, uptime_s, quitting, sheetOpen }
function blocked(ctx) {
ctx = ctx || {};
if (ctx.quitting) return 'the app is quitting';
if (ctx.sheetOpen) return 'the key sheet is open';
if (ctx.jobActive) return 'a job is running';
if (ctx.uptime_s >= 0 && ctx.uptime_s < FIRST_S) return 'the engine started under a minute ago';
return '';
}
// mem = { open: the card is up, later: the key Later was pressed on, seen: the version the card was shown for }
function decide(m, ctx, mem) {
mem = mem || {};
if (!m) return { show: false, why: 'none' };
var b = blocked(ctx);
if (b && m.stage !== 'installing') return { show: false, why: 'deferred', reason: b };
if (m.stage === 'installing' || m.stage === 'failed') return mem.open ? { show: true, why: 'open' } : { show: false, why: 'strip' };
if (!m.dismissable) return { show: true, why: 'urgent' };
if (mem.later === m.key) return { show: false, why: 'later' };
if (mem.open) return { show: true, why: 'open' };
if (m.stage === 'ready' && m.auto && mem.seen === m.version) return { show: false, why: 'auto' };
return { show: true, why: m.stage === 'ready' ? 'ready' : 'new' };
}
return { NAME: NAME, LINE_MAX: LINE_MAX, LINES: LINES, FIRST_S: FIRST_S, sentences: sentences, splitNotes: splitNotes, size: size, model: model, blocked: blocked, decide: decide };
})();
if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; }
if (typeof document !== 'undefined') (function () {
'use strict';
@ -843,6 +946,63 @@ if (typeof document !== 'undefined') (function () {
else if (act === 'sync') api('api/clock/sync', {});
});
// ---------- the update card (UpdateCard, top of this file): one update, centred, over everything ----------
// mem: open (the card is up), later (the key Later was pressed on), seen (the version it was shown for);
// asked: Install now was pressed on this card and the engine has not flipped to applying yet
var updMem = { open: false, later: '', seen: '' }, updSig = '', updAsked = '', updMore = false;
if (forcedUpdate && params.get('card') === '1') updMem.open = true; // screenshots: installing and failed only show on an open card
function cardCtx(s) { return { jobActive: !!(s.jobs && s.jobs.active), uptime_s: s.uptime_s, quitting: !!s.quitting, sheetOpen: !$('sheet-key').hidden }; }
function renderUpdateCard(s) {
var m = UpdateCard.model(s.update, s), d = UpdateCard.decide(m, cardCtx(s), updMem), wrap = $('upd');
if (!d.show) {
if (updMem.open || !wrap.hidden) { updMem.open = false; wrap.hidden = true; updSig = ''; }
return;
}
if (!updMem.open) { updMem.open = true; updMem.seen = m.version; updMore = false; if (updAsked !== m.key) updAsked = ''; }
var asked = updAsked === m.key && m.stage !== 'installing' && m.stage !== 'failed';
var sig = [m.key, m.stage, m.line, m.note, m.cause, m.ring, asked ? 'asked' : '', m.lines.join('|')].join('\u0001');
if (sig !== updSig) {
updSig = sig;
$('upd-name').textContent = m.name;
$('upd-line').textContent = m.line;
$('upd-cause').textContent = m.cause; $('upd-cause').hidden = !m.cause;
$('upd-notes').innerHTML = m.lines.map(function (l) { return '<li>' + esc(l) + '</li>'; }).join('');
$('upd-all').innerHTML = m.all.map(function (l) { return '<li>' + esc(l) + '</li>'; }).join('');
$('upd-more').hidden = !m.more; if (!m.more) updMore = false;
$('upd-more').textContent = updMore ? 'Less' : 'What changed'; $('upd-more').setAttribute('aria-expanded', updMore ? 'true' : 'false');
$('upd-all').hidden = !updMore;
$('upd-mark').className = 'upd-mark ' + m.ring;
var acts = asked ? [{ act: 'install', label: m.stage === 'ready' || m.stage === 'deferred' ? 'Installing' : 'Installs when ready', primary: true, disabled: true }, { act: 'later', label: 'Later' }] : m.actions;
$('upd-actions').innerHTML = acts.map(function (a) { return '<button class="btn ' + (a.primary ? 'primary big' : 'ghost') + '" data-act="' + esc(a.act) + '"' + (a.disabled ? ' disabled' : '') + '>' + esc(a.label) + '</button>'; }).join('');
$('upd').dataset.dismiss = m.dismissable ? '1' : '';
}
// the ring and the percent move every poll; the words above do not
if (m.ring === 'progress') { $('upd-arc').style.strokeDashoffset = (276.5 * (1 - m.pct / 100)).toFixed(1); $('upd-pct').textContent = m.pct + '%'; $('upd-pct').hidden = m.stage !== 'downloading'; }
else { $('upd-arc').style.strokeDashoffset = ''; $('upd-pct').hidden = true; }
var meta = [];
if (m.stage === 'downloading' && m.size) meta.push(m.pct + '% of ' + m.size);
else if (m.size && (m.stage === 'available')) meta.push(m.size + ' download');
else if (m.size && (m.stage === 'ready' || m.stage === 'manual' || m.stage === 'deferred' || m.stage === 'staging')) meta.push(m.size + ', downloaded');
if (m.note) meta.push(m.note);
$('upd-meta').textContent = meta.join(' · ');
if (wrap.hidden) { wrap.hidden = false; $('upd-card').focus({ preventScroll: true }); }
}
function cardLater() {
if (!updMem.open || !$('upd').dataset.dismiss) return;
var m = UpdateCard.model(state && state.update, state || {});
updMem.later = m ? m.key : updMem.later; updMem.open = false; $('upd').hidden = true; updSig = '';
}
$('upd').addEventListener('click', function (e) {
if (e.target === $('upd')) { cardLater(); return; }
var b = e.target.closest('[data-act]'); if (!b || b.disabled) return;
var act = b.dataset.act, m = UpdateCard.model(state && state.update, state || {});
if (act === 'later') cardLater();
else if (act === 'install' || act === 'retry') { api('api/update/install', {}); updAsked = m ? m.key : ''; updSig = ''; toast(act === 'retry' ? 'Trying the update again' : m && m.stage === 'ready' ? 'Installing at once' : 'Installs as soon as it is downloaded'); if (state) renderUpdateCard(state); }
else if (act === 'open') api('api/update/open', {});
});
$('upd-more').addEventListener('click', function () { updMore = !updMore; $('upd-all').hidden = !updMore; this.textContent = updMore ? 'Less' : 'What changed'; this.setAttribute('aria-expanded', updMore ? 'true' : 'false'); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !$('upd').hidden) { e.preventDefault(); cardLater(); } });
// ---------- over-the-air updates (src/ota.rs): the settings note; the strip is Notices.updateNotice ----------
function settingsUpdateNote(u) {
var n = Notices.updateNotice(u, state || {}), parts = [];
@ -856,16 +1016,19 @@ if (typeof document !== 'undefined') (function () {
return parts.join(' ');
}
function sampleUpdate(kind) {
var u = { available: true, version: '0.3.1', notes: 'difficulty v2, OTA updates', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20588331, auto: true, wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, error: '', updated_from: '', rolled_back: '' };
// ?update=<kind>[&auto=0]: the card and the strip for each state, without an engine
var u = { available: true, version: '0.3.7', notes: 'The update card: one centred card, Install now and Later. Worker restarts no longer drop the hourly program. The log drawer wraps long lines and jumps to the last error. Windows Hello is written but untested; Touch ID confirms every send on the Mac.', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20588331, auto: params.get('auto') !== '0', wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, error: '', updated_from: '', rolled_back: '' };
if (!u.auto) u.wait = 'waiting for Install now (automatic updates are off)';
if (kind === 'available') { u.status = 'available'; u.downloaded = false; u.ready = false; u.progress = 0; }
if (kind === 'downloading') { u.status = 'downloading'; u.downloaded = false; u.ready = false; u.progress = 0.43; }
if (kind === 'downloading') { u.status = 'downloading'; u.downloaded = false; u.ready = false; u.progress = Math.min(0.97, 0.43 + ((Date.now() / 1000) % 60) / 110); }
if (kind === 'staging') { u.status = 'staging'; u.ready = false; u.progress = 1; }
if (kind === 'waiting') { u.wait = 'hourly program boundary in 97 s; installing after it'; }
if (kind === 'applying') { u.status = 'applying'; u.applying = true; }
if (kind === 'deferred') { u.status = 'deferred'; u.wait = 'waits for the next time someone is at this PC (Windows asks for permission); mining continues'; }
if (kind === 'urgent') { u.urgent = true; u.activation_height = 120000; u.urgent_text = 'Consensus upgrade at height 120000 (difficulty v2): the node is 1,240 blocks away. Installing 0.3.1 now.'; }
if (kind === 'urgent') { u.status = 'downloading'; u.ready = false; u.downloaded = false; u.progress = 0.27; u.urgent = true; u.activation_height = 120000; u.urgent_text = 'Consensus upgrade at height 120000 (difficulty v2): the node is 1,240 blocks away. Installing 0.3.7 now.'; }
if (kind === 'manual') { u.status = 'manual'; u.ready = false; u.wait = '/Applications is not writable; open the downloaded disk image and drag the app over the old one'; }
if (kind === 'error') { u.status = 'error'; u.error = 'sha256 mismatch: the file is not what the manifest signed'; u.downloaded = false; u.ready = false; }
if (kind === 'updated') { u.status = 'current'; u.available = false; u.ready = false; u.downloaded = false; u.updated_from = '0.3.0'; u.version = '0.3.1'; }
if (kind === 'updated') { u.status = 'current'; u.available = false; u.ready = false; u.downloaded = false; u.updated_from = '0.3.6'; u.version = '0.3.7'; }
return u;
}
// ---------- remote jobs (src/jobrun.rs): the settings block; the strip is Notices.jobNotice ----------
@ -897,11 +1060,13 @@ if (typeof document !== 'undefined') (function () {
}
function render(s) {
state = s; stateAt = performance.now();
if (forcedUpdate) { s.update = sampleUpdate(forcedUpdate); if (forcedUpdate === 'updated') { s.version = '0.3.1'; s.uptime_s = 20; } }
if (forcedUpdate) { s.update = sampleUpdate(forcedUpdate); if (forcedUpdate === 'updated') { s.version = '0.3.7'; s.uptime_s = 20; } }
if (forcedJob) s.jobs = sampleJob(forcedJob, s.now || 0);
if (params.get('uptime')) s.uptime_s = parseInt(params.get('uptime'), 10) || 0; // the card's first-minute rule, for screenshots
renderPill(s);
renderClock(s);
renderNotices(s);
renderUpdateCard(s);
if (phase === 'cards') renderCards(s);
if (phase === 'dashboard') renderDashboard(s);
if (s.quitting && !$('btn-quit').disabled) { $('btn-quit').disabled = true; }

View file

@ -216,6 +216,26 @@
</section>
</main>
<!-- the update card (app.js, UpdateCard): one update, centred; Later, Escape or the backdrop leaves the strip above -->
<div class="upd-wrap" id="upd" hidden>
<div class="upd-card" id="upd-card" role="dialog" aria-modal="true" aria-labelledby="upd-name" aria-describedby="upd-line" tabindex="-1">
<div class="upd-mark" id="upd-mark">
<svg class="upd-ring" viewBox="0 0 96 96" aria-hidden="true"><circle class="track" cx="48" cy="48" r="44"></circle><circle class="arc" id="upd-arc" cx="48" cy="48" r="44"></circle></svg>
<img src="mark.svg" width="40" height="40" alt="">
<span class="upd-pct mono" id="upd-pct" hidden></span>
</div>
<div class="eyebrow ember">update</div>
<h2 class="upd-name" id="upd-name"></h2>
<p class="upd-line" id="upd-line"></p>
<p class="upd-cause mono" id="upd-cause" hidden></p>
<ul class="upd-notes" id="upd-notes"></ul>
<button class="upd-more" id="upd-more" type="button" aria-expanded="false" hidden>What changed</button>
<ul class="upd-all" id="upd-all" hidden></ul>
<p class="upd-meta mono" id="upd-meta"></p>
<div class="upd-actions" id="upd-actions"></div>
</div>
</div>
<!-- the key sheet, shown once -->
<div class="sheet-wrap" id="sheet-key" hidden>
<div class="sheet">

View file

@ -0,0 +1,118 @@
// node --test app/igneum-app/ui/update-card.test.mjs (no dependencies; CI runs it in the site job)
// Loads the UpdateCard block at the top of app.js (plain browser JS, run with `module` defined and no `document`)
// and checks what the card says for each update state, the release-note lines, and when it shows or waits.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js'), 'utf8');
const mod = { exports: {} };
new Function('module', src)(mod);
const C = mod.exports.UpdateCard;
const { model, decide, blocked, splitNotes, sentences, size } = C;
const NOTES = 'Difficulty rule v2 from DAA score 33,000 (about 18:35 UTC on 4 October); every node must carry it before then. The log drawer wraps long lines. Touch ID on the Mac. Windows Hello is written but untested.';
const upd = (over) => ({ available: true, version: '0.3.7', notes: NOTES, checked_at: 0, error: '', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20_588_331, auto: true, wait: '', urgent: false, urgent_text: '', activation_height: 0, unsupported: false, min_supported: '', channel: 'devnet', published_at: '', file: '', updated_from: '', rolled_back: '', ...over });
const quiet = { jobActive: false, uptime_s: 5000, quitting: false, sheetOpen: false };
test('release notes: sentences, three lines under 70 characters, the rest behind What changed', () => {
assert.deepEqual(sentences('one. two! three? four; five'), ['One.', 'Two!', 'Three?', 'Four', 'Five']);
assert.deepEqual(sentences('DAA score 33,000 is the switch. v2.1 ships.'), ['DAA score 33,000 is the switch.', 'V2.1 ships.']);
assert.deepEqual(sentences('line one\nline two\n\n'), ['Line one', 'Line two']);
assert.deepEqual(sentences(''), []);
const n = splitNotes(NOTES);
assert.equal(n.lines.length, 3);
for (const l of n.lines) assert.ok(l.length < C.LINE_MAX, `${l.length}: ${l}`);
assert.equal(n.lines[0], 'Difficulty rule v2 from DAA score 33,000 (about 18:35 UTC on 4…');
assert.equal(n.lines[1], 'Every node must carry it before then');
assert.equal(n.lines[2], 'The log drawer wraps long lines');
assert.equal(n.more, true);
assert.equal(n.all.length, 5);
const short = splitNotes('Faster sync. Fewer restarts.');
assert.deepEqual(short.lines, ['Faster sync', 'Fewer restarts']); assert.equal(short.more, false);
assert.deepEqual(splitNotes('').lines, []); assert.equal(splitNotes('').more, false);
assert.equal(size(20_588_331), '21 MB'); assert.equal(size(612_000), '0.6 MB'); assert.equal(size(0), '');
});
test('what the card says: available, downloading with the ring, ready, installing, failed, manual, waiting', () => {
const a = model(upd({ status: 'available', downloaded: false, ready: false, progress: 0 }), {});
assert.equal(a.name, 'Igneum Ember 0.3.7'); assert.equal(a.line, 'is available.'); assert.equal(a.stage, 'available');
assert.equal(a.key, 'update:0.3.7:pending'); assert.equal(a.ring, 'none'); assert.equal(a.size, '21 MB');
assert.deepEqual(a.actions.map((x) => x.label), ['Install now', 'Later']); assert.equal(a.dismissable, true);
const d = model(upd({ status: 'downloading', downloaded: false, ready: false, progress: 0.43 }), {});
assert.equal(d.line, 'is downloading.'); assert.equal(d.pct, 43); assert.equal(d.ring, 'progress'); assert.equal(d.key, a.key);
assert.equal(model(upd({ status: 'staging', ready: false }), {}).key, a.key);
const r = model(upd(), {});
assert.equal(r.line, 'is ready to install.'); assert.equal(r.note, 'It installs by itself at a quiet moment.'); assert.equal(r.key, 'update:0.3.7:ready'); assert.equal(r.ring, 'full');
assert.equal(model(upd({ auto: false, wait: 'waiting for Install now (automatic updates are off)' }), {}).note, '');
assert.equal(model(upd({ wait: 'this version failed to install before; it waits for Install now' }), {}).note, 'It failed to install before.');
const i = model(upd({ status: 'applying', applying: true }), {});
assert.equal(i.stage, 'installing'); assert.equal(i.line, 'Installing. The app restarts itself.'); assert.deepEqual(i.actions, []); assert.equal(i.dismissable, false); assert.equal(i.ring, 'busy');
assert.equal(model(upd({ wait: 'installing now' }), {}).stage, 'installing');
const f = model(upd({ status: 'error', error: 'sha256 mismatch: the file is not what the manifest signed\nsecond line', ready: false }), {});
assert.equal(f.stage, 'failed'); assert.equal(f.line, 'did not install.'); assert.equal(f.cause, 'sha256 mismatch: the file is not what the manifest signed');
assert.deepEqual(f.actions.map((x) => x.label), ['Try again', 'Later']); assert.equal(f.key, 'update:0.3.7:failed');
assert.equal(model(upd({ status: 'error', error: 'did not stay up', rolled_back: '0.3.7: did not stay up' }), {}).line, 'did not stay up and was rolled back.');
const m = model(upd({ status: 'manual', ready: false, wait: '/Applications is not writable' }), {});
assert.equal(m.line, 'is downloaded.'); assert.equal(m.actions[0].label, 'Open the download');
assert.equal(model(upd({ status: 'deferred' }), {}).line, 'is waiting for permission.');
// urgent: no Later, the engine's words as the note
const u = model(upd({ status: 'downloading', progress: 0.2, urgent: true, urgent_text: 'Consensus upgrade at height 120000. Installing now.' }), {});
assert.equal(u.dismissable, false); assert.deepEqual(u.actions.map((x) => x.label), ['Install now']); assert.equal(u.note, 'Consensus upgrade at height 120000. Installing now.');
// nothing to show
assert.equal(model(upd({ status: 'current', available: false }), {}), null);
assert.equal(model(upd({ status: 'checking' }), {}), null);
assert.equal(model(upd({ status: 'error', error: 'no update manifest configured in this build' }), {}), null);
assert.equal(model(upd({ status: 'error', version: '', error: 'manifest: connection refused' }), {}), null);
assert.equal(model(null, {}), null);
});
test('deferred: a running job, the first 60 s, the key sheet, quitting; installing is never hidden', () => {
assert.equal(blocked(quiet), '');
assert.equal(blocked({ ...quiet, jobActive: true }), 'a job is running');
assert.equal(blocked({ ...quiet, uptime_s: 59 }), 'the engine started under a minute ago');
assert.equal(blocked({ ...quiet, uptime_s: 60 }), '');
assert.equal(blocked({ ...quiet, sheetOpen: true }), 'the key sheet is open');
assert.equal(blocked({ ...quiet, quitting: true }), 'the app is quitting');
const a = model(upd({ status: 'available' }), {});
assert.deepEqual(decide(a, { ...quiet, jobActive: true }, {}), { show: false, why: 'deferred', reason: 'a job is running' });
assert.deepEqual(decide(a, { ...quiet, uptime_s: 10 }, {}), { show: false, why: 'deferred', reason: 'the engine started under a minute ago' });
assert.equal(decide(a, quiet, {}).show, true);
// the block lifts: the card comes (it was queued, not dismissed)
assert.equal(decide(a, { ...quiet, uptime_s: 61 }, { open: false, later: '', seen: '' }).show, true);
const i = model(upd({ status: 'applying', applying: true }), {});
assert.equal(decide(i, { ...quiet, jobActive: true }, { open: true }).show, true);
});
test('Later: hides this version at this stage; back for a newer version or a ready download with auto off', () => {
const pend = model(upd({ status: 'downloading', progress: 0.5, ready: false }), {});
const ready = model(upd(), {});
const readyOff = model(upd({ auto: false }), {});
assert.deepEqual(decide(pend, quiet, {}), { show: true, why: 'new' });
const later = { open: false, later: pend.key, seen: '0.3.7' };
assert.deepEqual(decide(pend, quiet, later), { show: false, why: 'later' });
// auto on: the ready download installs by itself, the strip says so, no second card
assert.deepEqual(decide(ready, quiet, later), { show: false, why: 'auto' });
// auto off: the ready download needs a click, so the card returns once
assert.deepEqual(decide(readyOff, quiet, later), { show: true, why: 'ready' });
assert.deepEqual(decide(readyOff, quiet, { open: false, later: readyOff.key, seen: '0.3.7' }), { show: false, why: 'later' });
// a newer version is a new key
const newer = model(upd({ status: 'available', version: '0.3.8' }), {});
assert.deepEqual(decide(newer, quiet, later), { show: true, why: 'new' });
// ready first seen with auto on (the download happened while the card could not open): shown once
assert.deepEqual(decide(ready, quiet, { open: false, later: '', seen: '' }), { show: true, why: 'ready' });
// an open card follows its update: downloading, ready, installing, failed
const open = { open: true, later: '', seen: '0.3.7' };
assert.equal(decide(pend, quiet, open).why, 'open'); assert.equal(decide(ready, quiet, open).why, 'open');
assert.equal(decide(model(upd({ status: 'applying', applying: true }), {}), quiet, open).show, true);
assert.equal(decide(model(upd({ status: 'error', error: 'x' }), {}), quiet, open).show, true);
// installing and failed without an open card: the strip has them
assert.deepEqual(decide(model(upd({ status: 'applying', applying: true }), {}), quiet, {}), { show: false, why: 'strip' });
assert.deepEqual(decide(model(upd({ status: 'error', error: 'x' }), {}), quiet, {}), { show: false, why: 'strip' });
// urgent cannot be dismissed
const urgent = model(upd({ status: 'downloading', progress: 0.2, urgent: true, urgent_text: 'Consensus upgrade.' }), {});
assert.deepEqual(decide(urgent, quiet, { later: urgent.key }), { show: true, why: 'urgent' });
assert.deepEqual(decide(null, quiet, {}), { show: false, why: 'none' });
});