Tokens (light + dark, neumorphism, 54/54 WCAG AA contrast pairs), the self-hosted JetBrains Mono fonts, and interactive mockups of every screen across three iterations. Iteration 3 is final: phone, tablet and desktop tiers, device preview, and "empty spool weight" terminology. The build scripts regenerate the screens byte-identically from source. Implementation is tracked in Maestro feature UI Refresh - V3.0 (224b72a1-8d84-4970-97ea-5dafa4d8ea3b). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
85 lines
13 KiB
HTML
85 lines
13 KiB
HTML
<style>
|
|
.jump { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }
|
|
.jump a { display: inline-flex; gap: 8px; align-items: center; padding: 6px 12px; border-radius: var(--radius-pill); border: 1px solid var(--border-control); text-decoration: none; color: var(--text-primary); font-size: 13px; background: var(--surface-raised); box-shadow: var(--nm-raised-1); }
|
|
.jump a:hover { border-color: var(--text-accent); }
|
|
.pgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: var(--space-6); align-items: start; }
|
|
.pcard { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); scroll-margin-top: 16px; }
|
|
.pcard.attn-card { border: 2px solid var(--status-warning); }
|
|
.phead { display: flex; gap: var(--space-3); align-items: flex-start; }
|
|
.phead h2 { margin: 0; font-size: var(--fs-h3); line-height: var(--lh-snug); }
|
|
.now { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px var(--space-3); padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); width: 100%; font-size: 13px; }
|
|
.now:hover { border-color: var(--text-accent); }
|
|
.now:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
|
|
.now.stale { border: 1px dashed var(--status-warning); }
|
|
.cam { aspect-ratio: 16 / 7; border-radius: var(--radius-md); background: radial-gradient(ellipse at 50% 95%, #5a4b3a 0 30%, transparent 31%), linear-gradient(180deg, #1c1d22 0%, #2b2c33 60%, #3a3326 100%); border: 1px solid var(--border-control); position: relative; overflow: hidden; }
|
|
.cam::after { content: ""; position: absolute; left: 34%; right: 34%; bottom: 18%; height: 26%; background: repeating-linear-gradient(0deg, #6b8fd6 0 3px, #5a7cc2 3px 6px); border-radius: 4px 4px 0 0; }
|
|
.cam .stamp { position: absolute; left: 8px; top: 6px; font-size: var(--fs-caption); color: #f4f4f6; background: rgba(0,0,0,.55); padding: 2px 8px; border-radius: 99px; z-index: 1; }
|
|
.cam-off { padding: var(--space-3); border-radius: var(--radius-md); border: 1px dashed var(--border-control); text-align: center; color: var(--text-secondary); font-size: var(--fs-label); }
|
|
.holder { padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); display: flex; flex-direction: column; gap: 6px; }
|
|
.holder-h { display: flex; gap: var(--space-3); align-items: center; font-size: var(--fs-label); }
|
|
.fill { display: inline-flex; gap: 3px; }
|
|
.fill i { width: 14px; height: 8px; border-radius: 2px; border: 1px solid var(--border-control); }
|
|
.fill i.on { background: var(--text-accent); border-color: var(--text-accent); }
|
|
.hrow { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; padding: 6px 8px; margin: 0 -8px; border-radius: var(--radius-sm); font-size: 13px; }
|
|
.hrow:hover { background: var(--accent-wash); }
|
|
.hrow:focus-visible { outline: var(--focus-width) solid var(--focus-ring); }
|
|
.lost { padding: var(--space-4); border-radius: var(--radius-md); border: 1px solid var(--status-warning); background: var(--wash-warning); font-size: 13px; display: flex; flex-direction: column; gap: 6px; }
|
|
.lost h3 { margin: 0; font-size: 14px; color: var(--status-warning); }
|
|
.lost ul { margin: 0; padding-left: 18px; }
|
|
details.cfg summary { cursor: pointer; font-size: var(--fs-label); font-weight: var(--fw-bold); }
|
|
.future { padding: var(--space-6); border: 2px dashed var(--border-control); border-radius: var(--radius-lg); }
|
|
.fgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: var(--space-4); margin-top: var(--space-4); }
|
|
.fitem { padding: var(--space-4); border: 1px dashed var(--border-control); border-radius: var(--radius-md); font-size: var(--fs-label); display: flex; flex-direction: column; gap: 6px; }
|
|
.fitem b { font-size: 13px; }
|
|
body.dev-only [data-feature] { display: none !important; }
|
|
body:not(.dev-only) [data-devonly] { display: none !important; }
|
|
</style>
|
|
<script>
|
|
(function () {
|
|
var x = X, FEAT = '<span class="prov info" title="Only on the printer-connections feature branch"><i>◈</i>feature</span>';
|
|
function lastPrint(pr) { return PRINTS.filter(function (p) { return p.printer === pr.id && p.status !== 'running'; })[0]; }
|
|
function holder(c) {
|
|
var sp = x.contents(c.id), free = c.cap - sp.length;
|
|
return '<div class="holder"><div class="holder-h"><b>' + x.esc(c.label) + '</b><span class="fill" aria-label="' + sp.length + ' of ' + c.cap + ' in use">' + Array.apply(null, Array(c.cap)).map(function (_, i) { return '<i class="' + (i < sp.length ? 'on' : '') + '"></i>'; }).join('') + '</span><span class="muted">' + sp.length + ' / ' + c.cap + '</span><span class="spacer"></span>' +
|
|
(free > 0 ? '<button class="btn btn-sm" type="button" data-load-into="' + c.id + '" style="min-height:28px;padding:3px 10px">+ Load</button>' : '<span class="sub">full</span>') + '</div>' +
|
|
(sp.length ? sp.map(function (s) { return '<button class="hrow" type="button" data-spool="' + s.id + '">' + x.sw(s.c, 'swatch-sm') + '<span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><b>' + x.esc(s.color) + '</b> <span class="muted">' + x.esc(s.brand + ' ' + s.mat + (s.type ? ' ' + s.type : '')) + '</span></span><span class="num' + (x.low(s) ? '" style="color:var(--status-warning);font-weight:700">⚠ ' : '">') + x.g(s.rem) + '</span></button>'; }).join('') : '<span class="sub">Empty</span>') + '</div>';
|
|
}
|
|
function card(pr) {
|
|
var open = pr.openPrint && x.print(pr.openPrint), last = lastPrint(pr), lostC = !pr.connected;
|
|
var conn = lostC
|
|
? '<div class="lost" role="status"><h3>⚡ Connection lost</h3><span data-feature>Reconnecting. Last contact <b>' + x.ago(pr.age) + '</b>. ' + FEAT + '</span><span data-devonly>Extrudex\'s MQTT session dropped. When it dropped isn\'t recorded on dev.</span>' +
|
|
'<ul class="small"><li>The printer may still be printing. This is Extrudex\'s connection, not the printer\'s power.</li><li><b>Capture is paused.</b> A job that starts and ends during the outage won\'t be recorded.</li><li>On reconnect: if <i>' + x.esc(pr.job) + '</i> is still running, it stays open with <b>+1 gap</b>; otherwise it closes as <b>unknown</b>.</li></ul></div>'
|
|
: '<div class="row small" style="gap:8px"><span class="fresh"><span class="dot"></span></span><span data-feature>Ready · last report ' + x.ago(pr.age) + ' ' + FEAT + '</span><span data-devonly>Connected</span></div>';
|
|
var now = open
|
|
? '<button class="now' + (pr.stale ? ' stale' : '') + '" type="button" data-print="' + open.id + '"><b>' + x.esc(open.job) + '</b><span class="num">' + x.dur(x.since(open.start)) + '</span>' + (pr.stale ? '<span class="sub" style="grid-column:1/-1">open record · started ' + x.when(open.start).split(' ').pop() + ' · watched until the drop</span><span class="sub" data-feature style="grid-column:1/-1"><span class="prov warn" style="white-space:normal">Last known: printing · ' + x.ago(pr.age) + '. Cached from the lost session, not live.</span></span>' : '<span class="sub">started ' + x.when(open.start).split(' ').pop() + ' · weight read from the 3MF when it ends</span><span class="sub">so far</span>') + '</button>'
|
|
: (last ? '<button class="now" type="button" data-print="' + last.id + '"><span><span class="sub" style="display:inline">Last · </span><b>' + x.esc(last.job) + '</b></span>' + x.printSt(last) + '<span class="sub">ended ' + x.when(last.end) + '</span><span class="sub">' + x.durCell(last).replace(/<span class="sub[^"]*">[^<]*<\/span>/, '') + '</span></button>' : '');
|
|
var cam = pr.connected ? '<div class="cam" role="img" aria-label="Camera snapshot from ' + x.esc(pr.name) + '"><span class="stamp">snapshot 09:41:0' + pr.id + '</span></div>' : '<div class="cam-off">Camera unavailable while disconnected</div>';
|
|
return '<article class="pcard nm-raised' + (lostC ? ' attn-card' : '') + '" id="printer-' + pr.id + '" aria-labelledby="ph' + pr.id + '"><div class="phead"><div style="flex:1;min-width:0"><h2 id="ph' + pr.id + '">' + x.esc(pr.name) + '</h2><span class="sub">Bambu Lab · ' + pr.model + '</span></div>' + x.printerSt(pr) +
|
|
'<div class="menu-wrap"><button class="btn btn-sm btn-icon" type="button" data-menu="pm' + pr.id + '" aria-haspopup="menu" aria-expanded="false" aria-label="Actions for ' + x.esc(pr.name) + '">⋯</button><div class="menu" id="pm' + pr.id + '" role="menu"><button role="menuitem" type="button" data-pedit="' + pr.id + '">Edit connection</button><button role="menuitem" type="button" data-holders="' + pr.id + '">Manage holders</button><hr><button role="menuitem" type="button" class="danger" data-premove="' + pr.id + '">Remove printer…</button></div></div></div>' +
|
|
conn + now + cam + '<div class="stack" style="gap:8px"><div class="row small"><b>Loaded filament</b><span class="sub" style="display:inline">recorded by Extrudex, not read from the printer</span></div>' + pr.containers.map(holder).join('') + '</div>' +
|
|
'<details class="cfg"><summary>Connection details</summary><dl class="kv" style="margin-top:10px"><dt>IP</dt><dd>' + x.esc(pr.ip) + '</dd><dt>Serial</dt><dd>' + x.esc(pr.serial) + '</dd><dt>Access code</dt><dd>✓ stored, never shown</dd>' +
|
|
'<dt data-feature>Lifecycle</dt><dd data-feature>' + pr.lifecycle + (pr.stale ? ' · <code>stale</code>' : '') + ' ' + FEAT + '</dd><dt data-feature>Session</dt><dd data-feature>#' + pr.session + ' (increments on reconnect)</dd></dl></details></article>';
|
|
}
|
|
function render() {
|
|
var order = PRINTERS.slice().sort(function (a, b) { var r = function (p) { return !p.connected ? 0 : p.state === 'RUNNING' ? 1 : 2; }; return r(a) - r(b); });
|
|
var nRun = PRINTERS.filter(function (p) { return p.connected && p.state === 'RUNNING'; }).length, nDown = PRINTERS.filter(function (p) { return !p.connected; }).length;
|
|
return '<header class="page-head"><div><h1>Printers</h1><p class="sum"><b>' + PRINTERS.length + '</b> printers · <b>' + nRun + '</b> printing · <b style="color:var(--status-warning)">' + nDown + '</b> disconnected · polled every 10 s, read-only MQTT</p></div>' +
|
|
'<div class="actions"><div class="tabs" role="group" aria-label="Data shown"><button type="button" data-ds="dev" aria-selected="' + document.body.classList.contains('dev-only') + '">dev today</button><button type="button" data-ds="feat" aria-selected="' + !document.body.classList.contains('dev-only') + '">+ feature branch</button></div><button class="btn btn-primary primary-desktop" type="button" data-pedit="new">+ Add printer</button></div></header>' +
|
|
'<nav class="jump" aria-label="Jump to printer"><span class="small muted">Jump to</span>' + order.map(function (p) { return '<a href="#printer=' + p.id + '">' + x.printerSt(p) + ' ' + x.esc(p.model) + '</a>'; }).join('') + '</nav>' +
|
|
'<div class="pgrid">' + order.map(card).join('') + '</div>' +
|
|
'<section class="future" aria-labelledby="fut-h"><h2 id="fut-h" style="margin:0;font-size:var(--fs-h3)"><span class="chip chip-neutral chip-dashed">Future, not built</span> Needs data Extrudex doesn\'t store</h2><div class="fgrid">' +
|
|
[['Progress % and time left', '<code>mc_percent</code> is parsed but not stored or served. ETA isn\'t read.'], ['Tray position in the AMS', 'Holders store which spools are in them, not which tray. The AMS tray index isn\'t recorded.'], ['Which spool is feeding now', '"Loaded" is recorded; "in use" isn\'t. Hence "not confirmed in use".'], ['Nozzle / bed temperature, layer', 'Not parsed from the report.'], ['AMS remaining % and humidity', 'Not read. <code>remain</code> only means something for RFID spools.'], ['Alert when MQTT drops', 'Only in container logs today. Dev doesn\'t store when it dropped.']].map(function (f) { return '<div class="fitem"><b>' + f[0] + '</b><span class="muted">' + f[1] + '</span></div>'; }).join('') + '</div></section>';
|
|
}
|
|
function focusPrinter() {
|
|
var m = /^#printer=(\d+)/.exec(location.hash); if (!m) return;
|
|
var el = document.getElementById('printer-' + m[1]); if (!el) return;
|
|
el.scrollIntoView({ block: 'start', behavior: 'smooth' }); el.classList.remove('is-flash'); void el.offsetWidth; el.classList.add('is-flash');
|
|
}
|
|
document.addEventListener('click', function (e) {
|
|
var a = e.target.closest('[data-ds]'); if (a) { document.body.classList.toggle('dev-only', a.getAttribute('data-ds') === 'dev'); document.getElementById('page').innerHTML = render(); }
|
|
});
|
|
window.addEventListener('hashchange', focusPrinter);
|
|
X.init({ name: 'printers', after: function () { setTimeout(focusPrinter, 60); }, refresh: function () { document.getElementById('page').innerHTML = render(); } }, render);
|
|
})();
|
|
</script>
|