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
11 KiB
HTML
85 lines
11 KiB
HTML
<style>
|
|
.qcard { padding: var(--space-4) var(--space-5); display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(260px, 1.1fr); gap: var(--space-4); align-items: start; }
|
|
.qcard.is-done { opacity: .55; }
|
|
.qcard .what { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
|
.qcard h3 { margin: 0; font-size: 15px; }
|
|
.fixes { display: flex; flex-direction: column; gap: var(--space-3); }
|
|
.fix { display: flex; flex-direction: column; gap: 6px; }
|
|
.fix .lab { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps); color: var(--status-warning); font-weight: var(--fw-bold); }
|
|
.sug { display: flex; gap: 10px; align-items: center; padding: 8px 10px; border-radius: var(--radius-md); border: 1px dashed var(--text-accent); background: var(--accent-wash); font-size: 13px; }
|
|
.batch { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; padding: var(--space-3) var(--space-4); }
|
|
.chk { width: 20px; height: 20px; accent-color: var(--text-accent); }
|
|
.filters { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: center; }
|
|
.filters .select, .filters .input { width: auto; min-height: 36px; padding: 6px 10px; font-size: 13px; }
|
|
.jobcell { min-width: 200px; }
|
|
@media (max-width: 900px) { .qcard { grid-template-columns: auto minmax(0, 1fr); } .qcard .fixes { grid-column: 1 / -1; } }
|
|
@media (max-width: 760px) { .hide-m { display: none; } .jobcell { min-width: 0; } }
|
|
</style>
|
|
<script>
|
|
(function () {
|
|
var x = X;
|
|
var q = new URLSearchParams(location.search);
|
|
var S = { tab: q.get('tab') || (x.printsNeeding().length ? 'needs' : 'all'), printer: '', status: '', q: '', sel: {}, done: {} };
|
|
function fixUI(p) {
|
|
var out = '';
|
|
if (p.grams == null) out += '<div class="fix"><span class="lab">! Filament weight</span><span class="row" style="gap:8px"><input class="input num" style="max-width:130px;min-height:36px" placeholder="grams" inputmode="numeric" aria-label="Grams used by ' + x.esc(p.job) + '"><button class="btn btn-sm" type="button" data-done="' + p.id + '" data-msg="Saved weight for ' + x.esc(p.job) + ' · remembered for ' + x.esc(p.key) + '">Save</button></span><span class="sub">From the slicer. Remembered for this gcode path.</span></div>';
|
|
var slots = p.filaments ? p.filaments.filter(function (f) { return f.spool == null; }) : (p.spool == null ? [{ suggested: p.suggested, slot: null, grams: p.grams }] : []);
|
|
slots.forEach(function (f) {
|
|
var s = f.suggested && x.spool(f.suggested);
|
|
out += '<div class="fix"><span class="lab">! Spool' + (f.slot != null ? ' for color ' + (f.slot + 1) + ' <span class="swatch swatch-sm" style="vertical-align:-3px"><i style="background:' + f.hex + '"></i></span>' : '') + '</span>' +
|
|
(s ? '<div class="sug">' + x.sw(s.c, 'swatch-sm') + '<span style="flex:1;min-width:0"><b>' + x.esc(x.short(s)) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">Same color, loaded in ' + x.esc(x.where(s).short) + '. Not charged until you confirm.</span></span><button class="btn btn-sm btn-primary" type="button" data-done="' + p.id + '" data-msg="Attributed ' + x.esc(p.job) + ' to ' + x.esc(x.short(s)) + (f.grams ? ' · ' + x.g(f.grams) + ' deducted' : '') + '">Use</button></div>' : '') +
|
|
'<select class="select" style="min-height:36px" aria-label="Pick spool"><option>' + (s ? 'Or pick another…' : 'Pick a spool…') + '</option>' + SPOOLS.slice(0, 10).map(function (sp) { return '<option>' + x.g(sp.rem) + ' · ' + x.esc(x.short(sp)) + ' · ' + sp.mat + '</option>'; }).join('') + '</select></div>';
|
|
});
|
|
return out;
|
|
}
|
|
function needsTab() {
|
|
var l = x.printsNeeding();
|
|
if (!l.length) return '<div class="nm-raised empty"><h3>✓ Every print is attributed</h3><p>New prints appear here when they end without a spool or weight.</p></div>';
|
|
var nsel = Object.keys(S.sel).filter(function (k) { return S.sel[k]; }).length;
|
|
return '<div class="batch nm-inset"><label class="row small" style="gap:8px"><input type="checkbox" class="chk" data-selall ' + (nsel === l.length ? 'checked' : '') + '> Select all ' + l.length + '</label><span class="spacer"></span><span class="small muted">' + nsel + ' selected</span><button class="btn btn-sm" type="button" ' + (nsel ? 'data-fake="Attributed ' + nsel + ' prints to one spool"' : 'disabled') + '>Attribute selected to one spool…</button></div>' +
|
|
'<p class="small muted" style="margin:0">Oldest first. Until these have a spool and a weight, nothing is deducted and cost stays incomplete.</p>' +
|
|
'<div class="stack" style="gap:12px">' + l.slice().reverse().map(function (p) {
|
|
var pr = x.printer(p.printer), done = S.done[p.id];
|
|
return '<article class="qcard nm-raised' + (done ? ' is-done' : '') + '"><input type="checkbox" class="chk" data-sel="' + p.id + '" aria-label="Select ' + x.esc(p.job) + '" ' + (S.sel[p.id] ? 'checked' : '') + '>' +
|
|
'<div class="what"><h3><button class="linkish" type="button" data-print="' + p.id + '" style="text-decoration:none;color:var(--text-primary)">' + x.esc(p.job) + '</button></h3><span class="sub">' + x.esc(pr.name) + ' · ' + x.when(p.end || p.start) + '</span><span class="row small" style="gap:12px;margin-top:4px">' + x.printSt(p) + '<span>' + x.durCell(p).replace(/<span class="sub[^"]*">/g, ' <span class="sub" style="display:inline">') + '</span>' + (p.grams != null ? '<span><b>' + x.g(p.grams) + '</b> ' + x.weightProv(p) + '</span>' : '') + '</span>' +
|
|
(p.filaments ? '<span class="sub">' + p.filaments.length + ' colors · ' + p.filaments.filter(function (f) { return f.spool; }).length + ' attributed</span>' : '') + '</div>' +
|
|
'<div class="fixes">' + (done ? '<span class="st ok">Done</span>' : fixUI(p)) + '</div></article>';
|
|
}).join('') + '</div>';
|
|
}
|
|
function allTab(filter) {
|
|
var l = PRINTS.filter(function (p) { return filter(p) && (!S.printer || p.printer === +S.printer) && (!S.status || p.status === S.status) && (!S.q || (p.job + ' ' + p.key).toLowerCase().indexOf(S.q.toLowerCase()) >= 0); });
|
|
var head = '<div class="filters"><input class="input" id="pq" type="search" placeholder="Job name or path…" value="' + x.esc(S.q) + '" aria-label="Filter prints"><select class="select" id="pp" aria-label="Printer"><option value="">All printers</option>' + PRINTERS.map(function (p) { return '<option value="' + p.id + '"' + (+S.printer === p.id ? ' selected' : '') + '>' + x.esc(p.name) + '</option>'; }).join('') + '</select>' +
|
|
'<select class="select" id="ps" aria-label="Status"><option value="">Any status</option>' + ['running', 'finished', 'failed', 'unknown'].map(function (s) { return '<option' + (S.status === s ? ' selected' : '') + '>' + s + '</option>'; }).join('') + '</select><span class="small muted">' + l.length + ' prints</span></div>';
|
|
if (!l.length) return head + '<div class="nm-raised empty"><h3>No prints match</h3><p>Change the printer, status or search.</p></div>';
|
|
return head + '<section class="nm-raised table-card"><div class="table-scroll"><table class="tbl"><thead><tr><th>Job</th><th>Status</th><th class="r hide-m">Duration</th><th class="r">Filament</th><th class="hide-m">Spool</th><th class="r hide-m hide-t">Cost</th><th class="hide-m hide-t">Use</th></tr></thead><tbody>' +
|
|
l.map(function (p) { var pr = x.printer(p.printer), n = x.needs(p); return '<tr class="clickable" tabindex="0" data-row-print="' + p.id + '"><td><div class="jobcell"><span class="strong">' + x.esc(p.job) + '</span><span class="sub">' + x.esc(pr.model) + ' · ' + x.when(p.start) + '</span></div></td><td>' + x.printSt(p) + (n.length ? '<span class="sub prov warn"><i>!</i>needs ' + n.map(function (i) { return i.k; }).join(' + ') + '</span>' : '') + '</td><td class="r hide-m">' + x.durCell(p) + '</td><td class="r">' + (p.grams == null ? '<span class="muted">—</span>' : '<b class="num">' + x.g(p.grams) + '</b><span class="sub">' + x.weightProv(p) + '</span>') + '</td><td class="hide-m">' + x.spoolLinks(p) + '</td><td class="r hide-m hide-t">' + (p.cost == null ? '<span class="muted">pending</span>' : x.usd(p.cost)) + '</td><td class="hide-m hide-t">' + (p.use ? '<span class="small">' + p.use + '</span>' : '<span class="muted small">not recorded</span>') + '</td></tr>'; }).join('') +
|
|
'</tbody></table></div><div class="table-foot"><span class="muted">Durations marked ≤ span a connection gap and are upper bounds. Hover for why.</span><span class="muted">' + l.length + ' of ' + PRINTS.length + '</span></div></section>';
|
|
}
|
|
function render() {
|
|
var n = x.printsNeeding().length, run = PRINTS.filter(function (p) { return p.status === 'running'; }).length;
|
|
var body = S.tab === 'needs' ? needsTab() : S.tab === 'all' ? allTab(function () { return true; }) : S.tab === 'running' ? allTab(function (p) { return p.status === 'running'; }) : '<div class="nm-raised empty"><h3>No archived prints</h3><p>Archiving hides a print from history. Its grams and cost still count.</p></div>';
|
|
return '<header class="page-head"><div><h1>Prints</h1><p class="sum"><b>' + PRINTS.length + '</b> captured (sample) · <b>' + run + '</b> running · <b>' + n + '</b> need attention</p></div></header>' +
|
|
'<div class="tabs" role="tablist" aria-label="Print lists">' + [['needs', 'Needs attention', n, 1], ['all', 'All', PRINTS.length], ['running', 'Running', run], ['archived', 'Archived', 0]].map(function (t) { return '<button role="tab" type="button" data-ptab="' + t[0] + '" aria-selected="' + (S.tab === t[0]) + '">' + t[1] + ' <span class="n' + (t[3] && t[2] ? ' warn' : '') + '">' + t[2] + '</span></button>'; }).join('') + '</div>' +
|
|
'<div class="stack" style="gap:16px">' + body + '</div>';
|
|
}
|
|
function redraw(focusId) {
|
|
var pos = focusId && document.getElementById(focusId) && document.getElementById(focusId).selectionStart;
|
|
document.getElementById('page').innerHTML = render();
|
|
if (focusId && document.getElementById(focusId)) { var el = document.getElementById(focusId); el.focus(); if (pos != null) el.setSelectionRange(pos, pos); }
|
|
history.replaceState(history.state, '', location.pathname + '?tab=' + S.tab + location.hash);
|
|
}
|
|
document.addEventListener('click', function (e) {
|
|
var t = e.target, a;
|
|
if ((a = t.closest('[data-ptab]'))) { S.tab = a.getAttribute('data-ptab'); return redraw(); }
|
|
if ((a = t.closest('[data-done]'))) { var id = +a.getAttribute('data-done'), pp = x.print(id); if (/weight/.test(a.getAttribute('data-msg'))) { pp.grams = 18; pp.wsrc = 'typed'; } else if (pp.filaments) { pp.filaments.forEach(function (f) { if (f.spool == null) f.spool = f.suggested || 52; }); } else { pp.spool = pp.suggested || 52; } X.toast(a.getAttribute('data-msg') + ' <a href="#print=' + id + '" data-print="' + id + '">View</a>'); return redraw(); }
|
|
if (t.matches('[data-selall]')) { x.printsNeeding().forEach(function (p) { S.sel[p.id] = t.checked; }); return redraw(); }
|
|
if (t.matches('[data-sel]')) { S.sel[t.getAttribute('data-sel')] = t.checked; return redraw(); }
|
|
if (t.closest('button, a, input, select')) return;
|
|
if ((a = t.closest('[data-row-print]'))) x.openDrawer('print', +a.getAttribute('data-row-print'));
|
|
});
|
|
document.addEventListener('input', function (e) { if (e.target.id === 'pq') { S.q = e.target.value; redraw('pq'); } });
|
|
document.addEventListener('change', function (e) { if (e.target.id === 'pp') { S.printer = e.target.value; redraw(); } if (e.target.id === 'ps') { S.status = e.target.value; redraw(); } });
|
|
X.init({ name: 'prints', refresh: function () { redraw(); } }, render);
|
|
})();
|
|
</script>
|