Add the Extrudex Design System v3 bundle
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9ba0d409c8
commit
d9ed1898b9
@@ -0,0 +1,553 @@
|
||||
/* Iteration-2 shared behaviour: shell, navigation badges, command palette,
|
||||
* drawers (spool / print / load), toasts, freshness and demo states.
|
||||
* Plain browser JS, no dependencies, works offline. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────── */
|
||||
var $ = function (s, r) { return (r || document).querySelector(s); };
|
||||
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
|
||||
function g(n) { return n == null ? '—' : Math.round(n).toLocaleString('en-US') + ' g'; }
|
||||
function kg(n) { return (n / 1000).toFixed(2) + ' kg'; }
|
||||
function usd(n) { return n == null ? '—' : '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
|
||||
function dur(s) { if (s == null) return '—'; if (s < 60) return '<1m'; var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + 'h ' + String(m).padStart(2, '0') + 'm' : m + 'm'; }
|
||||
function when(iso) {
|
||||
if (!iso) return '—';
|
||||
var d = new Date(iso), t = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
var days = Math.round((new Date(NOW.toDateString()) - new Date(d.toDateString())) / 864e5);
|
||||
if (days === 0) return 'Today ' + t;
|
||||
if (days === 1) return 'Yesterday ' + t;
|
||||
return d.getDate() + ' ' + d.toLocaleString('en-GB', { month: 'short' }) + ' ' + t;
|
||||
}
|
||||
function ago(sec) { if (sec < 60) return sec + 's ago'; var m = Math.floor(sec / 60); if (m < 60) return m + 'm ago'; var h = Math.floor(m / 60); return h < 24 ? h + 'h ago' : Math.floor(h / 24) + 'd ago'; }
|
||||
function since(iso) { return Math.max(0, Math.round((NOW - new Date(iso)) / 1000)); }
|
||||
|
||||
var byId = function (arr, id) { for (var i = 0; i < arr.length; i++) if (arr[i].id === id) return arr[i]; return null; };
|
||||
function spool(id) { return byId(SPOOLS, id); }
|
||||
function printer(id) { return byId(PRINTERS, id); }
|
||||
function print(id) { return byId(PRINTS, id); }
|
||||
function container(cid) {
|
||||
for (var i = 0; i < PRINTERS.length; i++) for (var j = 0; j < PRINTERS[i].containers.length; j++)
|
||||
if (PRINTERS[i].containers[j].id === cid) return { printer: PRINTERS[i], c: PRINTERS[i].containers[j] };
|
||||
return null;
|
||||
}
|
||||
function contents(cid) { return SPOOLS.filter(function (s) { return s.cont === cid; }); }
|
||||
function fullName(s) { return [s.brand, s.speed, s.mat, s.type, s.fin].filter(Boolean).join(' '); }
|
||||
function short(s) { return s.brand + ' ' + s.color; }
|
||||
function where(s) {
|
||||
if (!s.cont) return { loaded: false, text: s.loc || 'Storage', short: s.loc || 'Storage' };
|
||||
var c = container(s.cont);
|
||||
return { loaded: true, text: c.printer.name + ' · ' + c.c.label, short: c.printer.model + ' · ' + c.c.label, printer: c.printer };
|
||||
}
|
||||
/* Unknown, or an estimated figure nobody weighed: the weigh-in would rest on a guess. */
|
||||
function unverified(s) { return s.tare == null || s.tsrc === 'estimated'; }
|
||||
function gross(s) { return s.tare == null ? null : s.tare + s.rem; }
|
||||
function value(s) { return s.rem / 1000 * s.kg; }
|
||||
function low(s) { return s.rem < LOW; }
|
||||
function sw(colors, cls) { return '<span class="swatch ' + (cls || '') + '" aria-hidden="true">' + colors.map(function (h) { return '<i style="background:' + h + '"></i>'; }).join('') + '</span>'; }
|
||||
function meter(s) {
|
||||
var pct = Math.max(0, Math.min(100, s.rem / s.tot * 100)), tick = Math.min(100, LOW / s.tot * 100);
|
||||
return '<div class="meter' + (low(s) ? ' is-low' : '') + '" role="meter" aria-valuemin="0" aria-valuemax="' + s.tot + '" aria-valuenow="' + s.rem + '" aria-label="' + esc(s.color) + ' remaining"><span style="width:' + pct + '%"></span><i class="tick" style="left:' + tick + '%" title="200 g low-stock line"></i></div>';
|
||||
}
|
||||
|
||||
/* provenance: quiet icon + words */
|
||||
var TSRC = { spool: ['⚖', 'weighed bare', ''], manufacturer: ['◆', 'manufacturer', ''], community: ['◇', 'community', ''], estimated: ['≈', 'estimated', 'warn'] };
|
||||
function tareProv(s) {
|
||||
if (s.tare == null) return '<span class="prov warn"><i>?</i>empty spool weight unknown</span>';
|
||||
var t = TSRC[s.tsrc]; return '<span class="prov ' + t[2] + '" title="' + esc(s.tsrc === 'spool' ? 'This reel was weighed empty' : 'From spool type: ' + s.stype) + '"><i>' + t[0] + '</i>' + t[1] + '</span>';
|
||||
}
|
||||
function remProv(s) { return s.remSrc === 'weighed' ? '<span class="prov"><i>⚖</i>weighed' + (s.weighedAt ? ' ' + esc(s.weighedAt) : '') + '</span>' : '<span class="prov"><i>✎</i>typed</span>'; }
|
||||
var WSRC = { typed: ['✎', 'typed', '', 'A measurement someone entered. Survives an identity correction.'], sliced: ['▦', 'sliced', 'info', "Read from this run's 3MF over FTPS."], recalled: ['⎘', 'recalled', 'info', 'Copied from the weight remembered for this gcode path. No longer written.'], unknown: ['?', 'source unknown', 'warn', 'Written before the source was recorded. Neither correction path will move it.'] };
|
||||
function weightProv(p) { if (!p.wsrc) return ''; var w = WSRC[p.wsrc]; return '<span class="prov ' + w[2] + '" title="' + esc(w[3]) + '"><i>' + w[0] + '</i>' + w[1] + '</span>'; }
|
||||
function durCell(p) {
|
||||
if (p.status === 'running') return '<span class="num">' + dur(since(p.start)) + '</span><span class="sub">so far</span>';
|
||||
if (p.status === 'unknown') return '<span class="muted" title="Nobody saw this print end. The only measurable figure is the outage, which is not how long anything printed.">no duration</span><span class="sub">end not seen</span>';
|
||||
if (p.gaps) return '<span class="num strong" style="color:var(--status-warning)" title="The connection dropped ' + (p.gaps === 1 ? 'once' : p.gaps + ' times') + ' during this print. That time is inside the figure and nobody watched it, so it is an upper bound.">≤ ' + dur(p.dur) + '</span><span class="sub prov warn">upper bound · ' + p.gaps + ' gap' + (p.gaps > 1 ? 's' : '') + '</span>';
|
||||
if (p.gaps == null) return '<span class="num">' + dur(p.dur) + '</span><span class="sub" title="Predates gap counting: nobody asked whether the connection dropped.">gaps not counted</span>';
|
||||
return '<span class="num">' + dur(p.dur) + '</span>';
|
||||
}
|
||||
var PST = { running: ['run', 'Running'], finished: ['ok', 'Finished'], failed: ['err', 'Failed'], unknown: ['warn', 'Unknown'] };
|
||||
function printSt(p) { var s = PST[p.status]; return '<span class="st ' + s[0] + '">' + s[1] + '</span>'; }
|
||||
function printerSt(pr) {
|
||||
if (!pr.connected) return '<span class="st warn">' + (pr.lifecycle === 'reconnecting' ? 'Reconnecting' : 'Offline') + '</span>';
|
||||
if (pr.state === 'RUNNING') return '<span class="st run">Printing</span>';
|
||||
if (pr.state === 'PAUSE') return '<span class="st warn">Paused</span>';
|
||||
if (pr.state === 'FAILED') return '<span class="st err">Failed</span>';
|
||||
return '<span class="st idle">Idle</span>';
|
||||
}
|
||||
/* what a print still needs; empty while running */
|
||||
function needs(p) {
|
||||
if (p.status === 'running') return [];
|
||||
var n = [];
|
||||
if (p.grams == null) n.push({ k: 'weight', t: 'filament weight' });
|
||||
if (p.filaments) p.filaments.forEach(function (f) { if (f.spool == null) n.push({ k: 'spool', t: 'spool for color ' + (f.slot + 1), f: f }); });
|
||||
else if (p.spool == null) n.push({ k: 'spool', t: 'spool' });
|
||||
return n;
|
||||
}
|
||||
function printsNeeding() { return PRINTS.filter(function (p) { return needs(p).length && !p.archived; }); }
|
||||
function spoolLinks(p) {
|
||||
if (p.filaments) return p.filaments.map(function (f) { var s = f.spool && spool(f.spool); return s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '">' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '</a>' : '<span class="prov warn"><i>!</i>color ' + (f.slot + 1) + ' unattributed</span>'; }).join('<br>');
|
||||
var s = p.spool && spool(p.spool);
|
||||
return s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '" class="nowrap">' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '</a>' : (p.status === 'running' ? '<span class="muted">—</span>' : '<span class="prov warn"><i>!</i>no spool</span>');
|
||||
}
|
||||
|
||||
/* ── shell ───────────────────────────────────────────────── */
|
||||
var ICON = {
|
||||
dashboard: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="2"/><rect x="14" y="3" width="7" height="5" rx="2"/><rect x="14" y="12" width="7" height="9" rx="2"/><rect x="3" y="16" width="7" height="5" rx="2"/></svg>',
|
||||
filament: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v6M12 15v6"/></svg>',
|
||||
prints: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 20h14M7 16l3-9h4l3 9M9 12h6"/></svg>',
|
||||
printers: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 17h8M9 7h6v5H9z"/></svg>',
|
||||
lists: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/></svg>',
|
||||
labels: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="7.5" r="1.5"/></svg>',
|
||||
access: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0M17 11l2 2 4-4"/></svg>',
|
||||
system: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h7"/><circle cx="18" cy="16" r="3"/></svg>',
|
||||
more: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>',
|
||||
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></svg>',
|
||||
sun: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>'
|
||||
};
|
||||
var MANAGE = [['lists', 'Lists', '05-lists.html', 0], ['labels', 'Labels', '06-labels.html', 0], ['access', 'Access', '07-access.html', 1], ['system', 'System', '08-system.html', 1]];
|
||||
var PAGES = [['dashboard', 'Dashboard', '01-dashboard.html'], ['filament', 'Filament', '02-filament.html'], ['prints', 'Prints', '03-prints.html'], ['printers', 'Printers', '04-printers.html']];
|
||||
function badges() {
|
||||
var need = printsNeeding().length, down = PRINTERS.filter(function (p) { return !p.connected; }).length;
|
||||
var unv = SPOOLS.filter(unverified), unk = unv.filter(function (s) { return s.tare == null; }).length;
|
||||
return {
|
||||
filament: unv.length ? '<span class="count count-warn" title="' + unv.length + ' spools with an unverified empty spool weight: ' + unk + ' unknown, ' + (unv.length - unk) + ' estimated">' + unv.length + '</span>' : '',
|
||||
prints: need ? '<span class="count count-warn" title="' + need + ' prints need a spool or weight">' + need + '</span>' : '',
|
||||
printers: down ? '<span class="count count-err" title="' + down + ' printer not connected">' + down + '</span>' : ''
|
||||
};
|
||||
}
|
||||
function shell(page) {
|
||||
var b = badges();
|
||||
var nav = PAGES.map(function (p) { return '<a href="' + p[2] + '"' + (p[0] === page ? ' aria-current="page"' : '') + '>' + ICON[p[0]] + '<span>' + p[1] + '</span>' + (b[p[0]] || '') + '</a>'; }).join('');
|
||||
var unl = SPOOLS.filter(function (s) { return !s.label; }).length;
|
||||
var rail = '<nav class="rail nm-raised" aria-label="Main">' +
|
||||
'<a class="wordmark" href="01-dashboard.html" style="text-decoration:none;color:inherit"><span class="wordmark-mark" aria-hidden="true">X</span>Extrudex</a>' +
|
||||
'<div class="nav nav-group">' + nav + '</div>' +
|
||||
'<div class="nav nav-group"><span class="nav-label">Manage</span>' +
|
||||
MANAGE.map(function (m) { return '<a href="' + m[2] + '"' + (m[0] === page ? ' aria-current="page"' : '') + '>' + ICON[m[0]] + '<span>' + m[1] + '</span>' + (m[0] === 'labels' ? '<span class="count" style="color:var(--text-secondary)" title="' + unl + ' spools without a printed label">' + unl + '</span>' : m[3] ? '<span class="sub" style="margin-left:auto">admin</span>' : '') + '</a>'; }).join('') + '</div>' +
|
||||
'<div class="rail-foot"><div class="seg" role="group" aria-label="Color theme"><button type="button" data-set-theme="light">Light</button><button type="button" data-set-theme="dark">Dark</button></div>' +
|
||||
'<span class="small muted">Signed in · operator</span></div></nav>';
|
||||
var inManage = MANAGE.some(function (m) { return m[0] === page; });
|
||||
var tabbar = '<nav class="tabbar nm-raised" aria-label="Main">' + PAGES.map(function (p) { return '<a href="' + p[2] + '"' + (p[0] === page ? ' aria-current="page"' : '') + '>' + ICON[p[0]] + (b[p[0]] || '') + '<span>' + p[1] + '</span></a>'; }).join('') +
|
||||
'<a href="#" data-menu="moremenu" aria-haspopup="menu" aria-expanded="false"' + (inManage ? ' aria-current="page"' : '') + '>' + ICON.more + '<span>More</span></a></nav>' +
|
||||
'<div class="menu more-sheet nm-dialog" id="moremenu" role="menu" aria-label="More">' + MANAGE.map(function (m) { return '<a role="menuitem" href="' + m[2] + '">' + ICON[m[0]] + ' ' + m[1] + (m[3] ? '<span class="hint">admin</span>' : '') + '</a>'; }).join('') + '<hr><button role="menuitem" type="button" data-toggle-theme>' + ICON.sun + ' Switch light / dark</button></div>';
|
||||
var top = '<div class="topbar"><button class="search-trigger" type="button" data-palette>' + ICON.search + '<span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"><span class="long">Search spools, prints, printers…</span><span class="short">Search…</span></span><span class="kbd long">⌘K</span></button>' +
|
||||
'<span class="fresh" id="fresh" role="status"><span class="dot" aria-hidden="true"></span><span id="fresh-t">Live · updated 3s ago</span></span>' +
|
||||
'<button class="btn btn-icon btn-sm" type="button" data-toggle-theme aria-label="Switch color theme">' + ICON.sun + '</button></div>';
|
||||
var mock = '<div class="mock"><button type="button" data-menu="mockmenu" aria-expanded="false">Mockup<span class="long"> · v3 iteration 3</span></button><div class="menu" id="mockmenu" role="dialog" aria-label="About this mockup">' +
|
||||
'<p><b>Sample data</b>, based on the real inventory. Nothing is wired to the API.</p>' +
|
||||
'<p><span class="prov info"><i>◈</i>feature</span> fields exist only on the printer-connections branch. <span class="prov warn"><i>⚠</i>API gap</span> means stored but not served.</p><hr>' +
|
||||
'<button type="button" data-demo="loading">Replay first load (skeletons)</button>' +
|
||||
'<button type="button" data-demo="offline">Simulate polling failure</button>' +
|
||||
'<button type="button" data-demo="online">Back to live</button><hr>' +
|
||||
'<a href="index.html">Flow map of all screens →</a></div></div>';
|
||||
var drawer = '<div class="drawer-scrim" data-close-drawer></div><aside class="drawer nm-dialog" id="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title" hidden></aside>';
|
||||
var pal = '<div class="pal-scrim" id="pal" role="presentation"><div class="pal nm-dialog" role="dialog" aria-modal="true" aria-label="Search and commands"><div class="pal-input">' + ICON.search +
|
||||
'<input id="pal-q" role="combobox" aria-expanded="true" aria-controls="pal-list" aria-autocomplete="list" placeholder="Type a spool, #id, job, printer or action…" autocomplete="off"><span class="kbd">esc</span></div>' +
|
||||
'<div class="pal-list" id="pal-list" role="listbox"></div><div class="pal-foot"><span><span class="kbd">↑</span> <span class="kbd">↓</span> move</span><span><span class="kbd">↵</span> open</span><span><span class="kbd">/</span> or <span class="kbd">⌘K</span> anywhere</span></div></div></div>';
|
||||
return { rail: rail, tabbar: tabbar, top: top, rest: mock + drawer + pal + '<div class="toasts" id="toasts" aria-live="polite"></div>' };
|
||||
}
|
||||
|
||||
/* ── theme ───────────────────────────────────────────────── */
|
||||
function setTheme(v) {
|
||||
document.documentElement.setAttribute('data-theme', v);
|
||||
$$('[data-set-theme]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.getAttribute('data-set-theme') === v)); });
|
||||
try { localStorage.setItem('extrudex-v3-theme', v); } catch (e) {}
|
||||
}
|
||||
|
||||
/* ── toasts ──────────────────────────────────────────────── */
|
||||
function updateBadges() {
|
||||
var b = badges();
|
||||
$$('.nav a, .tabbar a').forEach(function (a) {
|
||||
var key = { '02-filament.html': 'filament', '03-prints.html': 'prints', '04-printers.html': 'printers' }[a.getAttribute('href')]; if (!key) return;
|
||||
var old = a.querySelector('.count'); if (old) old.parentNode.removeChild(old);
|
||||
if (b[key]) a.querySelector('span').insertAdjacentHTML(a.closest('.tabbar') ? 'beforebegin' : 'afterend', b[key]);
|
||||
});
|
||||
}
|
||||
function toast(html, kind) {
|
||||
setTimeout(updateBadges, 0);
|
||||
var t = document.createElement('div'); t.className = 'toast ' + (kind || ''); t.setAttribute('role', 'status');
|
||||
t.innerHTML = '<span>' + html + '</span><button class="btn btn-sm btn-icon" type="button" aria-label="Dismiss">✕</button>';
|
||||
$('#toasts').appendChild(t);
|
||||
var kill = function () { if (t.parentNode) t.parentNode.removeChild(t); };
|
||||
t.querySelector('button').onclick = kill; setTimeout(kill, 6000);
|
||||
}
|
||||
|
||||
/* ── drawer ──────────────────────────────────────────────── */
|
||||
var D = { kind: null, id: null, tab: 'overview', state: 'normal', list: null, lastFocus: null, dirty: false };
|
||||
function openDrawer(kind, id, tab, push) {
|
||||
D.kind = kind; D.id = id; D.tab = tab || 'overview'; D.state = 'normal'; D.dirty = false;
|
||||
if (!document.body.classList.contains('drawer-open')) D.lastFocus = document.activeElement;
|
||||
renderDrawer();
|
||||
var el = $('#drawer'); el.hidden = false;
|
||||
requestAnimationFrame(function () { document.body.classList.add('drawer-open'); var f = el.querySelector('[autofocus]') || el.querySelector('h2'); if (f) { f.setAttribute('tabindex', f.tagName === 'H2' ? '-1' : f.getAttribute('tabindex')); f.focus(); } });
|
||||
var h = '#' + kind + '=' + id + (D.tab !== 'overview' ? '&tab=' + D.tab : '');
|
||||
if (push !== false && location.hash !== h) history.pushState({ d: 1 }, '', h);
|
||||
$$('[data-row-spool],[data-row-print]').forEach(function (r) { r.classList.toggle('is-selected', (kind === 'spool' && +r.getAttribute('data-row-spool') === id) || (kind === 'print' && +r.getAttribute('data-row-print') === id)); });
|
||||
}
|
||||
function closeDrawer(push) {
|
||||
if (D.dirty && !confirmDiscard()) return;
|
||||
document.body.classList.remove('drawer-open'); D.kind = null; D.session = false; D.brand = null;
|
||||
setTimeout(function () { if (!D.kind) $('#drawer').hidden = true; }, 240);
|
||||
if (push !== false && location.hash) history.pushState({}, '', location.pathname + location.search);
|
||||
$$('.is-selected').forEach(function (r) { r.classList.remove('is-selected'); });
|
||||
if (D.lastFocus && D.lastFocus.focus) D.lastFocus.focus();
|
||||
}
|
||||
function confirmDiscard() {
|
||||
var bar = $('#discard-bar'); if (bar) { bar.hidden = false; bar.querySelector('button').focus(); return false; }
|
||||
return true;
|
||||
}
|
||||
function renderDrawer() {
|
||||
var el = $('#drawer');
|
||||
if (D.kind === 'spool') el.innerHTML = spoolDrawer(spool(D.id));
|
||||
else if (D.kind === 'new') el.innerHTML = editDrawer(null);
|
||||
else if (D.kind === 'print') el.innerHTML = printDrawer(print(D.id));
|
||||
else if (D.kind === 'load') el.innerHTML = loadDrawer(D.id);
|
||||
else if (D.kind === 'pedit') el.innerHTML = peditDrawer(D.id);
|
||||
else if (D.kind === 'holders') el.innerHTML = holdersDrawer(D.id);
|
||||
else if (D.kind === 'premove') el.innerHTML = premoveDrawer(D.id);
|
||||
if (window.PAGE && PAGE.onDrawer) PAGE.onDrawer();
|
||||
}
|
||||
function head(icon, title, sub, extra) {
|
||||
var list = D.list || [], i = list.indexOf(D.id);
|
||||
var nav = (D.kind === 'spool' && i >= 0 && list.length > 1) ? '<div class="drawer-nav"><button class="btn btn-sm btn-icon" type="button" data-step="-1" aria-label="Previous spool in list" ' + (i === 0 ? 'disabled' : '') + '>↑</button><button class="btn btn-sm btn-icon" type="button" data-step="1" aria-label="Next spool in list" ' + (i === list.length - 1 ? 'disabled' : '') + '>↓</button></div>' : '';
|
||||
return '<div class="drawer-head">' + icon + '<div style="flex:1;min-width:0"><h2 id="drawer-title">' + title + '</h2><span class="sub">' + sub + '</span>' + (extra || '') + '</div>' + nav + '<button class="btn btn-sm btn-icon" type="button" data-close-drawer aria-label="Close panel (Esc)">✕</button></div>';
|
||||
}
|
||||
function tabs(list) {
|
||||
return '<div class="drawer-tabs"><div class="tabs" role="tablist" aria-label="Spool panel">' + list.map(function (t) { return '<button type="button" role="tab" data-tab="' + t[0] + '" aria-selected="' + (D.tab === t[0]) + '">' + t[1] + '</button>'; }).join('') + '</div></div>';
|
||||
}
|
||||
|
||||
function spoolDrawer(s) {
|
||||
var w = where(s);
|
||||
var h = head(sw(s.c, '" style="width:44px;height:44px'), esc(s.color) + ' <span class="muted" style="font-weight:400">#' + s.id + '</span>', esc(fullName(s)) + ' · ' + esc(w.text));
|
||||
var t = tabs([['overview', 'Overview'], ['weigh', 'Weigh'], ['move', w.loaded ? 'Move' : 'Load / move'], ['edit', 'Edit']]);
|
||||
var body = '', foot = '';
|
||||
if (D.tab === 'overview') {
|
||||
var used = PRINTS.filter(function (p) { return p.spool === s.id || (p.filaments || []).some(function (f) { return f.spool === s.id; }); });
|
||||
var gr = gross(s);
|
||||
body = '<div class="nm-inset panel"><div class="row" style="align-items:baseline"><span style="font-size:34px;font-weight:800;line-height:1" class="num' + (low(s) ? '" style="color:var(--status-warning)' : '') + '">' + Math.round(s.rem).toLocaleString('en-US') + '<span style="font-size:15px"> g</span></span><span class="muted">of ' + g(s.tot) + ' net</span><span class="spacer"></span>' + remProv(s) + '</div>' +
|
||||
'<div style="margin-top:12px">' + meter(s) + '</div>' + (low(s) ? '<p class="small" style="margin:10px 0 0;color:var(--status-warning)"><b>⚠ Below the 200 g line.</b> Reorder or finish it on a small job.</p>' : '') + '</div>' +
|
||||
'<div class="panel nm-raised-sm"><h3>On the scale</h3>' + (gr == null
|
||||
? '<p class="small" style="margin:0">Gross is <b>unknown</b> because no empty spool weight is known. It is never treated as 0.</p><div class="row" style="margin-top:12px"><button class="btn btn-sm" type="button" data-tab="weigh">Set empty spool weight</button></div>'
|
||||
: '<dl class="kv"><dt>Empty spool</dt><dd>' + g(s.tare) + ' ' + tareProv(s) + '</dd><dt>+ Filament (net)</dt><dd>' + g(s.rem) + '</dd><dt class="strong" style="color:var(--text-primary)">= Gross, on a scale</dt><dd class="strong">' + g(gr) + '</dd></dl>') + '</div>' +
|
||||
'<dl class="kv"><dt>Location</dt><dd>' + (w.loaded ? '<a href="04-printers.html#printer=' + w.printer.id + '">' + esc(w.text) + '</a>' : esc(w.text)) + ' · <button class="btn btn-sm" type="button" data-tab="move" style="min-height:26px;padding:2px 8px">Move</button></dd>' +
|
||||
'<dt>Value left</dt><dd>' + usd(value(s)) + ' <span class="sub" style="display:inline">at ' + usd(s.kg) + '/kg</span></dd>' +
|
||||
'<dt>Spool type</dt><dd>' + (s.stype ? esc(s.stype) : '<span class="muted">none chosen</span>') + '</dd>' +
|
||||
'<dt>Label</dt><dd>' + (s.label ? 'printed' : '<span class="prov warn"><i>!</i>not printed</span> <button class="btn btn-sm" type="button" data-fake="Label PDF generated for #' + s.id + '" style="min-height:26px;padding:2px 8px">Print label</button>') + '</dd>' +
|
||||
'<dt>Record</dt><dd class="muted">version ' + s.v + '</dd></dl>' +
|
||||
'<div><h3 class="section" style="margin-bottom:8px">Prints from this spool <span class="muted">· ' + used.length + '</span></h3>' + (used.length ? '<div class="stack" style="gap:6px">' + used.map(function (p) { var f = (p.filaments || []).filter(function (f) { return f.spool === s.id; })[0]; return '<a class="row nm-raised-sm" style="padding:8px 12px;text-decoration:none;color:inherit;gap:10px" href="#print=' + p.id + '" data-print="' + p.id + '">' + printSt(p) + '<span class="strong" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis">' + esc(p.job) + '</span><span class="num">' + g(f ? f.grams : p.grams) + '</span><span class="sub" style="display:inline">' + when(p.start) + '</span></a>'; }).join('') + '</div>' : '<p class="small muted" style="margin:0">No prints drew from this spool yet.</p>') + '</div>' +
|
||||
'<p class="small muted" style="margin:0"><span class="prov warn"><i>⚠</i>API gap</span> Location history is stored in <code>spool_location_event</code> but there is no endpoint to read it.</p>';
|
||||
foot = '<button class="btn btn-danger btn-sm" type="button" data-delete="' + s.id + '" style="margin-right:auto">Delete…</button><button class="btn" type="button" data-tab="edit">Edit</button><button class="btn btn-primary" type="button" data-tab="weigh" ' + (s.tare == null ? 'title="Needs an empty spool weight first"' : '') + '>Weigh</button>';
|
||||
}
|
||||
if (D.tab === 'weigh') {
|
||||
if (s.tare == null) {
|
||||
body = '<div class="banner"><b>?</b><span>This spool has <b>no empty spool weight</b>, so a scale reading can\'t be turned into filament. Set one first. It is never assumed to be 0.</span></div>' +
|
||||
'<label class="field stack" style="gap:6px"><span class="strong small">Pick a spool type</span><select class="select" autofocus><option>Choose…</option><option>Generic — 1 kg spool (assumed) · 180 g · estimated</option><option>Creality — Hyper cardboard · 176 g · community</option><option>Creality — legacy plastic · 138 g · community</option></select><span class="sub">Fastest. The figure\'s trust level is shown next to it.</span></label>' +
|
||||
'<div class="row"><hr style="flex:1;border:0;border-top:1px solid var(--border-hairline)"><span class="sub">or, more accurate</span><hr style="flex:1;border:0;border-top:1px solid var(--border-hairline)"></div>' +
|
||||
'<label class="field stack" style="gap:6px"><span class="strong small">Weigh the empty spool</span><input class="input num" inputmode="numeric" placeholder="e.g. 210 g"><span class="sub">Only possible once the spool is used up, or from an identical empty.</span></label>';
|
||||
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" data-fake="Empty spool weight saved for #' + s.id + '. Weigh-in unlocked.">Save empty spool weight</button>';
|
||||
} else {
|
||||
var wl = where(s), li = D.list ? D.list.indexOf(s.id) : -1;
|
||||
body = (D.session && li >= 0 ? '<div class="row small" style="gap:10px"><span class="st run">Weigh session</span><span class="muted">spool ' + (li + 1) + ' of ' + D.list.length + ' · lightest first</span></div>' : '') +
|
||||
'<div class="findit"><span class="sub">Find it</span><b>' + esc(wl.text) + '</b>' + sw(s.c, 'swatch-sm') + '<span class="muted small">' + esc(s.color) + ' · #' + s.id + '</span></div>' +
|
||||
'<div class="panel nm-inset"><label class="stack" style="gap:8px"><span class="strong">Whole spool on the scale</span><input class="input num" id="scale" inputmode="numeric" autofocus placeholder="full reads ' + g(s.tare + s.tot) + '" style="font-size:22px;min-height:56px"><span class="sub">Remaining = reading − ' + g(s.tare) + ' empty spool (' + tareProv(s) + ')</span></label></div>' +
|
||||
'<div class="panel nm-raised-sm" id="weigh-out" aria-live="polite"><h3>Result</h3><div class="row"><span class="muted">Now</span><b class="num">' + g(s.rem) + '</b><span class="muted">→</span><b class="num" id="w-after">—</b><span class="spacer"></span><span id="w-diff" class="num"></span></div><p class="small muted" id="w-note" style="margin:8px 0 0">Type the reading and the result appears here before you save.</p></div>';
|
||||
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" id="w-save" disabled>Save weigh-in</button>' + (D.list && D.list.indexOf(s.id) < D.list.length - 1 ? '<button class="btn" type="button" id="w-next" disabled>Save & weigh next ↓</button>' : '');
|
||||
}
|
||||
}
|
||||
if (D.tab === 'move') {
|
||||
var dests = PRINTERS.map(function (pr) {
|
||||
return '<div class="stack" style="gap:8px"><div class="row"><b class="small">' + esc(pr.name) + '</b>' + (pr.connected ? '' : '<span class="prov warn"><i>⚡</i>disconnected (loading still recorded)</span>') + '</div><div class="row" style="gap:8px;align-items:stretch">' +
|
||||
pr.containers.map(function (c) { var n = contents(c.id).length, full = n >= c.cap && s.cont !== c.id, here = s.cont === c.id; return '<button type="button" class="slot" style="min-height:64px;flex:1" data-dest="' + c.id + '" ' + (full ? 'disabled title="Full: unload a spool first"' : '') + ' aria-pressed="' + here + '"><span class="sn">' + esc(c.label) + '</span><span class="g">' + n + ' / ' + c.cap + (here ? ' · here now' : full ? ' · full' : '') + '</span></button>'; }).join('') + '</div></div>';
|
||||
}).join('');
|
||||
body = '<p class="small muted" style="margin:0">Where is the spool going? Full holders are disabled. Extrudex records which holder a spool is in, not its tray position.</p>' +
|
||||
'<div class="stack" style="gap:8px"><b class="small">Storage</b><div class="row" style="gap:8px;align-items:stretch"><button type="button" class="slot" style="min-height:64px;flex:1" data-dest="storage" aria-pressed="' + !s.cont + '"><span class="sn">Shelf / storage</span><span class="g">' + (s.cont ? 'unload' : 'here now') + '</span></button></div>' +
|
||||
'<label class="stack" style="gap:6px"><span class="sub">Where in storage (optional)</span><input class="input" value="' + esc(s.loc || '') + '" placeholder="Shelf A2, Dry box 1…"></label></div>' + dests;
|
||||
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" id="m-save" disabled>Move spool</button>';
|
||||
}
|
||||
if (D.tab === 'edit') { return editDrawer(s); }
|
||||
return h + t + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
|
||||
}
|
||||
|
||||
function editDrawer(s) {
|
||||
var isNew = !s; s = s || { id: null, c: ['#888888'], color: '', brand: 'Sunlu', speed: null, mat: 'PLA', type: null, fin: 'Basic', rem: 1000, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', kg: 0, loc: '', v: null };
|
||||
var st = D.state;
|
||||
var h = isNew ? head('<span class="swatch" style="width:44px;height:44px"><i style="background:#888"></i></span>', 'Add spool', 'New spools carry no version') : head(sw(s.c, '" style="width:44px;height:44px'), 'Edit ' + esc(s.color) + ' <span class="muted" style="font-weight:400">#' + s.id + '</span>', 'Opened 09:32 at version ' + (st === 'conflict' ? s.v - 1 : s.v));
|
||||
var t = isNew ? '' : tabs([['overview', 'Overview'], ['weigh', 'Weigh'], ['move', where(s).loaded ? 'Move' : 'Load / move'], ['edit', 'Edit']]);
|
||||
function sel(label, val, req, opts) { return '<label class="stack" style="gap:6px"><span class="small strong">' + label + ' ' + (req ? '<span class="sub" style="display:inline;color:var(--text-accent)">required</span>' : '<span class="sub" style="display:inline">optional</span>') + '</span><select class="select"><option>' + (val || 'None') + '</option>' + (opts || '') + '</select></label>'; }
|
||||
var demo = '<div class="row small" style="gap:8px"><span class="muted">Demo state</span><div class="tabs" role="group" aria-label="Demo state">' + [['normal', 'Normal'], ['errors', '400 errors'], ['conflict', '409 conflict']].map(function (x) { return '<button type="button" data-state="' + x[0] + '" aria-selected="' + (st === x[0]) + '">' + x[1] + '</button>'; }).join('') + '</div></div>';
|
||||
var conflict = st === 'conflict' ? '<div class="nm-inset" role="alert" style="padding:16px;border:2px solid var(--status-warning);background:var(--wash-warning);border-radius:var(--radius-lg)"><b style="color:var(--status-warning)">⚠ This spool changed since you opened it. Nothing was saved.</b><p class="small" style="margin:8px 0">Someone weighed it at 09:39 (version ' + (s.v - 1) + ' → ' + s.v + '). Saving would have put the old remaining weight back.</p>' +
|
||||
'<table class="tbl" style="font-size:12px"><thead><tr><th>Field</th><th class="r">You opened</th><th class="r">Now</th><th class="r">Your edit</th></tr></thead><tbody><tr><td>Remaining</td><td class="r">860 g</td><td class="r strong" style="color:var(--status-warning)">' + g(s.rem) + ' ⚖</td><td class="r muted">—</td></tr><tr><td>Location</td><td class="r">Shelf A3</td><td class="r">Shelf A3</td><td class="r strong accent">' + esc(where(s).short) + '</td></tr></tbody></table>' +
|
||||
'<div class="row" style="margin-top:12px"><button class="btn btn-primary btn-sm" type="button" data-fake="Reloaded version ' + s.v + ' and reapplied your location change. Review, then save.">Reload & reapply my change</button><button class="btn btn-sm" type="button" data-state="normal">Reload, discard mine</button></div></div>' : '';
|
||||
var errsum = st === 'errors' ? '<div role="alert" style="padding:14px 16px;border:2px solid var(--status-error);background:var(--wash-error);border-radius:var(--radius-lg)"><b style="color:var(--status-error)">⚠ 2 fields need fixing. Nothing was saved.</b><ul class="small" style="margin:6px 0 0;padding-left:18px"><li><a href="#e-rem">Remaining is more than the spool holds</a></li><li><a href="#e-name">Color name is empty</a></li></ul></div>' : '';
|
||||
var name = '<div class="nm-inset" style="padding:12px 14px;border-radius:var(--radius-md)"><span class="sub">Reads as</span><div class="strong" style="font-size:17px">' + esc(fullName(s)) + '</div><span class="sub">brand · <i>speed</i> · material · <i>type</i> · finish (italic = optional)</span></div>';
|
||||
var body = demo + conflict + errsum + name +
|
||||
'<div class="fr2">' + sel('Brand', s.brand, 1) + sel('Print speed', s.speed, 0, '<option>Rapid</option><option>High Speed</option>') + sel('Material', s.mat, 1) + sel('Filament type', s.type, 0, '<option>CF</option><option>+ 2.0</option>') + sel('Finish', s.fin, 1) + '</div>' +
|
||||
'<div class="fr2"><div class="stack' + (st === 'errors' ? ' field has-error' : '') + '" id="e-name" style="gap:6px"><label class="small strong" for="f-name">Color name <span class="sub" style="display:inline;color:var(--text-accent)">required</span></label><input class="input" id="f-name" value="' + (st === 'errors' ? '' : esc(s.color)) + '" ' + (st === 'errors' ? 'aria-invalid="true" aria-describedby="e-name-m"' : '') + ' placeholder="e.g. Charcoal">' + (st === 'errors' ? '<p class="field-error" id="e-name-m" style="margin:0">Give the color a name, even "Unknown". <span class="sub" style="display:inline">server: <code>color_name is required</code></span></p>' : '') + '</div>' +
|
||||
'<label class="stack" style="gap:6px"><span class="small strong">Colors <span class="sub" style="display:inline">1–3</span></span><span class="row" style="gap:8px">' + sw(s.c) + '<input class="input" value="' + s.c[0] + '" style="max-width:110px"><button class="btn btn-sm" type="button">+ color</button></span></label></div>' +
|
||||
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Total <span class="sub" style="display:inline">net, as sold</span></span><input class="input num" value="' + g(s.tot) + '"></label>' +
|
||||
'<div class="stack' + (st === 'errors' ? ' field has-error' : '') + '" id="e-rem" style="gap:6px"><label class="small strong" for="f-rem">Remaining <span class="sub" style="display:inline">net</span></label><input class="input num" id="f-rem" value="' + (st === 'errors' ? '1,040 g' : g(s.rem)) + '" ' + (st === 'errors' ? 'aria-invalid="true" aria-describedby="e-rem-m"' : '') + '>' + (st === 'errors' ? '<p class="field-error" id="e-rem-m" style="margin:0">1,040 g is more than this spool holds (' + g(s.tot) + '). <span class="sub" style="display:inline">server: <code>remaining_grams cannot exceed total_grams</code></span></p>' : '<span class="sub">To measure it, use the <b>Weigh</b> tab instead.</span>') + '</div></div>' +
|
||||
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Spool type <span class="sub" style="display:inline">supplies the empty spool weight</span></span><select class="select"><option>' + esc(s.stype || 'None chosen') + '</option></select></label>' +
|
||||
'<label class="stack" style="gap:6px"><span class="small strong">Empty spool weight</span><input class="input num" value="' + (s.tare == null ? '' : g(s.tare)) + '" placeholder="unknown"><span class="sub">' + (s.tare == null ? '<span class="prov warn"><i>?</i>unknown, never 0</span>' : tareProv(s)) + '</span></label></div>' +
|
||||
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Cost per kg</span><input class="input num" value="' + usd(s.kg) + '"></label><label class="stack" style="gap:6px"><span class="small strong">Storage location <span class="sub" style="display:inline">optional</span></span><input class="input" value="' + esc(s.loc || '') + '"></label></div>' +
|
||||
'<div class="banner" id="discard-bar" hidden><span style="flex:1">Discard unsaved changes?</span><button class="btn btn-sm" type="button" data-discard>Discard</button><button class="btn btn-sm" type="button" data-keep>Keep editing</button></div>';
|
||||
var foot = (st === 'conflict' ? '<span class="small muted" style="margin-right:auto">Save is off until you choose a version.</span>' : '') + '<button class="btn" type="button" data-' + (isNew ? 'close-drawer' : 'tab="overview"') + '>Cancel</button><button class="btn btn-primary" type="button" ' + (st === 'conflict' ? 'disabled' : 'data-save') + '>' + (isNew ? 'Add spool' : 'Save changes') + '</button>';
|
||||
return h + t + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
|
||||
}
|
||||
|
||||
function printDrawer(p) {
|
||||
var pr = printer(p.printer), n = needs(p);
|
||||
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.prints + '</span>', esc(p.job), printSt(p) + ' · ' + esc(pr.name) + ' · print #' + p.id);
|
||||
var fix = '';
|
||||
if (n.length) {
|
||||
fix = '<div class="nm-inset panel" style="border:1px solid var(--status-warning)"><h3 style="color:var(--status-warning)">Needs ' + n.map(function (x) { return x.t; }).join(' + ') + '</h3>' + fixers(p) + '</div>';
|
||||
}
|
||||
var body = fix + '<dl class="kv"><dt>Printer</dt><dd><a href="04-printers.html#printer=' + pr.id + '">' + esc(pr.name) + '</a></dd><dt>Started</dt><dd>' + when(p.start) + '</dd><dt>Ended</dt><dd>' + (p.end ? when(p.end) : p.status === 'running' ? '<span class="muted">still running</span>' : '—') + '</dd>' +
|
||||
'<dt>Duration</dt><dd>' + durCell(p) + '</dd>' +
|
||||
'<dt>Job key</dt><dd><code>' + esc(p.key) + '</code><span class="sub">The identity. Two jobs can share a name, never a path.</span></dd>' +
|
||||
'<dt>Filament</dt><dd>' + (p.grams == null ? '<span class="muted">not known yet</span>' : '<b class="num">' + g(p.grams) + '</b> ' + weightProv(p) + (p.status === 'failed' ? '<span class="sub">Failed prints record the full job weight. Correct by hand if needed.</span>' : '')) + '</dd>' +
|
||||
'<dt>Spool</dt><dd>' + spoolLinks(p) + changeSpool(p) + '</dd><dt>Cost</dt><dd>' + (p.cost == null ? '<span class="muted">pending: needs weight and spool</span>' : usd(p.cost) + (p.filaments && p.filaments.some(function (f) { return f.spool == null; }) ? ' <span class="sub" style="display:inline">so far</span>' : '')) + '</dd>' +
|
||||
'<dt>Use</dt><dd><div class="tabs" role="group" aria-label="Use type"><button type="button" aria-selected="' + (p.use === 'business') + '">Business</button><button type="button" aria-selected="' + (p.use === 'personal') + '">Personal</button></div>' + (p.use == null ? '<span class="sub">Not recorded</span>' : '') + '</dd></dl>' +
|
||||
(p.filaments ? '<div><h3 class="section">Colors</h3><table class="tbl"><thead><tr><th>#</th><th>Color</th><th>Spool</th><th class="r">Grams</th><th class="r">Cost</th></tr></thead><tbody>' + p.filaments.map(function (f) { var s = f.spool && spool(f.spool); return '<tr><td>' + (f.slot + 1) + '</td><td><span class="swatch swatch-sm"><i style="background:' + f.hex + '"></i></span> <code>' + f.hex + '</code></td><td>' + (s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '">' + esc(short(s)) + '</a>' : '<span class="prov warn"><i>!</i>unattributed</span>') + '</td><td class="r">' + g(f.grams) + '</td><td class="r">' + usd(f.cost) + '</td></tr>'; }).join('') + '</tbody></table></div>' : '');
|
||||
var foot = p.status === 'running' ? '<span class="small muted">Name and weight are captured when the print ends.</span>' : '<button class="btn btn-sm" type="button" data-fake="Archived ' + esc(p.job) + '. Accounting unchanged." style="margin-right:auto">Archive</button><button class="btn" type="button" data-close-drawer>Done</button>';
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
|
||||
}
|
||||
function changeSpool(p) {
|
||||
var attributed = p.filaments ? p.filaments.filter(function (f) { return f.spool; }) : (p.spool ? [{ spool: p.spool, grams: p.grams, slot: null }] : []);
|
||||
if (!attributed.length || p.status === 'running') return '';
|
||||
if (p.unreconciled) return '<div class="banner err" role="alert" style="margin-top:8px;font-size:12px"><span><b>Can\'t move this one yet.</b> It was attributed to spool #' + p.spool + ' before Extrudex tracked deductions, so nothing records how much came off. Check that spool\'s remaining grams, then re-attribute this print to <b>the same spool</b> to reconcile it. After that it can move.</span></div>';
|
||||
return attributed.map(function (f) {
|
||||
var from = spool(f.spool);
|
||||
return '<details class="chg"><summary>Change spool' + (f.slot != null ? ' for color ' + (f.slot + 1) : '') + '</summary><div class="stack" style="gap:8px;margin-top:8px"><select class="select" style="min-height:36px" aria-label="New spool">' + SPOOLS.filter(function (x) { return x.id !== f.spool; }).slice(0, 10).map(function (x) { return '<option>' + g(x.rem) + ' · ' + esc(short(x)) + ' · ' + x.mat + ' · #' + x.id + '</option>'; }).join('') + '</select>' +
|
||||
'<span class="sub">' + (f.grams != null ? '<b>' + g(f.grams) + ' goes back</b> to ' + esc(short(from)) + ' #' + from.id + ' and comes off the spool you pick. Cost is re-rated at that spool\'s $/kg.' : 'No weight yet, so nothing moves until one is entered.') + '</span>' +
|
||||
'<button class="btn btn-sm" type="button" data-fake="Moved ' + esc(p.job) + (f.grams != null ? ': ' + g(f.grams) + ' returned to ' + esc(short(from)) : '') + '">Move to this spool</button></div></details>';
|
||||
}).join('');
|
||||
}
|
||||
function fixers(p) {
|
||||
var out = '';
|
||||
if (p.grams == null) out += '<label class="stack" style="gap:6px;margin-bottom:12px"><span class="small strong">Filament used</span><span class="row" style="gap:8px"><input class="input num" style="max-width:140px" placeholder="grams" inputmode="numeric" autofocus><button class="btn btn-sm" type="button" data-fake="Saved ' + esc(p.job) + ' weight · remembered for ' + esc(p.key) + '">Save weight</button></span><span class="sub">From the slicer. Remembered for <code>' + esc(p.key) + '</code> so later runs fill in by themselves.</span></label>';
|
||||
var slots = p.filaments ? p.filaments.filter(function (f) { return f.spool == null; }) : (p.spool == null ? [{ suggested: p.suggested, slot: null }] : []);
|
||||
slots.forEach(function (f) {
|
||||
var sug = f.suggested && spool(f.suggested);
|
||||
out += '<div class="stack" style="gap:6px;margin-bottom:8px"><span class="small strong">Spool' + (f.slot != null ? ' for color ' + (f.slot + 1) + ' <span class="swatch swatch-sm"><i style="background:' + f.hex + '"></i></span>' : '') + '</span>' +
|
||||
(sug ? '<div class="row nm-raised-sm" style="padding:8px 12px;gap:10px">' + sw(sug.c, 'swatch-sm') + '<span style="flex:1"><b>' + esc(short(sug)) + '</b><span class="sub">Suggested: same color, loaded in ' + esc(where(sug).short) + '. Not charged until you confirm.</span></span><button class="btn btn-sm btn-primary" type="button" data-fake="Attributed to ' + esc(short(sug)) + ' · ' + g(f.grams || p.grams) + ' deducted">Use this</button></div>' : '') +
|
||||
'<select class="select"><option>' + (sug ? 'Or pick another spool…' : 'Pick a spool…') + '</option>' + SPOOLS.slice(0, 8).map(function (s) { return '<option>' + g(s.rem) + ' · ' + esc(short(s)) + ' · ' + esc(s.mat) + '</option>'; }).join('') + '</select></div>';
|
||||
});
|
||||
return out;
|
||||
}
|
||||
var MODELS = { bambu: ['A1', 'A1 mini', 'P1P', 'P1S', 'X1 Carbon', 'X1E', 'H2D', 'H2C', 'H2S'], elegoo: ['Centauri', 'Centauri Carbon', 'OrangeStorm Giga'] };
|
||||
function peditDrawer(id) {
|
||||
var pr = id === 'new' ? null : printer(+id), st = D.state;
|
||||
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.printers + '</span>', pr ? 'Edit ' + esc(pr.name) : 'Add printer', pr ? 'Connection details · admin' : 'Extrudex connects read-only; nothing is sent to the printer');
|
||||
var brand = pr ? 'bambu' : (D.brand || 'bambu');
|
||||
function f(label, hint, input, err) { return '<div class="stack' + (err ? ' field has-error' : '') + '" style="gap:6px"><label class="small strong">' + label + (hint ? ' <span class="sub" style="display:inline">' + hint + '</span>' : '') + '</label>' + input + (err ? '<p class="field-error" style="margin:0">' + err + '</p>' : '') + '</div>'; }
|
||||
var body = '<div class="row small" style="gap:8px"><span class="muted">Demo state</span><div class="tabs" role="group" aria-label="Demo state">' + [['normal', 'Normal'], ['errors', '400 errors']].map(function (x) { return '<button type="button" data-state="' + x[0] + '" aria-selected="' + (st === x[0]) + '">' + x[1] + '</button>'; }).join('') + '</div></div>' +
|
||||
'<div class="fr2">' + f('Brand', '', '<select class="select" id="pe-brand"><option value="bambu"' + (brand === 'bambu' ? ' selected' : '') + '>Bambu Lab</option><option value="elegoo"' + (brand === 'elegoo' ? ' selected' : '') + '>Elegoo</option></select>') +
|
||||
f('Model', '', '<select class="select">' + MODELS[brand].map(function (m) { return '<option' + (pr && pr.model === m ? ' selected' : '') + '>' + m + '</option>'; }).join('') + '</select>') + '</div>' +
|
||||
f('Name', 'shown everywhere; must be unique', '<input class="input" value="' + (pr ? esc(pr.name) : '') + '" placeholder="X1C — Bench">', st === 'errors' && !pr ? 'A printer called “P1S - Prints Charming” already exists. Names must be unique.' : '') +
|
||||
'<div class="fr2">' + f('IP address', 'same network as Extrudex', '<input class="input" value="' + (st === 'errors' ? '10.60.1.300' : pr ? esc(pr.ip) : '') + '" placeholder="10.60.1.10" inputmode="decimal">', st === 'errors' ? '10.60.1.300 isn\'t a valid IPv4 address.' : '') +
|
||||
f('Serial', 'on the printer\'s label', '<input class="input" value="' + (pr ? esc(pr.serial) : '') + '">') + '</div>' +
|
||||
(brand === 'bambu' ? f('Access code', pr ? 'leave blank to keep the stored code' : 'from the printer screen: Settings → LAN', '<input class="input" type="password" placeholder="' + (pr ? 'unchanged' : '8 characters') + '" autocomplete="off">') + '<p class="sub" style="margin:0">Stored, never shown again. Read-only MQTT needs only IP, serial and access code, no Developer Mode.</p>'
|
||||
: '<p class="small muted" style="margin:0">Elegoo Centauri uses unauthenticated SDCP, so there is no access code.</p>') +
|
||||
(pr ? '' : '<div class="nm-inset panel small"><b>After you add it</b><ul style="margin:6px 0 0;padding-left:18px"><li>An <b>External</b> holder is created automatically.</li><li>Add its AMS units under <b>Manage holders</b>.</li><li>Capture starts on the first status report.</li></ul></div>');
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer>Cancel</button><button class="btn btn-primary" type="button" ' + (st === 'errors' ? 'disabled' : 'data-fake="' + (pr ? 'Saved ' + esc(pr.name) + '. The monitor reconnects with the new details.' : 'Added printer. Waiting for its first status report.') + '"') + '>' + (pr ? 'Save' : 'Add printer') + '</button></div>';
|
||||
}
|
||||
function holdersDrawer(id) {
|
||||
var pr = printer(+id);
|
||||
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.printers + '</span>', 'Holders · ' + esc(pr.model), esc(pr.name) + ' · where filament can be loaded');
|
||||
var body = pr.containers.map(function (c) {
|
||||
var n = contents(c.id).length, ext = c.kind === 'external';
|
||||
return '<div class="holder"><div class="holder-h"><b>' + esc(c.label) + '</b><span class="muted">' + { ams: 'AMS', ams_lite: 'AMS Lite', external: 'External' }[c.kind] + ' · holds ' + c.cap + ' · #' + HOLDER_IDS[c.id] + '</span><span class="spacer"></span><span class="small">' + n + ' loaded</span></div>' +
|
||||
'<div class="row" style="gap:8px"><button class="btn btn-sm" type="button" data-fake="Holder label PDF #' + HOLDER_IDS[c.id] + ' generated">Print label</button>' +
|
||||
(ext ? '<span class="sub">Every printer keeps exactly one external holder.</span>' : n ? '<button class="btn btn-sm" type="button" disabled title="Unload its spools first">Remove</button><span class="sub">Unload its ' + n + ' spool' + (n > 1 ? 's' : '') + ' first.</span>' : '<button class="btn btn-sm btn-danger" type="button" data-fake="Removed ' + esc(c.label) + '">Remove</button>') + '</div></div>';
|
||||
}).join('') +
|
||||
'<div class="panel nm-raised-sm stack" style="gap:10px"><h3 style="margin:0">Add an AMS unit</h3><div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Type</span><select class="select"><option>AMS (4 spools)</option><option>AMS Lite (4 spools)</option></select></label><label class="stack" style="gap:6px"><span class="small strong">Label</span><input class="input" value="AMS 2"><span class="sub">Unique on this printer</span></label></div><button class="btn btn-sm" type="button" data-fake="Added AMS 2 to ' + esc(pr.model) + '" style="align-self:flex-start">Add holder</button></div>' +
|
||||
'<p class="small muted" style="margin:0">Capacity comes from the type, so a holder can\'t disagree with its hardware. Tray positions aren\'t recorded.</p>';
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer>Done</button></div>';
|
||||
}
|
||||
function premoveDrawer(id) {
|
||||
var pr = printer(+id), loaded = pr.containers.reduce(function (a, c) { return a + contents(c.id).length; }, 0), open = pr.openPrint && print(pr.openPrint);
|
||||
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--wash-error);color:var(--status-error);border-color:var(--status-error)">!</span>', 'Remove ' + esc(pr.name) + '?', esc(pr.model) + ' · ' + esc(pr.ip));
|
||||
var body = '<ul class="small" style="margin:0;padding-left:18px;display:flex;flex-direction:column;gap:6px"><li>Capture stops for this printer.</li><li><b>Print history is kept</b>, including cost records.</li>' + (open ? '<li><b>' + esc(open.job) + '</b> is open, so it will be <b>closed as unknown</b> with no duration, because nobody will watch it end.</li>' : '') + '</ul>' +
|
||||
(loaded ? '<div class="banner err" role="alert"><span><b>Can\'t remove yet:</b> ' + loaded + ' spool' + (loaded > 1 ? 's are' : ' is') + ' still loaded in its holders. Unload them first, so they don\'t lose their place. <a href="04-printers.html#printer=' + pr.id + '" data-close-drawer>Show holders</a></span></div>' : '') +
|
||||
'<label class="stack" style="gap:6px"><span class="small strong">Type <i>' + esc(pr.name) + '</i> to confirm</span><input class="input" id="rm-name" autocomplete="off" ' + (loaded ? 'disabled' : '') + '></label>';
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer autofocus>Keep printer</button><button class="btn btn-danger" type="button" id="rm-go" disabled data-name="' + esc(pr.name) + '">Remove printer</button></div>';
|
||||
}
|
||||
function loadDrawer(cid) {
|
||||
var c = container(cid), inIt = contents(cid);
|
||||
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.printers + '</span>', 'Load into ' + esc(c.c.label), esc(c.printer.name) + ' · ' + inIt.length + ' of ' + c.c.cap + ' in use');
|
||||
var cands = SPOOLS.filter(function (s) { return !s.cont; });
|
||||
var body = '<label class="stack" style="gap:6px"><span class="small strong">Find a spool in storage</span><input class="input" id="load-q" placeholder="Brand, color, material…" autofocus></label><div class="stack" style="gap:6px" id="load-list">' +
|
||||
cands.map(function (s) { return '<button type="button" class="slot" style="min-height:0;flex-direction:row;align-items:center;gap:12px;width:100%" data-load="' + s.id + '" aria-pressed="false" data-hay="' + esc((fullName(s) + ' ' + s.color + ' ' + (s.loc || '')).toLowerCase()) + '">' + sw(s.c) + '<span style="flex:1;min-width:0"><span class="nm" style="display:block">' + esc(s.color) + ' · ' + esc(s.brand) + '</span><span class="sub">' + esc(fullName(s)) + ' · ' + esc(s.loc || 'Storage') + '</span></span><span class="g num' + (low(s) ? '" style="color:var(--status-warning)' : '') + '">' + g(s.rem) + '</span></button>'; }).join('') + '</div>' +
|
||||
'<p class="small muted" style="margin:0">Scanning the holder\'s QR label does the same thing from a phone (existing <code>/c/:id</code> scan session).</p>';
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer>Cancel</button><button class="btn btn-primary" type="button" id="l-save" disabled>Load spool</button></div>';
|
||||
}
|
||||
|
||||
/* ── palette ─────────────────────────────────────────────── */
|
||||
var P = { items: [], sel: 0 };
|
||||
function palItems(q) {
|
||||
q = q.replace(/^\s+/, '').toLowerCase(); if (!/^(load|attribute)\s/.test(q)) q = q.trim(); else q = q.replace(/\s+$/, function (m) { return /\s(into|to)\s+$/.test(q) ? ' ' : ''; });
|
||||
var acts = [
|
||||
{ sec: 'Actions', ico: '+', t: 'Add spool', m: 'new', run: function () { openDrawer('new', 0, 'edit'); } },
|
||||
{ sec: 'Actions', ico: '⚖', t: 'Weigh a spool…', m: 'type its name', run: function () { $('#pal-q').value = 'weigh '; palRender(); }, keep: 1 },
|
||||
{ sec: 'Actions', ico: '⇥', t: 'Load a spool into a holder…', m: 'load ‹spool› into ‹holder›', run: function () { $('#pal-q').value = 'load '; palRender(); }, keep: 1 },
|
||||
{ sec: 'Actions', ico: '⇄', t: 'Attribute a print to a spool…', m: 'attribute ‹print› to ‹spool›', run: function () { $('#pal-q').value = 'attribute '; palRender(); }, keep: 1 },
|
||||
{ sec: 'Actions', ico: '!', t: 'Prints needing attention', m: printsNeeding().length + ' open', href: '03-prints.html?tab=needs' },
|
||||
{ sec: 'Actions', ico: '◐', t: 'Switch color theme', m: 'light / dark', run: function () { setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'); } }
|
||||
].concat(PAGES.map(function (p) { return { sec: 'Go to', ico: '→', t: p[1], m: 'page', href: p[2] }; }));
|
||||
var lm = /^load(?:\s|$)/.test(q) && /^load\s*(.*?)(?:\s+into\b\s*(.*))?$/.exec(q), am = /^attribute(?:\s|$)/.test(q) && /^attribute\s*(.*?)(?:\s+to\b\s*(.*))?$/.exec(q);
|
||||
if (lm) return loadCmd(lm[1] || '', lm[2]);
|
||||
if (am) return attrCmd(am[1] || '', am[2]);
|
||||
var weigh = /^weigh\s*/.test(q); if (weigh) q = q.replace(/^weigh\s*/, '');
|
||||
var sp = SPOOLS.map(function (s) { return { sec: weigh ? 'Weigh which spool?' : 'Spools', html: sw(s.c, 'swatch-sm'), t: s.color + ' · ' + fullName(s), m: '#' + s.id + ' · ' + g(s.rem) + ' · ' + where(s).short, hay: ('#' + s.id + ' ' + s.id + ' ' + fullName(s) + ' ' + s.color + ' ' + where(s).text).toLowerCase(), run: function () { go('spool', s.id, weigh ? 'weigh' : 'overview'); } }; });
|
||||
var pr = PRINTS.map(function (p) { return { sec: 'Prints', ico: '▤', t: p.job, m: PST[p.status][1] + ' · ' + when(p.start), hay: (p.job + ' ' + p.key + ' ' + printer(p.printer).name).toLowerCase(), run: function () { go('print', p.id); } }; });
|
||||
var pt = PRINTERS.map(function (p) { return { sec: 'Printers', ico: '▣', t: p.name, m: p.connected ? (p.state === 'RUNNING' ? 'printing' : 'idle') : 'reconnecting', hay: p.name.toLowerCase(), href: '04-printers.html#printer=' + p.id }; });
|
||||
if (weigh) return sp.filter(function (i) { return !q || i.hay.indexOf(q) >= 0; }).slice(0, 12);
|
||||
if (!q) return acts.concat(sp.filter(function (i, k) { return k < 3; }).map(function (i) { i.sec = 'Recent spools'; return i; }));
|
||||
var match = function (i) { return (i.hay || i.t.toLowerCase()).indexOf(q) >= 0; };
|
||||
return acts.filter(match).concat(sp.filter(match).slice(0, 8), pr.filter(match).slice(0, 5), pt.filter(match));
|
||||
}
|
||||
function holders() { var out = []; PRINTERS.forEach(function (pr) { pr.containers.forEach(function (c) { out.push({ id: c.id, pr: pr, c: c, n: contents(c.id).length, hay: (pr.name + ' ' + pr.model + ' ' + c.label + ' ' + c.kind).toLowerCase() }); }); }); return out; }
|
||||
function spoolHay(s) { return ('#' + s.id + ' ' + s.id + ' ' + fullName(s) + ' ' + s.color + ' ' + where(s).text).toLowerCase(); }
|
||||
function loadCmd(sq, hq) {
|
||||
var sp = SPOOLS.filter(function (s) { return !sq || spoolHay(s).indexOf(sq) >= 0; }).slice(0, 6);
|
||||
if (hq == null) return sp.map(function (s) { return { sec: 'Load which spool? (then type “ into …”)', html: sw(s.c, 'swatch-sm'), t: s.color + ' · ' + fullName(s), m: '#' + s.id + ' · ' + where(s).short, keep: 1, run: function () { $('#pal-q').value = 'load #' + s.id + ' into '; palRender(); } }; });
|
||||
var s1 = sp[0]; if (!s1) return [];
|
||||
return holders().filter(function (h) { return !hq || h.hay.indexOf(hq) >= 0; }).map(function (h) {
|
||||
var full = h.n >= h.c.cap && s1.cont !== h.id;
|
||||
return { sec: 'Load ' + s1.color + ' · ' + s1.brand + ' (#' + s1.id + ') into…', ico: full ? '✕' : '⇥', t: h.pr.model + ' · ' + h.c.label, m: full ? 'full · ' + h.n + '/' + h.c.cap : h.n + '/' + h.c.cap + ' in use', run: function () { if (full) return toast(h.c.label + ' on ' + h.pr.model + ' is full. Unload a spool first.', 'warn'); s1.cont = h.id; s1.v++; toast('Loaded <b>' + esc(s1.color + ' · ' + s1.brand) + '</b> into ' + esc(h.pr.model + ' · ' + h.c.label)); if (PAGE.refresh) PAGE.refresh(); } };
|
||||
});
|
||||
}
|
||||
function attrCmd(pq, sq) {
|
||||
var pr = printsNeeding().filter(function (p) { return !pq || (p.job + ' ' + p.key).toLowerCase().indexOf(pq) >= 0; });
|
||||
if (sq == null) return pr.map(function (p) { return { sec: 'Attribute which print? (then type “ to …”)', ico: '▤', t: p.job, m: needs(p).map(function (n) { return n.t; }).join(', '), keep: 1, run: function () { $('#pal-q').value = 'attribute ' + p.job.toLowerCase() + ' to '; palRender(); } }; });
|
||||
var p1 = pr[0]; if (!p1) return [];
|
||||
return SPOOLS.filter(function (s) { return !sq || spoolHay(s).indexOf(sq) >= 0; }).slice(0, 8).map(function (s) {
|
||||
return { sec: 'Attribute ' + p1.job + ' to…', html: sw(s.c, 'swatch-sm'), t: s.color + ' · ' + fullName(s), m: '#' + s.id + ' · ' + g(s.rem), run: function () { if (p1.filaments) p1.filaments.forEach(function (f) { if (f.spool == null) f.spool = s.id; }); else p1.spool = s.id; toast('Attributed <b>' + esc(p1.job) + '</b> to ' + esc(short(s)) + (p1.grams != null ? ' · ' + g(p1.grams) + ' deducted' : '')); if (PAGE.refresh) PAGE.refresh(); } };
|
||||
});
|
||||
}
|
||||
function go(kind, id, tab) {
|
||||
var pageFor = { spool: null, print: null };
|
||||
if (window.PAGE && PAGE.name) openDrawer(kind, id, tab);
|
||||
}
|
||||
function palRender() {
|
||||
P.items = palItems($('#pal-q').value); if (P.sel >= P.items.length) P.sel = 0;
|
||||
var last = '', html = '';
|
||||
P.items.forEach(function (it, i) {
|
||||
if (it.sec !== last) { html += '<div class="pal-sec">' + esc(it.sec) + '</div>'; last = it.sec; }
|
||||
html += '<div class="pal-item" role="option" id="pi' + i + '" data-i="' + i + '" aria-selected="' + (i === P.sel) + '">' + (it.html || '<span style="width:18px;text-align:center" aria-hidden="true">' + it.ico + '</span>') + '<span>' + esc(it.t) + '</span><span class="meta">' + esc(it.m || '') + '</span></div>';
|
||||
});
|
||||
$('#pal-list').innerHTML = html || '<div class="empty"><h3>No matches</h3><p>Try a color, brand, <code>#47</code>, a job name or a printer.</p></div>';
|
||||
$('#pal-q').setAttribute('aria-activedescendant', P.items.length ? 'pi' + P.sel : '');
|
||||
var cur = $('#pi' + P.sel); if (cur) cur.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
function palOpen(prefill) { $('#pal').classList.add('is-open'); $('#pal-q').value = prefill || ''; P.sel = 0; palRender(); $('#pal-q').focus(); }
|
||||
function palClose() { $('#pal').classList.remove('is-open'); }
|
||||
function palRun(i) { var it = P.items[i]; if (!it) return; if (!it.keep) palClose(); if (it.href) location.href = it.href; else it.run(); }
|
||||
|
||||
/* ── freshness + demos ───────────────────────────────────── */
|
||||
var fresh = { n: 3, stale: false };
|
||||
function tick() {
|
||||
fresh.n = fresh.stale ? fresh.n + 1 : (fresh.n >= 10 ? 0 : fresh.n + 1);
|
||||
var f = $('#fresh'); if (!f) return;
|
||||
f.classList.toggle('is-stale', fresh.stale);
|
||||
$('#fresh-t').innerHTML = fresh.stale ? 'Not updating · data from 09:41:07 <span class="long">· retrying</span>' : 'Live <span class="long">· updated ' + fresh.n + 's ago</span>';
|
||||
}
|
||||
function demo(kind) {
|
||||
var main = $('#page');
|
||||
if (kind === 'loading') {
|
||||
var keep = main.innerHTML;
|
||||
main.innerHTML = '<div class="stack" style="gap:24px" aria-busy="true" aria-label="Loading"><div class="skel" style="width:40%;height:28px"></div><div class="stats">' + [1, 2, 3, 4].map(function () { return '<div class="stat nm-raised"><div class="skel" style="width:50%"></div><div class="skel" style="width:70%;height:24px;margin-top:10px"></div></div>'; }).join('') + '</div><div class="nm-raised card stack">' + [1, 2, 3, 4, 5].map(function () { return '<div class="skel" style="height:18px"></div>'; }).join('') + '</div></div>';
|
||||
setTimeout(function () { main.innerHTML = keep; if (PAGE.after) PAGE.after(); }, 1600);
|
||||
}
|
||||
if (kind === 'offline') { fresh.stale = true; fresh.n = 0; tick(); var b = $('#offline-banner'); if (!b) { b = document.createElement('div'); b.id = 'offline-banner'; b.className = 'banner'; b.setAttribute('role', 'alert'); b.innerHTML = '<b>⚠</b><span style="flex:1">Can\'t reach Extrudex. You are looking at data from <b>09:41:07</b>, and it is not updating. Retrying every 10 s. Saving is paused.</span><button class="btn btn-sm" type="button" data-demo="online">Retry now</button>'; main.parentNode.insertBefore(b, main); } }
|
||||
if (kind === 'online') { fresh.stale = false; fresh.n = 0; tick(); var ob = $('#offline-banner'); if (ob) ob.parentNode.removeChild(ob); }
|
||||
}
|
||||
|
||||
/* ── wiring ──────────────────────────────────────────────── */
|
||||
function parseHash() {
|
||||
var m = /^#(spool|print|load|pedit|holders|premove)=([\w-]+)(?:&tab=(\w+))?(?:&state=(\w+))?/.exec(location.hash);
|
||||
if (m) { var id = (m[1] === 'load' || m[2] === 'new') ? m[2] : +m[2]; openDrawer(m[1], id, m[3], false); if (m[4]) { D.state = m[4]; renderDrawer(); } }
|
||||
else if (D.kind) closeDrawer(false);
|
||||
}
|
||||
function init(page, render) {
|
||||
var s = shell(page.name);
|
||||
document.body.insertAdjacentHTML('afterbegin', '<a class="btn btn-sm" href="#page" style="position:absolute;left:-9999px;top:8px" onfocus="this.style.left=\'8px\'" onblur="this.style.left=\'-9999px\'">Skip to content</a><div class="shell">' + s.rail + '<main id="main">' + s.top + '<div id="page"></div></main></div>' + s.tabbar + s.rest);
|
||||
window.PAGE = page;
|
||||
$('#page').innerHTML = render();
|
||||
if (page.after) page.after();
|
||||
setTheme(document.documentElement.getAttribute('data-theme'));
|
||||
setInterval(tick, 1000);
|
||||
parseHash();
|
||||
var pq = /[?&]palette=([^&]*)/.exec(location.search); if (pq) palOpen(decodeURIComponent(pq[1]).replace(/\+/g, ' '));
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if ((a = t.closest('[data-set-theme]'))) return setTheme(a.getAttribute('data-set-theme'));
|
||||
if (t.closest('[data-toggle-theme]')) return setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
|
||||
if (t.closest('[data-palette]')) return palOpen();
|
||||
if (t.id === 'pal') return palClose();
|
||||
if ((a = t.closest('.pal-item'))) return palRun(+a.getAttribute('data-i'));
|
||||
if ((a = t.closest('[data-menu]'))) { e.preventDefault(); var m = $('#' + a.getAttribute('data-menu')), open = !m.classList.contains('is-open'); $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); }); m.classList.toggle('is-open', open); a.setAttribute('aria-expanded', String(open)); if (open) { var f = m.querySelector('button,a'); if (f) f.focus(); } e.stopPropagation(); return; }
|
||||
if (!t.closest('.menu')) $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); });
|
||||
if ((a = t.closest('[data-demo]'))) { $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); }); return demo(a.getAttribute('data-demo')); }
|
||||
if ((a = t.closest('[data-soon]'))) { e.preventDefault(); return toast('<b>' + a.getAttribute('data-soon') + '</b> keeps its current screen on dev. It isn\'t redesigned in this iteration.'); }
|
||||
if ((a = t.closest('[data-spool]'))) { e.preventDefault(); D.list = null; return openDrawer('spool', +a.getAttribute('data-spool'), a.getAttribute('data-tab-open') || 'overview'); }
|
||||
if ((a = t.closest('[data-print]'))) { e.preventDefault(); return openDrawer('print', +a.getAttribute('data-print')); }
|
||||
if ((a = t.closest('[data-load-into]'))) { e.preventDefault(); return openDrawer('load', a.getAttribute('data-load-into')); }
|
||||
if ((a = t.closest('[data-pedit]'))) { e.preventDefault(); var pv = a.getAttribute('data-pedit'); return openDrawer('pedit', pv === 'new' ? 'new' : +pv); }
|
||||
if ((a = t.closest('[data-holders]'))) { e.preventDefault(); return openDrawer('holders', +a.getAttribute('data-holders')); }
|
||||
if ((a = t.closest('[data-premove]'))) { e.preventDefault(); return openDrawer('premove', +a.getAttribute('data-premove')); }
|
||||
if (t.id === 'rm-go') { closeDrawer(); return toast('Removed <b>' + esc(t.getAttribute('data-name')) + '</b>. Its print history is kept.'); }
|
||||
if ((a = t.closest('[data-add-spool]'))) { e.preventDefault(); return openDrawer('new', 0, 'edit'); }
|
||||
if (t.closest('[data-close-drawer]')) return closeDrawer();
|
||||
if ((a = t.closest('#drawer [data-tab]'))) { D.tab = a.getAttribute('data-tab'); D.state = 'normal'; renderDrawer(); history.replaceState({ d: 1 }, '', '#' + D.kind + '=' + D.id + (D.tab !== 'overview' ? '&tab=' + D.tab : '')); var f = $('#drawer [autofocus]'); if (f) f.focus(); return; }
|
||||
if ((a = t.closest('[data-state]'))) { D.state = a.getAttribute('data-state'); return renderDrawer(); }
|
||||
if ((a = t.closest('[data-step]'))) { var i = D.list.indexOf(D.id) + +a.getAttribute('data-step'); if (D.list[i] != null) openDrawer('spool', D.list[i], D.tab, false); history.replaceState({ d: 1 }, '', '#spool=' + D.id + (D.tab !== 'overview' ? '&tab=' + D.tab : '')); return; }
|
||||
if ((a = t.closest('[data-dest]'))) { $$('[data-dest]').forEach(function (b) { b.setAttribute('aria-pressed', String(b === a)); }); $('#m-save').disabled = false; var dc = a.getAttribute('data-dest'), cc = dc !== 'storage' && container(dc); $('#m-save').textContent = cc ? 'Load into ' + cc.printer.model + ' · ' + cc.c.label : 'Move to storage'; return; }
|
||||
if ((a = t.closest('[data-load]'))) { $$('[data-load]').forEach(function (b) { b.setAttribute('aria-pressed', String(b === a)); }); var ls = spool(+a.getAttribute('data-load')); $('#l-save').disabled = false; $('#l-save').textContent = 'Load ' + ls.color + ' · ' + ls.brand; $('#l-save').setAttribute('data-sid', ls.id); return; }
|
||||
if (t.id === 'l-save') { var sid = +t.getAttribute('data-sid'), ss = spool(sid), cc2 = container(D.id); ss.cont = D.id; closeDrawer(); toast('Loaded <b>' + esc(ss.color + ' · ' + ss.brand) + '</b> into ' + esc(cc2.printer.model + ' · ' + cc2.c.label) + ' <a href="#spool=' + sid + '" data-spool="' + sid + '">View</a>'); if (PAGE.refresh) PAGE.refresh(); return; }
|
||||
if (t.id === 'm-save') { var dest = $('[data-dest][aria-pressed="true"]').getAttribute('data-dest'), sp = spool(D.id); sp.cont = dest === 'storage' ? null : dest; sp.v++; D.tab = 'overview'; renderDrawer(); toast('Moved <b>' + esc(sp.color) + '</b> to ' + esc(where(sp).text)); if (PAGE.refresh) PAGE.refresh(); return; }
|
||||
if (t.id === 'w-save' || t.id === 'w-next') { var ws = spool(D.id), before = ws.rem, nv = Math.round(+$('#scale').value.replace(/[^\d.]/g, '') - ws.tare); ws.rem = nv; ws.remSrc = 'weighed'; ws.weighedAt = '18 Sep 09:41'; ws.v++; toast('Weighed <b>' + esc(ws.color + ' · ' + ws.brand) + '</b>: ' + g(before) + ' → ' + g(nv)); if (PAGE.refresh) PAGE.refresh(); if (t.id === 'w-next') { var nx = D.list[D.list.indexOf(D.id) + 1]; openDrawer('spool', nx, 'weigh', false); history.replaceState({ d: 1 }, '', '#spool=' + nx + '&tab=weigh'); } else { D.tab = 'overview'; renderDrawer(); } return; }
|
||||
if (t.closest('[data-save]')) { var sv = D.kind === 'new'; closeDrawer(); D.dirty = false; return toast(sv ? 'Added spool <b>#57</b>. <a href="#">Print its label</a>' : 'Saved changes'); }
|
||||
if (t.closest('[data-discard]')) { D.dirty = false; return closeDrawer(); }
|
||||
if (t.closest('[data-keep]')) { $('#discard-bar').hidden = true; return; }
|
||||
if ((a = t.closest('[data-fake]'))) { toast(a.getAttribute('data-fake')); return; }
|
||||
if ((a = t.closest('[data-delete]'))) { var ds = spool(+a.getAttribute('data-delete')); $('#drawer .drawer-foot').innerHTML = '<div class="stack" style="gap:8px;width:100%"><b>Delete ' + esc(ds.color + ' · ' + fullName(ds)) + '?</b><span class="small">Its print history stays. Prints keep their grams and cost and only lose the link to this spool. This can\'t be undone.</span><div class="row" style="justify-content:flex-end"><button class="btn" type="button" data-tab="overview" autofocus>Keep spool</button><button class="btn btn-danger" type="button" data-fake="Spool #' + ds.id + ' deleted. 0 prints lost their cost.">Delete spool</button></div></div>'; $('#drawer .drawer-foot [autofocus]').focus(); return; }
|
||||
});
|
||||
document.addEventListener('input', function (e) {
|
||||
if (e.target.id === 'pal-q') { P.sel = 0; return palRender(); }
|
||||
if (e.target.closest('#drawer .drawer-body') && D.tab === 'edit') D.dirty = true;
|
||||
if (e.target.id === 'scale') {
|
||||
var s = spool(D.id), v = +e.target.value.replace(/[^\d.]/g, ''), r = Math.round(v - s.tare);
|
||||
var ok = v > 0 && v >= s.tare && r <= s.tot + 1;
|
||||
$('#w-after').textContent = v ? g(r) : '—';
|
||||
$('#w-diff').innerHTML = v && ok ? '<span class="prov">' + (r - s.rem > 0 ? '+' : '') + (r - s.rem).toLocaleString('en-US') + ' g</span>' : '';
|
||||
$('#w-note').innerHTML = !v ? 'Type the reading and the result appears here before you save.' : v < s.tare ? '<span style="color:var(--status-error)">⚠ ' + g(v) + ' is less than the ' + g(s.tare) + ' empty spool. One of them is wrong. Nothing will be saved.</span>' : r > s.tot + 1 ? '<span style="color:var(--status-error)">⚠ That leaves ' + g(r) + ', more than the spool holds full (' + g(s.tot) + '). Check the total or the empty spool weight.</span>' : 'Saved with the empty spool weight it used, so correcting that weight later re-bases this reading.';
|
||||
$('#w-save').disabled = !ok; if ($('#w-next')) $('#w-next').disabled = !ok;
|
||||
}
|
||||
if (e.target.id === 'rm-name') { $('#rm-go').disabled = e.target.value !== $('#rm-go').getAttribute('data-name'); }
|
||||
if (e.target.id === 'load-q') { var q = e.target.value.toLowerCase(); $$('[data-load]').forEach(function (b) { b.hidden = q && b.getAttribute('data-hay').indexOf(q) < 0; }); }
|
||||
});
|
||||
document.addEventListener('change', function (e) { if (e.target.id === 'pe-brand') { D.brand = e.target.value; renderDrawer(); } });
|
||||
document.addEventListener('keydown', function (e) {
|
||||
var typing = /INPUT|SELECT|TEXTAREA/.test(document.activeElement.tagName);
|
||||
if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || (e.key === '/' && !typing)) { e.preventDefault(); return palOpen(); }
|
||||
if ($('#pal').classList.contains('is-open')) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); return palClose(); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); P.sel = Math.min(P.items.length - 1, P.sel + 1); return palRender(); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); P.sel = Math.max(0, P.sel - 1); return palRender(); }
|
||||
if (e.key === 'Enter') { e.preventDefault(); return palRun(P.sel); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') { if ($('.menu.is-open')) { $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); }); return; } if (D.kind) return closeDrawer(); }
|
||||
if (D.kind === 'spool' && !typing && D.list && (e.key === 'j' || e.key === 'k')) { var b = $('[data-step="' + (e.key === 'j' ? 1 : -1) + '"]'); if (b && !b.disabled) b.click(); }
|
||||
if (e.key === 'Enter' && document.activeElement.matches('tr[data-row-spool],tr[data-row-print]')) document.activeElement.click();
|
||||
if (D.kind && e.key === 'Tab') { var dr = $('#drawer'), f = $$('button:not([disabled]),a[href],input,select,[tabindex="0"]', dr).filter(function (x) { return x.offsetParent; }); if (!f.length) return; if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); } else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); } }
|
||||
});
|
||||
window.addEventListener('popstate', parseHash);
|
||||
|
||||
window.X = { unverified: unverified, holderIds: HOLDER_IDS, init: init, esc: esc, g: g, kg: kg, usd: usd, dur: dur, when: when, ago: ago, since: since, spool: spool, printer: printer, print: print, container: container, contents: contents, fullName: fullName, short: short, where: where, gross: gross, value: value, low: low, sw: sw, meter: meter, tareProv: tareProv, remProv: remProv, weightProv: weightProv, durCell: durCell, printSt: printSt, printerSt: printerSt, needs: needs, printsNeeding: printsNeeding, spoolLinks: spoolLinks, openDrawer: openDrawer, D: D, toast: toast, ICON: ICON };
|
||||
})();
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Iteration 2: assemble self-contained pages.
|
||||
|
||||
Each screen = v3 tokens (fonts inlined as base64) + iteration-1 app.css
|
||||
utilities + components.css + page CSS, then data.js + app.js + page script.
|
||||
Run: python3 build.py (writes ../screens/*.html)
|
||||
"""
|
||||
import base64, pathlib, re
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent.parent # ~/Design/extrudex-v3
|
||||
OUT = HERE.parent / 'screens'
|
||||
OUT.mkdir(exist_ok=True)
|
||||
|
||||
tokens = (ROOT / 'design-system-v3.css').read_text()
|
||||
tokens = re.sub(r"url\('\./fonts/([^']+)'\)",
|
||||
lambda m: "url('data:font/woff2;base64," + base64.b64encode((ROOT / 'fonts' / m.group(1)).read_bytes()).decode() + "')",
|
||||
tokens)
|
||||
base_css = (ROOT / 'source' / 'app.css').read_text()
|
||||
comp_css = (HERE / 'components.css').read_text()
|
||||
data_js = (HERE / 'data.js').read_text()
|
||||
app_js = (HERE / 'app.js').read_text()
|
||||
|
||||
THEME = """<script>
|
||||
(function () { var t = null; try { t = localStorage.getItem('extrudex-v3-theme'); } catch (e) {}
|
||||
var q = /[?&]theme=(light|dark)/.exec(location.search); if (q) t = q[1];
|
||||
if (t !== 'light' && t !== 'dark') t = window.matchMedia && matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', t); })();
|
||||
</script>"""
|
||||
|
||||
def build(slug, title, desc):
|
||||
src = (HERE / 'pages' / f'{slug}.html').read_text()
|
||||
css = ''.join(re.findall(r'<style>(.*?)</style>', src, re.S))
|
||||
js = ''.join(re.findall(r'<script>(.*?)</script>', src, re.S))
|
||||
html = f"""<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
<meta name="description" content="{desc}">
|
||||
{THEME}
|
||||
<style>
|
||||
{tokens}
|
||||
{base_css}
|
||||
{comp_css}
|
||||
{css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
{data_js}
|
||||
{app_js}
|
||||
</script>
|
||||
<script>
|
||||
{js}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
(OUT / f'{slug}.html').write_text(html)
|
||||
print(slug, len(html) // 1024, 'KB')
|
||||
|
||||
PAGES = [
|
||||
('index', 'Extrudex Flow Map', 'How the iteration-2 screens connect, with guided flows.'),
|
||||
('01-dashboard', 'Extrudex Dashboard', 'Attention-first dashboard, iteration 3.'),
|
||||
('02-filament', 'Extrudex Filament', 'Filament inventory with drawer, filters and weigh session, iteration 3.'),
|
||||
('03-prints', 'Extrudex Prints', 'Print history and attribution queue, iteration 3.'),
|
||||
('04-printers', 'Extrudex Printers', 'Printers with holders, load flow, admin panels and connection state.'),
|
||||
('05-lists', 'Extrudex Lists', 'Taxonomy lists the spool name is built from.'),
|
||||
('06-labels', 'Extrudex Labels', 'Spool and holder QR labels.'),
|
||||
('07-access', 'Extrudex Access', 'People, sessions, machines and API tokens.'),
|
||||
('08-system', 'Extrudex System', 'Low-filament report, audit log and this device.'),
|
||||
('09-scan-holder', 'Extrudex Holder Scan', 'Phone-first holder scan session.'),
|
||||
('10-scan-spool', 'Extrudex Spool Scan', 'Phone-first scanned spool and weigh-in.'),
|
||||
('devices', 'Extrudex Devices', 'Every screen in phone and tablet frames.'),
|
||||
]
|
||||
for p in PAGES:
|
||||
if (HERE / 'pages' / f'{p[0]}.html').exists():
|
||||
build(*p)
|
||||
@@ -0,0 +1,275 @@
|
||||
/* ── Design System v3.1 components (iteration 2) ─────────────────────────
|
||||
* Built only from design-system-v3.css tokens. No new colors. */
|
||||
|
||||
/* Shell */
|
||||
.shell { display: grid; grid-template-columns: 224px minmax(0, 1fr); gap: var(--space-6); max-width: 1480px; margin: 0 auto; padding: var(--space-5) var(--space-6); min-height: 100vh; }
|
||||
.rail { position: sticky; top: var(--space-5); align-self: start; padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-4); max-height: calc(100vh - 40px); }
|
||||
.wordmark { font-weight: var(--fw-display); font-size: var(--fs-h3); display: flex; align-items: center; gap: var(--space-2); padding: 4px 6px; }
|
||||
.wordmark-mark { width: 30px; height: 30px; border-radius: 10px; background: var(--accent-fill); color: var(--text-on-accent); display: grid; place-items: center; font-size: 15px; border: 1px solid var(--border-control); box-shadow: var(--nm-raised-1); }
|
||||
.nav-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.nav-label { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); font-weight: var(--fw-bold); padding: 8px 12px 2px; }
|
||||
.nav a { display: flex; align-items: center; gap: var(--space-3); padding: 9px 12px; border-radius: var(--radius-md); color: var(--text-primary); text-decoration: none; border: 1px solid transparent; font-weight: var(--fw-medium); position: relative; }
|
||||
.nav a:hover { border-color: var(--border-control); }
|
||||
.nav a:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
|
||||
.nav a[aria-current="page"] { background: var(--surface-inset); box-shadow: var(--nm-inset-1); border-color: var(--text-accent); color: var(--text-accent); font-weight: var(--fw-bold); }
|
||||
.nav svg { width: 18px; height: 18px; flex: none; }
|
||||
.nav .count { margin-left: auto; min-width: 22px; height: 20px; padding: 0 6px; border-radius: var(--radius-pill); font-size: var(--fs-caption); font-weight: var(--fw-bold); display: inline-grid; place-items: center; border: 1px solid currentColor; }
|
||||
.count-warn { color: var(--status-warning); background: var(--wash-warning); }
|
||||
.count-err { color: var(--status-error); background: var(--wash-error); }
|
||||
.rail-foot { margin-top: auto; display: flex; flex-direction: column; gap: var(--space-3); border-top: 1px solid var(--border-hairline); padding-top: var(--space-3); }
|
||||
|
||||
/* Top bar: search, freshness, context */
|
||||
.topbar { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
|
||||
.search-trigger { flex: 1; min-width: 220px; max-width: 560px; display: flex; align-items: center; gap: var(--space-3); padding: 9px 14px; border-radius: var(--radius-pill); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-control); color: var(--text-secondary); font: inherit; cursor: pointer; text-align: left; }
|
||||
.search-trigger:hover { border-color: var(--text-accent); }
|
||||
.search-trigger:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: var(--focus-offset); }
|
||||
.search-trigger svg { width: 16px; height: 16px; }
|
||||
.kbd { font: var(--fw-bold) 10px/1 var(--font-mono); padding: 3px 6px; border-radius: 5px; border: 1px solid var(--border-control); color: var(--text-secondary); background: var(--surface-raised); box-shadow: 0 1px 0 var(--border-strong); }
|
||||
.fresh { display: inline-flex; align-items: center; gap: 8px; font-size: var(--fs-label); color: var(--text-secondary); }
|
||||
.fresh .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--status-success); box-shadow: 0 0 0 3px var(--wash-success); }
|
||||
.fresh.is-stale .dot { background: var(--status-warning); box-shadow: 0 0 0 3px var(--wash-warning); }
|
||||
.fresh.is-stale { color: var(--status-warning); }
|
||||
|
||||
/* Breadcrumb */
|
||||
.crumbs { display: flex; gap: 6px; align-items: center; font-size: var(--fs-label); color: var(--text-secondary); }
|
||||
.crumbs a { color: var(--text-secondary); text-decoration: none; }
|
||||
.crumbs a:hover { color: var(--text-accent); text-decoration: underline; }
|
||||
|
||||
/* Page header: title, one-line summary, ONE primary action, overflow for the rest */
|
||||
.page-head { display: flex; flex-wrap: wrap; gap: var(--space-4); align-items: flex-end; justify-content: space-between; }
|
||||
.page-head h1 { margin: 0; font-size: var(--fs-h1); line-height: var(--lh-tight); font-weight: var(--fw-display); }
|
||||
.page-head .sum { margin: 6px 0 0; color: var(--text-secondary); font-size: 13px; }
|
||||
.page-head .sum b { color: var(--text-primary); }
|
||||
|
||||
/* Overflow / row menu */
|
||||
.menu-wrap { position: relative; display: inline-block; }
|
||||
.menu { position: absolute; right: 0; top: calc(100% + 6px); z-index: 30; min-width: 220px; padding: 6px; display: none; flex-direction: column; gap: 2px; background: var(--surface-high); border: 1px solid var(--border-strong); border-radius: var(--radius-md); box-shadow: var(--nm-raised-3); }
|
||||
.menu.is-open { display: flex; }
|
||||
.menu button, .menu a { all: unset; box-sizing: border-box; display: flex; gap: 10px; align-items: center; padding: 8px 10px; border-radius: var(--radius-sm); font-size: 13px; cursor: pointer; color: var(--text-primary); }
|
||||
.menu button:hover, .menu a:hover, .menu button:focus-visible, .menu a:focus-visible { background: var(--accent-wash); outline: 1px solid var(--text-accent); }
|
||||
.menu .danger { color: var(--status-error); }
|
||||
.menu hr { border: 0; border-top: 1px solid var(--border-hairline); margin: 4px 0; width: 100%; }
|
||||
.menu .hint { margin-left: auto; font-size: var(--fs-caption); color: var(--text-secondary); }
|
||||
|
||||
/* Tabs (segmented, with counts) */
|
||||
.tabs { display: flex; gap: 4px; padding: 4px; border-radius: var(--radius-pill); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-control); width: max-content; max-width: 100%; overflow-x: auto; }
|
||||
.tabs button { font: var(--fw-medium) 13px/1 var(--font-body); color: var(--text-secondary); background: transparent; border: 1px solid transparent; border-radius: var(--radius-pill); padding: 8px 14px; cursor: pointer; display: inline-flex; gap: 8px; align-items: center; white-space: nowrap; }
|
||||
.tabs button[aria-selected="true"] { background: var(--surface-raised); color: var(--text-primary); font-weight: var(--fw-bold); box-shadow: var(--nm-raised-1); border-color: var(--border-control); }
|
||||
.tabs button:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
|
||||
.tabs .n { font-size: var(--fs-caption); padding: 1px 7px; border-radius: var(--radius-pill); border: 1px solid currentColor; }
|
||||
.tabs .n.warn { color: var(--status-warning); }
|
||||
|
||||
/* Filter chips (removable) */
|
||||
.fchips { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }
|
||||
.fchip { display: inline-flex; gap: 6px; align-items: center; padding: 4px 6px 4px 10px; border-radius: var(--radius-pill); border: 1px solid var(--text-accent); background: var(--accent-wash); color: var(--text-accent); font-size: var(--fs-label); font-weight: var(--fw-bold); }
|
||||
.fchip button { all: unset; cursor: pointer; width: 18px; height: 18px; display: grid; place-items: center; border-radius: 50%; }
|
||||
.fchip button:hover, .fchip button:focus-visible { background: var(--text-accent); color: var(--text-on-accent); }
|
||||
.fadd { font-size: var(--fs-label); }
|
||||
|
||||
/* Quiet provenance: icon + lowercase text, never a chip */
|
||||
.prov { display: inline-flex; gap: 4px; align-items: center; font-size: var(--fs-caption); color: var(--text-secondary); white-space: nowrap; }
|
||||
.prov i { font-style: normal; width: 14px; text-align: center; }
|
||||
.prov.warn { color: var(--status-warning); font-weight: var(--fw-bold); }
|
||||
.prov.info { color: var(--status-info); }
|
||||
|
||||
/* Status: small filled label + glyph. Reserved for state, not provenance */
|
||||
.st { display: inline-flex; gap: 6px; align-items: center; font-size: var(--fs-label); font-weight: var(--fw-bold); white-space: nowrap; }
|
||||
.st::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex: none; }
|
||||
.st.run { color: var(--text-accent); } .st.ok { color: var(--status-success); } .st.err { color: var(--status-error); } .st.warn { color: var(--status-warning); } .st.idle { color: var(--text-secondary); }
|
||||
.st.warn::before { border-radius: 2px; transform: rotate(45deg); }
|
||||
.st.err::before { border-radius: 1px; }
|
||||
.st.idle::before { background: transparent; border: 2px solid currentColor; width: 6px; height: 6px; }
|
||||
|
||||
/* Data tables (v3.1): quieter, row = one line where possible */
|
||||
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.tbl th { text-align: left; font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps); color: var(--text-secondary); font-weight: var(--fw-bold); padding: 10px 12px; border-bottom: 1px solid var(--border-strong); white-space: nowrap; position: sticky; top: 0; background: var(--surface-raised); z-index: 1; }
|
||||
.tbl th button.sort { all: unset; cursor: pointer; display: inline-flex; gap: 4px; align-items: center; }
|
||||
.tbl th button.sort:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; border-radius: 3px; }
|
||||
.tbl th[aria-sort="ascending"] button.sort::after { content: "▲"; font-size: 9px; color: var(--text-accent); }
|
||||
.tbl th[aria-sort="descending"] button.sort::after { content: "▼"; font-size: 9px; color: var(--text-accent); }
|
||||
.tbl th[aria-sort="none"] button.sort::after { content: "↕"; font-size: 10px; opacity: .6; }
|
||||
.tbl td { padding: 10px 12px; border-bottom: 1px solid var(--border-hairline); vertical-align: middle; }
|
||||
.tbl tr:last-child td { border-bottom: 0; }
|
||||
.tbl .r { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.tbl tbody tr.clickable { cursor: pointer; }
|
||||
.tbl tbody tr.clickable:hover td { background: var(--accent-wash); }
|
||||
.tbl tbody tr.is-selected td { background: var(--accent-wash); box-shadow: inset 0 1px 0 var(--text-accent), inset 0 -1px 0 var(--text-accent); }
|
||||
.tbl tbody tr.is-selected td:first-child { box-shadow: inset 3px 0 0 var(--text-accent), inset 0 1px 0 var(--text-accent), inset 0 -1px 0 var(--text-accent); }
|
||||
.tbl tbody tr:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
|
||||
.sub { display: block; font-size: var(--fs-caption); color: var(--text-secondary); font-weight: var(--fw-regular); line-height: var(--lh-snug); }
|
||||
.strong { font-weight: var(--fw-bold); }
|
||||
.num { font-variant-numeric: tabular-nums; }
|
||||
.table-card { padding: 0; overflow: hidden; }
|
||||
.table-scroll { overflow: auto; max-height: 70vh; }
|
||||
.table-foot { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border-top: 1px solid var(--border-hairline); flex-wrap: wrap; font-size: var(--fs-label); }
|
||||
|
||||
/* Meter: remaining bar with the 200 g threshold tick */
|
||||
.meter { position: relative; height: 8px; border-radius: var(--radius-pill); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); overflow: visible; min-width: 90px; }
|
||||
.meter > span { display: block; height: 100%; border-radius: var(--radius-pill); background: var(--text-accent); }
|
||||
.meter.is-low > span { background: var(--status-warning); }
|
||||
.meter .tick { position: absolute; top: -3px; bottom: -3px; width: 2px; background: var(--text-secondary); border-radius: 1px; }
|
||||
|
||||
/* Drawer (right sheet; full-screen on phones) */
|
||||
.drawer-scrim { position: fixed; inset: 0; background: rgba(10, 10, 14, .5); opacity: 0; pointer-events: none; transition: opacity var(--dur-base) var(--ease-out); z-index: 40; }
|
||||
:root[data-theme="light"] .drawer-scrim { background: rgba(60, 55, 80, .28); }
|
||||
.drawer { position: fixed; top: 12px; right: 12px; bottom: 12px; width: min(520px, calc(100vw - 24px)); z-index: 41; transform: translateX(calc(100% + 24px)); transition: transform 220ms var(--ease-out); display: flex; flex-direction: column; overflow: hidden; }
|
||||
body.drawer-open .drawer { transform: none; }
|
||||
body.drawer-open .drawer-scrim { opacity: 1; pointer-events: auto; }
|
||||
.drawer-head { padding: var(--space-5) var(--space-5) var(--space-3); display: flex; gap: var(--space-3); align-items: flex-start; border-bottom: 1px solid var(--border-hairline); }
|
||||
.drawer-head h2 { margin: 0; font-size: var(--fs-h3); line-height: var(--lh-snug); }
|
||||
.drawer-tabs { padding: var(--space-3) var(--space-5) 0; }
|
||||
.drawer-body { flex: 1; overflow: auto; padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-5); }
|
||||
.drawer-foot { padding: var(--space-4) var(--space-5); border-top: 1px solid var(--border-hairline); display: flex; gap: var(--space-3); justify-content: flex-end; align-items: center; flex-wrap: wrap; }
|
||||
.drawer-nav { display: flex; gap: 4px; }
|
||||
@media (prefers-reduced-motion: reduce) { .drawer, .drawer-scrim { transition: none; } }
|
||||
|
||||
/* Key-value list */
|
||||
.kv { display: grid; grid-template-columns: minmax(110px, auto) 1fr; gap: 8px var(--space-4); margin: 0; font-size: 13px; }
|
||||
.kv dt { color: var(--text-secondary); }
|
||||
.kv dd { margin: 0; font-variant-numeric: tabular-nums; }
|
||||
.panel { padding: var(--space-4); }
|
||||
.panel h3 { margin: 0 0 var(--space-3); font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); }
|
||||
|
||||
/* Command palette */
|
||||
.pal-scrim { position: fixed; inset: 0; background: rgba(10, 10, 14, .55); z-index: 60; display: none; align-items: flex-start; justify-content: center; padding-top: 12vh; }
|
||||
:root[data-theme="light"] .pal-scrim { background: rgba(60, 55, 80, .3); }
|
||||
.pal-scrim.is-open { display: flex; }
|
||||
.pal { width: min(640px, calc(100vw - 32px)); max-height: 70vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.pal-input { display: flex; gap: var(--space-3); align-items: center; padding: var(--space-4); border-bottom: 1px solid var(--border-hairline); }
|
||||
.pal-input input { all: unset; flex: 1; font: var(--fw-medium) 16px/1.4 var(--font-body); color: var(--text-primary); }
|
||||
.pal-input input::placeholder { color: var(--text-secondary); }
|
||||
.pal-list { overflow: auto; padding: 6px; }
|
||||
.pal-sec { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); font-weight: var(--fw-bold); padding: 10px 10px 4px; }
|
||||
.pal-item { display: flex; gap: var(--space-3); align-items: center; padding: 9px 10px; border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; border: 1px solid transparent; }
|
||||
.pal-item[aria-selected="true"] { background: var(--accent-wash); border-color: var(--text-accent); }
|
||||
.pal-item .meta { margin-left: auto; font-size: var(--fs-caption); color: var(--text-secondary); white-space: nowrap; }
|
||||
.pal-foot { padding: 8px var(--space-4); border-top: 1px solid var(--border-hairline); display: flex; gap: var(--space-4); font-size: var(--fs-caption); color: var(--text-secondary); flex-wrap: wrap; }
|
||||
|
||||
/* Toasts */
|
||||
.toasts { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 70; display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; }
|
||||
.toast { pointer-events: auto; display: flex; gap: var(--space-3); align-items: center; padding: 10px 12px 10px 16px; background: var(--surface-high); border: 1px solid var(--border-strong); border-left: 4px solid var(--status-success); border-radius: var(--radius-md); box-shadow: var(--nm-raised-3); font-size: 13px; max-width: min(560px, calc(100vw - 32px)); }
|
||||
.toast.warn { border-left-color: var(--status-warning); }
|
||||
|
||||
/* Attention list (dashboard) */
|
||||
.attn-list { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.attn { display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; gap: var(--space-4); align-items: center; padding: var(--space-4); border-radius: var(--radius-md); background: var(--surface-raised); border: 1px solid var(--border-hairline); box-shadow: var(--nm-raised-2); }
|
||||
.attn .ico { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; font-size: 20px; font-weight: var(--fw-bold); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); }
|
||||
.attn.warn .ico { color: var(--status-warning); border-color: var(--status-warning); }
|
||||
.attn.err .ico { color: var(--status-error); border-color: var(--status-error); }
|
||||
.attn.info .ico { color: var(--status-info); }
|
||||
.attn h3 { margin: 0; font-size: 14px; }
|
||||
.attn p { margin: 2px 0 0; font-size: var(--fs-label); color: var(--text-secondary); }
|
||||
.attn-done { padding: var(--space-5); text-align: center; color: var(--text-secondary); }
|
||||
|
||||
/* Skeleton + empty + error states */
|
||||
.skel { border-radius: 6px; background: linear-gradient(90deg, var(--surface-inset) 0%, var(--border-hairline) 50%, var(--surface-inset) 100%); background-size: 200% 100%; animation: skel 1.4s linear infinite; height: 12px; }
|
||||
@keyframes skel { to { background-position: -200% 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { .skel { animation: none; } }
|
||||
.empty { padding: var(--space-8) var(--space-5); text-align: center; display: flex; flex-direction: column; gap: var(--space-3); align-items: center; }
|
||||
.empty h3 { margin: 0; font-size: 15px; }
|
||||
.empty p { margin: 0; color: var(--text-secondary); font-size: 13px; max-width: 420px; }
|
||||
.banner { display: flex; gap: var(--space-3); align-items: center; padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); font-size: 13px; border: 1px solid var(--status-warning); background: var(--wash-warning); }
|
||||
.banner.err { border-color: var(--status-error); background: var(--wash-error); }
|
||||
|
||||
/* Slot grid (printers + move flow) */
|
||||
.slotgrid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
||||
.slotgrid.one { grid-template-columns: minmax(0, 1fr); max-width: 25%; }
|
||||
.slot { all: unset; box-sizing: border-box; cursor: pointer; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; padding: 10px; min-height: 92px; border-radius: var(--radius-md); border: 1px solid var(--border-control); background: var(--surface-raised); box-shadow: var(--nm-raised-1); font-size: var(--fs-caption); position: relative; min-width: 0; }
|
||||
.slot:hover { border-color: var(--text-accent); }
|
||||
.slot:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
|
||||
.slot .sn { color: var(--text-secondary); font-weight: var(--fw-bold); }
|
||||
.slot .nm { font-weight: var(--fw-bold); font-size: 12px; line-height: var(--lh-snug); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.slot .g { margin-top: auto; font-variant-numeric: tabular-nums; font-size: 12px; }
|
||||
.slot.empty { border-style: dashed; box-shadow: none; background: transparent; color: var(--text-secondary); justify-content: center; align-items: center; text-align: center; }
|
||||
.slot.is-low { border-color: var(--status-warning); }
|
||||
.slot[aria-pressed="true"] { background: var(--accent-wash); border: 2px solid var(--text-accent); box-shadow: var(--nm-inset-1); }
|
||||
.slot[aria-pressed="true"]::after { content: "✓"; position: absolute; top: 6px; right: 8px; color: var(--text-accent); font-weight: var(--fw-bold); font-size: 13px; }
|
||||
.slot[disabled] { cursor: not-allowed; opacity: .75; }
|
||||
|
||||
/* Mockup pill (replaces the iteration-1 banner) */
|
||||
.mock { position: fixed; left: 16px; bottom: 16px; z-index: 35; }
|
||||
.mock > button { font: var(--fw-bold) var(--fs-caption)/1 var(--font-body); text-transform: uppercase; letter-spacing: var(--tracking-caps); padding: 8px 12px; border-radius: var(--radius-pill); border: 1px solid var(--text-accent); background: var(--surface-high); color: var(--text-accent); box-shadow: var(--nm-raised-2); cursor: pointer; }
|
||||
.mock .menu { left: 0; right: auto; top: auto; bottom: calc(100% + 8px); width: 330px; padding: var(--space-4); gap: var(--space-2); font-size: var(--fs-label); }
|
||||
.mock .menu p { margin: 0; line-height: var(--lh-snug); }
|
||||
|
||||
/* Mobile bottom tab bar + FAB */
|
||||
.tabbar { display: none; }
|
||||
.fab { display: none; }
|
||||
@media (max-width: 699px) {
|
||||
.shell { grid-template-columns: minmax(0, 1fr); padding: var(--space-3) var(--space-4) 96px; gap: var(--space-4); }
|
||||
.rail { display: none; }
|
||||
.tabbar { display: grid; grid-template-columns: repeat(4, 1fr); position: fixed; left: 8px; right: 8px; bottom: 8px; z-index: 36; padding: 6px; border-radius: var(--radius-xl); }
|
||||
.tabbar a { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 6px 0; border-radius: var(--radius-lg); color: var(--text-secondary); text-decoration: none; font-size: 11px; font-weight: var(--fw-medium); position: relative; border: 1px solid transparent; }
|
||||
.tabbar a[aria-current="page"] { color: var(--text-accent); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border-color: var(--text-accent); font-weight: var(--fw-bold); }
|
||||
.tabbar svg { width: 20px; height: 20px; }
|
||||
.tabbar .count { position: absolute; top: 2px; right: calc(50% - 22px); min-width: 16px; height: 16px; font-size: 9px; padding: 0 4px; border-radius: 99px; display: grid; place-items: center; border: 1px solid currentColor; }
|
||||
.fab { display: grid; place-items: center; position: fixed; right: 16px; bottom: 88px; z-index: 36; width: 56px; height: 56px; border-radius: 18px; font-size: 26px; padding: 0; }
|
||||
.mock { bottom: 88px; }
|
||||
.topbar .fresh span.long { display: none; }
|
||||
.page-head .primary-desktop { display: none; }
|
||||
.drawer { top: 0; right: 0; bottom: 0; width: 100vw; border-radius: 0; }
|
||||
.slotgrid.one { max-width: 50%; }
|
||||
}
|
||||
@media (max-width: 520px) { .slotgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
|
||||
/* Form grid + small utilities */
|
||||
.fr2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px 16px; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.drawer-body a { color: var(--text-accent); }
|
||||
.linkish { all: unset; cursor: pointer; color: var(--text-accent); text-decoration: underline; }
|
||||
.linkish:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
|
||||
.tile-link { text-decoration: none; color: inherit; }
|
||||
.tile-link:hover { border-color: var(--text-accent); }
|
||||
.tile-link:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: var(--focus-offset); }
|
||||
.is-flash { animation: flash 1.6s var(--ease-out) 1; }
|
||||
@keyframes flash { 0%, 40% { outline: 3px solid var(--focus-ring); outline-offset: 4px; } 100% { outline: 3px solid transparent; outline-offset: 4px; } }
|
||||
@media (prefers-reduced-motion: reduce) { .is-flash { animation: none; outline: 3px solid var(--focus-ring); } }
|
||||
#page { display: flex; flex-direction: column; gap: var(--space-6); min-width: 0; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
#drawer-title:focus { outline: none; }
|
||||
#drawer-title:focus-visible { outline: none; text-decoration: underline; text-decoration-color: var(--focus-ring); text-underline-offset: 4px; }
|
||||
.short { display: none; }
|
||||
@media (max-width: 699px) {
|
||||
.search-trigger .long, .search-trigger .kbd.long, .mock .long { display: none; }
|
||||
.search-trigger .short { display: inline; }
|
||||
.search-trigger { min-width: 0; }
|
||||
.remcell .top { flex-wrap: nowrap; white-space: nowrap; }
|
||||
.remcell .top .sub { display: none !important; }
|
||||
}
|
||||
.pal-input svg { width: 18px; height: 18px; flex: none; color: var(--text-secondary); }
|
||||
|
||||
/* ── Tablet tier (700–1180 px): compact rail, side-sheet panel ───────────── */
|
||||
@media (min-width: 700px) and (max-width: 1180px) {
|
||||
.shell { grid-template-columns: 88px minmax(0, 1fr); gap: var(--space-5); padding: var(--space-4) var(--space-5); }
|
||||
.rail { padding: var(--space-3) 8px; gap: var(--space-3); align-items: stretch; }
|
||||
.wordmark { justify-content: center; font-size: 0; padding: 4px 0; }
|
||||
.wordmark-mark { width: 40px; height: 40px; font-size: 18px; }
|
||||
.nav-label { font-size: 9px; padding: 6px 0 0; text-align: center; letter-spacing: .06em; }
|
||||
.nav a { flex-direction: column; gap: 4px; padding: 10px 2px 8px; font-size: 11px; text-align: center; justify-content: center; }
|
||||
.nav a svg { width: 22px; height: 22px; }
|
||||
.nav a > span:not(.count) { line-height: 1.1; }
|
||||
.nav a .sub { display: none; }
|
||||
.nav .count { position: absolute; top: 4px; right: 10px; margin: 0; min-width: 18px; height: 18px; font-size: 10px; padding: 0 4px; }
|
||||
.rail-foot .seg, .rail-foot .small { display: none; }
|
||||
.rail-foot { border-top: 0; padding-top: 0; }
|
||||
.drawer { width: min(480px, calc(100vw - 96px)); }
|
||||
.hide-t { display: none !important; }
|
||||
.page-head h1 { font-size: var(--fs-h2); }
|
||||
}
|
||||
/* Tablet touch targets: at least 44 px for primary interactive rows */
|
||||
@media (pointer: coarse) {
|
||||
.btn-sm { min-height: 40px; }
|
||||
.tbl td { padding-top: 12px; padding-bottom: 12px; }
|
||||
.menu button, .menu a { padding: 12px 12px; }
|
||||
.kbd { display: none; }
|
||||
}
|
||||
.more-sheet { position: fixed; left: 8px; right: 8px; bottom: 84px; top: auto; z-index: 37; padding: 8px; }
|
||||
.more-sheet a svg, .more-sheet button svg { width: 18px; height: 18px; }
|
||||
.findit { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 12px 14px; border-radius: var(--radius-md); border: 2px solid var(--text-accent); background: var(--accent-wash); }
|
||||
.findit b { font-size: 17px; }
|
||||
.findit .sub { width: 100%; text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); font-weight: 700; }
|
||||
details.chg { margin-top: 8px; }
|
||||
details.chg summary { cursor: pointer; font-size: var(--fs-label); color: var(--text-accent); font-weight: 700; }
|
||||
.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: 8px; }
|
||||
.holder-h { display: flex; gap: var(--space-3); align-items: center; font-size: var(--fs-label); flex-wrap: wrap; }
|
||||
.tabbar { grid-template-columns: repeat(5, 1fr) !important; }
|
||||
@@ -0,0 +1,96 @@
|
||||
/* Sample data for the iteration-2 mockups. One model shared by every page, so a
|
||||
* spool, print or printer is the same object wherever it appears. Values are
|
||||
* based on the real inventory (import/filament_review.tsv, seed.sql spool
|
||||
* types) and the 2026-08-12 production canary. Nothing here is live. */
|
||||
var NOW = new Date('2026-09-18T09:41:07');
|
||||
|
||||
var CANARY = { spools: 105, brands: 18, kg: 95.72, value: 1721.18 };
|
||||
|
||||
/* tsrc: where this spool's tare came from.
|
||||
* 'spool' weighed for this reel (tare_origin=spool)
|
||||
* 'manufacturer' | 'community' | 'estimated' via the chosen spool type
|
||||
* null no tare known */
|
||||
var SPOOLS = [
|
||||
{ id: 40, c: ['#1f3fbf'], color: 'Klein Blue', brand: 'Sunlu', speed: null, mat: 'PLA', type: '+ 2.0', fin: 'Basic', rem: 64, tot: 250, tare: 60, tsrc: 'community', stype: 'Sunlu — 250 g mini', remSrc: 'typed', kg: 25.20, cont: null, loc: 'Shelf B2', label: true, v: 3 },
|
||||
{ id: 41, c: ['#b87333'], color: 'Copper', brand: 'Sunlu', speed: null, mat: 'PLA', type: '+', fin: 'Silk', rem: 97, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', remSrc: 'typed', kg: 19.99, cont: 'p1-ams1', label: true, v: 7 },
|
||||
{ id: 42, c: ['#141414'], color: 'Black', brand: 'Elegoo', speed: 'Rapid', mat: 'PETG', type: null, fin: 'Basic', rem: 142, tot: 1000, tare: 154, tsrc: 'manufacturer', stype: 'Elegoo — 1 kg cardboard', remSrc: 'typed', kg: 11.74, cont: 'p2-ams1', label: true, v: 9 },
|
||||
{ id: 43, c: ['#ece8df'], color: 'Rock White', brand: 'Overture', speed: null, mat: 'PLA', type: null, fin: 'Marble', rem: 171, tot: 1000, tare: 155, tsrc: 'community', stype: 'Overture — cardboard, PLA lines', remSrc: 'typed', kg: 17.99, cont: null, loc: 'Shelf C', label: true, v: 4 },
|
||||
{ id: 44, c: ['#161616', '#c0232f'], color: 'Black Red', brand: 'TECSONAR', speed: null, mat: 'PLA', type: null, fin: 'Matte', rem: 188, tot: 1000, tare: null, tsrc: null, stype: null, remSrc: 'typed', kg: 15.99, cont: null, loc: 'Shelf C', label: false, v: 2 },
|
||||
{ id: 45, c: ['#101010'], color: 'Black', brand: 'JAYO', speed: 'Rapid', mat: 'PETG', type: null, fin: 'Matte', rem: 655, tot: 1000, tare: null, tsrc: null, stype: null, remSrc: 'typed', kg: 12.72, cont: 'p1-ams1', label: true, v: 5 },
|
||||
{ id: 46, c: ['#8a8f7a', '#e9e4d8'], color: 'Marble', brand: 'iSANMATE', speed: null, mat: 'ASA', type: null, fin: 'Marble', rem: 430, tot: 1000, tare: 180, tsrc: 'estimated', stype: 'Generic — 1 kg spool (assumed)', remSrc: 'weighed', kg: 25.99, cont: null, loc: 'Dry box 1', label: true, v: 6 },
|
||||
{ id: 47, c: ['#1b1b1b'], color: 'Black', brand: 'Sunlu', speed: null, mat: 'PETG', type: 'CF', fin: 'Matte', rem: 812, tot: 1000, tare: 262, tsrc: 'spool', stype: 'Sunlu — 1 kg plastic', remSrc: 'weighed', weighedAt: '18 Sep 09:39', kg: 22.79, cont: 'p3-lite', label: true, v: 5 },
|
||||
{ id: 48, c: ['#b8864b'], color: 'Natural Wood', brand: 'Sunlu', speed: null, mat: 'PLA', type: null, fin: 'Wood', rem: 1000, tot: 1000, tare: 35, tsrc: 'estimated', stype: 'Refill — bare cardboard core', remSrc: 'typed', kg: 16.50, cont: null, loc: 'Shelf A3', label: false, v: 1 },
|
||||
{ id: 49, c: ['#a3402c'], color: 'Brick Red', brand: 'Sunlu', speed: 'High Speed', mat: 'PLA', type: null, fin: 'Marble', rem: 1000, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', remSrc: 'typed', kg: 17.50, cont: null, loc: 'Shelf A2', label: true, v: 1 },
|
||||
{ id: 50, c: ['#e8453c', '#f2c230', '#2a8fd6'], color: 'Gradient Rainbow', brand: 'AMOLEN', speed: null, mat: 'PETG', type: null, fin: 'Gradient', rem: 1000, tot: 1000, tare: 180, tsrc: 'estimated', stype: 'Generic — 1 kg spool (assumed)', remSrc: 'typed', kg: 23.99, cont: null, loc: 'Shelf D', label: false, v: 1 },
|
||||
{ id: 51, c: ['#1d4fa8'], color: 'Blue', brand: 'Elegoo', speed: 'Rapid', mat: 'PETG', type: null, fin: 'Basic', rem: 1000, tot: 1000, tare: 154, tsrc: 'manufacturer', stype: 'Elegoo — 1 kg cardboard', remSrc: 'typed', kg: 17.99, cont: 'p2-ams1', label: true, v: 2 },
|
||||
{ id: 52, c: ['#141414'], color: 'Black', brand: 'Sunlu', speed: null, mat: 'PLA', type: '+ 2.0', fin: 'Basic', rem: 873, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', remSrc: 'typed', kg: 11.47, cont: 'p1-ams1', label: true, v: 11 },
|
||||
{ id: 53, c: ['#b9bcc0'], color: 'Mist Gray', brand: 'Overture', speed: null, mat: 'PLA', type: null, fin: 'Marble', rem: 920, tot: 1000, tare: 155, tsrc: 'community', stype: 'Overture — cardboard, PLA lines', remSrc: 'typed', kg: 16.99, cont: 'p1-ams1', label: true, v: 3 },
|
||||
{ id: 54, c: ['#f4f4f0'], color: 'White', brand: 'Elegoo', speed: null, mat: 'PLA', type: null, fin: 'Matte', rem: 569, tot: 1000, tare: 154, tsrc: 'manufacturer', stype: 'Elegoo — 1 kg cardboard', remSrc: 'typed', kg: 14.99, cont: 'p2-ams1', label: true, v: 6 },
|
||||
{ id: 55, c: ['#e8453c', '#f2c230', '#2a8fd6'], color: 'Red Yellow Blue', brand: 'AMOLEN', speed: null, mat: 'PLA', type: null, fin: 'Matte', rem: 532, tot: 1000, tare: 180, tsrc: 'estimated', stype: 'Generic — 1 kg spool (assumed)', remSrc: 'typed', kg: 19.99, cont: 'p3-lite', label: true, v: 4 },
|
||||
{ id: 56, c: ['#6e1a2a'], color: 'Transparent Wine Red', brand: 'Creality', speed: null, mat: 'PETG', type: null, fin: 'Translucent', rem: 1000, tot: 1000, tare: null, tsrc: null, stype: null, remSrc: 'typed', kg: 17.99, cont: null, loc: 'Shelf D', label: false, v: 1 }
|
||||
];
|
||||
|
||||
var PRINTERS = [
|
||||
{ id: 1, name: 'P1S - Prints Charming', model: 'P1S', ip: '10.60.1.242', serial: '01P09A••••••4821', state: 'RUNNING', connected: true, lifecycle: 'ready', stale: false, age: 2, session: 3, camera: 'snapshot', job: 'Gridfinity_bins_3x2', openPrint: 212,
|
||||
containers: [{ id: 'p1-ams1', kind: 'ams', label: 'AMS 1', cap: 4 }, { id: 'p1-ext', kind: 'external', label: 'External', cap: 1 }] },
|
||||
{ id: 2, name: 'H2C - Constipation Orion', model: 'H2C', ip: '10.60.1.202', serial: '31B••••••••0093', state: 'FINISH', connected: true, lifecycle: 'ready', stale: false, age: 4, session: 2, camera: 'snapshot', job: '', openPrint: null,
|
||||
containers: [{ id: 'p2-ams1', kind: 'ams', label: 'AMS 1', cap: 4 }, { id: 'p2-ext', kind: 'external', label: 'External', cap: 1 }] },
|
||||
{ id: 3, name: 'A1 - Jordyns Layer Slayer', model: 'A1', ip: '10.60.1.x (sample)', serial: '039••••••••7710', state: 'RUNNING', connected: false, lifecycle: 'reconnecting', stale: true, age: 366, session: 14, camera: 'snapshot', job: 'Coaster_set', openPrint: 213,
|
||||
containers: [{ id: 'p3-lite', kind: 'ams_lite', label: 'AMS Lite', cap: 4 }, { id: 'p3-ext', kind: 'external', label: 'External', cap: 1 }] }
|
||||
];
|
||||
|
||||
/* weight source: typed | sliced | recalled | unknown | null (not known yet)
|
||||
* gaps: null = before counting, 0 = asked and none */
|
||||
var PRINTS = [
|
||||
{ id: 213, job: 'Coaster_set', key: '/coasters/plate_1.gcode', printer: 3, status: 'running', start: '2026-09-18T08:55', end: null, dur: null, gaps: 0, grams: null, wsrc: null, cost: null, spool: null, use: 'personal', open: true },
|
||||
{ id: 212, job: 'Gridfinity_bins_3x2', key: '/gridfinity/bins_3x2.gcode', printer: 1, status: 'running', start: '2026-09-18T08:37', end: null, dur: null, gaps: 0, grams: null, wsrc: null, cost: null, spool: null, use: 'business' },
|
||||
{ id: 211, job: 'Benchy_x4', key: '/benchy-batch/plate_1.gcode', printer: 2, status: 'finished', start: '2026-09-18T02:15', end: '2026-09-18T05:27', dur: 11520, gaps: 1, grams: 46, wsrc: 'sliced', cost: 0.54, spool: 42, use: 'business' },
|
||||
{ id: 210, job: 'Sign_CubeCraft', key: '/signs/cubecraft.gcode', printer: 2, status: 'finished', start: '2026-09-17T21:48', end: '2026-09-17T23:53', dur: 7500, gaps: 0, grams: 64, wsrc: 'sliced', cost: 0.61, spool: null, use: 'business',
|
||||
filaments: [{ slot: 0, spool: 54, grams: 41, hex: '#f4f4f0', cost: 0.61 }, { slot: 1, spool: null, suggested: 51, grams: 23, hex: '#1d4fa8', cost: null }] },
|
||||
{ id: 209, job: 'Cable_clips', key: '/misc/cable_clips.gcode', printer: 3, status: 'unknown', start: '2026-09-17T19:30', end: '2026-09-17T21:02', dur: null, gaps: 1, grams: 12, wsrc: 'typed', cost: 0.27, spool: 47, use: 'personal' },
|
||||
{ id: 208, job: 'Lid_v3', key: '/enclosure/lid_v3.gcode', printer: 1, status: 'failed', start: '2026-09-17T16:02', end: '2026-09-17T16:49', dur: 2820, gaps: 0, grams: 118, wsrc: 'typed', cost: 1.35, spool: 52, use: 'business' },
|
||||
{ id: 207, job: 'Planter_small', key: '/garden/planter_small.gcode', printer: 3, status: 'finished', start: '2026-09-16T10:20', end: '2026-09-16T16:00', dur: 20400, gaps: 0, grams: 212, wsrc: 'recalled', cost: 4.24, spool: 55, use: 'personal' },
|
||||
{ id: 206, job: 'Bracket_M3', key: '/brackets/m3.gcode', printer: 1, status: 'finished', start: '2026-09-16T08:05', end: '2026-09-16T09:17', dur: 4320, gaps: 0, grams: null, wsrc: null, cost: null, spool: null, use: null },
|
||||
{ id: 205, job: 'Filament_clip', key: '/misc/filament_clip.gcode', printer: 1, status: 'finished', start: '2026-09-15T14:40', end: '2026-09-15T15:18', dur: 2280, gaps: 0, grams: 7, wsrc: 'sliced', cost: null, spool: null, suggested: 41, use: 'personal' },
|
||||
{ id: 201, job: 'Test_cube', key: '/calibration/test_cube.gcode', printer: 1, status: 'finished', start: '2026-08-07T14:11', end: '2026-08-07T14:42', dur: 1860, gaps: null, grams: 9, wsrc: 'unknown', cost: 0.10, spool: 52, use: null, unreconciled: true }
|
||||
];
|
||||
|
||||
var LOW = 200;
|
||||
|
||||
/* ── Iteration 3 additions ───────────────────────────────────────────── */
|
||||
/* Lookup lists (Lists screen). `used` = spools referencing the value in the
|
||||
* real 105-spool inventory where known from import/filament_review.tsv. Brand
|
||||
* tares are the last-resort guess the form labels as such. */
|
||||
var LOOKUPS = {
|
||||
brands: [['Sunlu', 35, null], ['Elegoo', 27, null], ['Overture', 7, null], ['TECSONAR', 6, null], ['Polymaker', 4, null], ['Inland', 4, null], ['ZIRO', 3, null], ['iSANMATE', 3, null], ['Sovol', 2, null], ['JAYO', 2, null], ['GratKit', 2, null], ['Creality', 2, null], ['AMOLEN', 2, null], ['TINMORRY', 1, null], ['Kingroon', 1, null], ['Geeetech', 1, null], ['Anycubic', 1, null], ['3DimsFila', 1, null], ['Prusament', 0, null], ['eSun', 0, null], ['Hatchbox', 0, null], ['Bambu Lab', 0, null]],
|
||||
materials: [['PLA', 55], ['PETG', 34], ['ASA', 10], ['TPU', 3], ['PA6', 2], ['ABS', 1]],
|
||||
types: [['Standard', 0], ['CF', 3], ['GF', 4], ['+', 6], ['+ 2.0', 14], ['HF', 0], ['95A', 3]],
|
||||
speeds: [['Standard', 0], ['Rapid', 18], ['High Speed', 9]],
|
||||
finishes: [['Basic', 44], ['Matte', 17], ['Silk', 6], ['Sparkle', 3], ['Translucent', 4], ['Metallic', 0], ['Wood', 5], ['Marble', 12], ['Rock', 1], ['Gradient', 9], ['Glow', 0]],
|
||||
spool_types: [['Generic — 1 kg spool (assumed)', 33, 180, 'estimated'], ['Sunlu — 1 kg plastic', 23, 140, 'community'], ['Elegoo — 1 kg cardboard', 20, 154, 'manufacturer'], ['Sunlu — 250 g mini', 8, 60, 'community'], ['Overture — cardboard, PLA lines', 6, 155, 'community'], ['Refill — bare cardboard core', 4, 35, 'estimated'], ['Inland — PLA Basics cardboard', 4, 143, 'community'], ['Polymaker — 0.5 kg cardboard', 3, 190, 'manufacturer'], ['Polymaker — 1 kg cardboard', 1, 140, 'manufacturer'], ['Overture — cardboard, PETG / ASA', 1, 132, 'community'], ['Bambu Lab — reusable', 0, 250, 'manufacturer'], ['Bambu Lab — cardboard', 0, 196, 'estimated'], ['Hatchbox — 1 kg', 0, 225, 'manufacturer'], ['Creality — Hyper cardboard', 0, 176, 'community']]
|
||||
};
|
||||
|
||||
/* Holder ids as the database numbers them (used on holder labels, /c/:id). */
|
||||
var HOLDER_IDS = { 'p1-ams1': 1, 'p1-ext': 2, 'p2-ams1': 3, 'p2-ext': 4, 'p3-lite': 5, 'p3-ext': 6 };
|
||||
|
||||
/* Access (humans come from Authentik; Extrudex shows its local projection). */
|
||||
var PEOPLE = [
|
||||
{ id: 1, name: 'Joshua King', email: 'owner@cubecraft (sample)', role: 'admin', lastSeen: '2026-09-18T09:40', sessions: [{ created: '2026-09-18T07:02', lastSeen: '2026-09-18T09:40', idleExp: '2026-09-18T17:40', maxExp: '2026-09-25T07:02', revoked: null, you: true }, { created: '2026-09-15T20:11', lastSeen: '2026-09-17T22:03', idleExp: '2026-09-18T06:03', maxExp: '2026-09-22T20:11', revoked: null }] },
|
||||
{ id: 2, name: 'Jordyn', email: 'jordyn@cubecraft (sample)', role: 'operator', lastSeen: '2026-09-17T19:28', sessions: [{ created: '2026-09-17T18:50', lastSeen: '2026-09-17T19:28', idleExp: '2026-09-18T03:28', maxExp: '2026-09-24T18:50', revoked: null }] },
|
||||
{ id: 3, name: 'Guest tablet', email: null, role: 'reader', lastSeen: '2026-09-09T12:00', sessions: [] }
|
||||
];
|
||||
var MACHINES = [
|
||||
{ id: 10, name: 'hermes-gateway', role: 'operator', tokens: [{ id: 21, name: 'gateway 2026-09', created: '2026-09-01T10:00', lastUsed: '2026-09-18T09:39', revoked: null }, { id: 17, name: 'gateway 2026-08', created: '2026-08-03T10:00', lastUsed: '2026-08-31T23:58', revoked: '2026-09-01T10:02' }] },
|
||||
{ id: 11, name: 'uptime-kuma', role: 'reader', tokens: [{ id: 22, name: 'health probe', created: '2026-09-05T14:30', lastUsed: '2026-09-18T09:41', revoked: null }] }
|
||||
];
|
||||
var AUDIT = [
|
||||
{ id: 9031, who: 'Joshua King', action: 'spool.weighed', type: 'spool', rid: 47, ctx: '860 g → 812 g · tare 262 g (spool)', at: '2026-09-18T09:39' },
|
||||
{ id: 9030, who: 'hermes-gateway', action: 'print.spool_set', type: 'print', rid: 211, ctx: 'spool #42 · 46 g deducted at $11.74/kg', at: '2026-09-18T05:28' },
|
||||
{ id: 9029, who: 'Jordyn', action: 'spool.moved', type: 'spool', rid: 55, ctx: 'Shelf D → A1 · AMS Lite', at: '2026-09-17T19:26' },
|
||||
{ id: 9028, who: 'Jordyn', action: 'print.use_type_set', type: 'print', rid: 209, ctx: 'personal', at: '2026-09-17T19:25' },
|
||||
{ id: 9027, who: 'Joshua King', action: 'lookup.updated', type: 'spool_type', rid: 4, ctx: 'Sunlu — 250 g mini · tare 60 g · community', at: '2026-09-17T08:12' },
|
||||
{ id: 9026, who: 'Joshua King', action: 'printer.updated', type: 'printer', rid: 3, ctx: 'ip changed · access code unchanged', at: '2026-09-16T21:40' },
|
||||
{ id: 9025, who: 'Joshua King', action: 'session.revoked', type: 'principal', rid: 3, ctx: '1 local session', at: '2026-09-09T12:05' }
|
||||
];
|
||||
var WEBHOOK = { enabled: true, schedule: 'Mondays 08:00 America/New_York', threshold: 200, target: 'configured (URL kept in a root-only file, never shown)',
|
||||
events: [{ period: '2026-W38', observed: '2026-09-14T08:00', state: 'delivered', attempts: 1, spools: 4, delivered: '2026-09-14T08:00' }, { period: '2026-W37', observed: '2026-09-07T08:00', state: 'delivered', attempts: 3, spools: 3, delivered: '2026-09-07T08:21', lastError: 'HTTP 502 from receiver (retried)' }, { period: '2026-W36', observed: '2026-08-31T08:00', state: 'no_low', attempts: 0, spools: 0 }] };
|
||||
@@ -0,0 +1,76 @@
|
||||
<style>
|
||||
.dash { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(300px, 1fr); gap: var(--space-6); align-items: start; }
|
||||
.stats .stat { gap: 4px; }
|
||||
.stats a.stat { border: 1px solid var(--border-hairline); }
|
||||
.day { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); font-weight: var(--fw-bold); padding: 14px 16px 6px; }
|
||||
.feed { display: flex; flex-direction: column; }
|
||||
.feed-row { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: 110px minmax(0, 1fr) auto auto; gap: 4px var(--space-4); align-items: center; padding: 10px 16px; border-top: 1px solid var(--border-hairline); font-size: 13px; }
|
||||
.feed-row:hover { background: var(--accent-wash); }
|
||||
.feed-row:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
|
||||
.feed-row .job { font-weight: var(--fw-bold); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.feed-row .r { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.side-row { 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: 10px 16px; border-top: 1px solid var(--border-hairline); font-size: 13px; width: 100%; }
|
||||
.side-row:hover { background: var(--accent-wash); }
|
||||
.side-row:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
|
||||
.card-h { display: flex; align-items: center; gap: var(--space-3); padding: 14px 16px 10px; }
|
||||
.card-h h2 { margin: 0; font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); }
|
||||
.card-h a { margin-left: auto; font-size: var(--fs-label); }
|
||||
.swrow { display: inline-flex; margin-left: 6px; vertical-align: middle; }
|
||||
.swrow .swatch { margin-left: -6px; width: 18px; height: 18px; }
|
||||
@media (max-width: 980px) { .dash { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media (max-width: 620px) { .feed-row { grid-template-columns: minmax(0, 1fr) auto; } .feed-row .when { grid-column: 1 / -1; order: -1; } .feed-row .hide-s { display: none; } .attn { grid-template-columns: 36px minmax(0, 1fr); } .attn .ico { width: 36px; height: 36px; } .attn .btn { grid-column: 1 / -1; justify-content: center; } }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
function attention() {
|
||||
var lowS = SPOOLS.filter(x.low), tareS = SPOOLS.filter(x.unverified), tareU = tareS.filter(function (s) { return s.tare == null; }).length, need = x.printsNeeding(), down = PRINTERS.filter(function (p) { return !p.connected; });
|
||||
var withSug = need.filter(function (p) { return p.suggested || (p.filaments || []).some(function (f) { return f.suggested; }); }).length;
|
||||
var items = [];
|
||||
down.forEach(function (p) { items.push('<div class="attn err"><span class="ico" aria-hidden="true">⚡</span><div><h3>' + x.esc(p.name) + ' lost its connection</h3><p>Last contact ' + x.ago(p.age) + '. Print capture is paused; <b>' + x.esc(p.job) + '</b> is still open and will be reconciled on reconnect.</p></div><a class="btn btn-sm" href="04-printers.html#printer=' + p.id + '">Open printer</a></div>'); });
|
||||
if (need.length) items.push('<div class="attn warn"><span class="ico" aria-hidden="true">!</span><div><h3>' + need.length + ' prints need a spool or weight</h3><p>Their filament hasn\'t been deducted and their cost is incomplete.' + (withSug ? ' ' + withSug + ' have a same-color suggestion ready.' : '') + '</p></div><a class="btn btn-sm btn-primary" href="03-prints.html?tab=needs">Review ' + need.length + '</a></div>');
|
||||
if (lowS.length) items.push('<div class="attn warn"><span class="ico" aria-hidden="true">▁</span><div><h3>' + lowS.length + ' spools under 200 g <span class="swrow">' + lowS.map(function (s) { return x.sw(s.c); }).join('') + '</span></h3><p>' + lowS.filter(function (s) { return s.cont; }).length + ' of them are loaded in a printer right now.</p></div><a class="btn btn-sm" href="02-filament.html?f=low">See low stock</a></div>');
|
||||
if (tareS.length) items.push('<div class="attn info"><span class="ico" aria-hidden="true">?</span><div><h3>' + tareS.length + ' spools have an unverified empty spool weight</h3><p>' + tareU + ' unknown, so they can\'t be weighed yet. ' + (tareS.length - tareU) + ' use an estimated figure, so their weigh-ins could be off. Weighing an empty spool settles it.</p></div><a class="btn btn-sm" href="02-filament.html?f=unverified">Review empty spool weights</a></div>');
|
||||
return items.length ? items.join('') : '<div class="attn-done nm-inset">✓ Nothing needs you right now.</div>';
|
||||
}
|
||||
function feed() {
|
||||
var groups = [];
|
||||
PRINTS.slice(0, 7).forEach(function (p) { var w = x.when(p.start), d = /^(Today|Yesterday)/.test(w) ? w.split(' ')[0] : w.split(' ').slice(0, 2).join(' '); var last = groups[groups.length - 1]; if (!last || last.d !== d) groups.push(last = { d: d, list: [] }); last.list.push(p); });
|
||||
return groups.map(function (grp) {
|
||||
return '<div class="day">' + grp.d + '</div>' + grp.list.map(function (p) {
|
||||
var n = x.needs(p), pr = x.printer(p.printer);
|
||||
return '<button class="feed-row" type="button" data-print="' + p.id + '"><span class="when">' + x.printSt(p) + '<span class="sub">' + x.when(p.start).split(' ').pop() + '</span></span>' +
|
||||
'<span style="min-width:0"><span class="job" style="display:block">' + x.esc(p.job) + '</span><span class="sub">' + x.esc(pr.model) + (p.open && !pr.connected ? ' · <span class="prov warn"><i>⚡</i>open across outage</span>' : '') + (n.length ? ' · <span class="prov warn"><i>!</i>needs ' + n.map(function (i) { return i.t; }).join(', ') + '</span>' : '') + '</span></span>' +
|
||||
'<span class="r hide-s">' + x.durCell(p) + '</span><span class="r">' + (p.grams == null ? '<span class="muted">—</span>' : '<b>' + x.g(p.grams) + '</b><span class="sub">' + x.weightProv(p) + '</span>') + '</span></button>';
|
||||
}).join('');
|
||||
}).join('');
|
||||
}
|
||||
function printers() {
|
||||
return PRINTERS.map(function (p) {
|
||||
return '<a class="side-row" href="04-printers.html#printer=' + p.id + '"><span>' + x.printerSt(p) + '</span><span style="min-width:0"><b style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + x.esc(p.name) + '</b><span class="sub">' + (p.connected ? (p.job ? x.esc(p.job) : 'last: Benchy_x4') : 'last known: printing ' + x.esc(p.job)) + '</span></span><span class="sub">' + (p.connected ? x.ago(p.age) : '<span class="prov warn">' + x.ago(p.age) + '</span>') + '</span></a>';
|
||||
}).join('');
|
||||
}
|
||||
function lows() {
|
||||
return SPOOLS.filter(x.low).sort(function (a, b) { return a.rem - b.rem; }).map(function (s) {
|
||||
return '<button class="side-row" type="button" data-spool="' + s.id + '">' + x.sw(s.c) + '<span style="min-width:0"><b>' + x.esc(s.color) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">' + x.esc(x.fullName(s)) + ' · ' + x.esc(x.where(s).short) + '</span></span><span class="num strong" style="color:var(--status-warning)">' + x.g(s.rem) + '</span></button>';
|
||||
}).join('');
|
||||
}
|
||||
function render() {
|
||||
var today = PRINTS.filter(function (p) { return /^Today/.test(x.when(p.start)) && p.grams != null; });
|
||||
var usedG = today.reduce(function (a, p) { return a + p.grams; }, 0), usedC = today.reduce(function (a, p) { return a + (p.cost || 0); }, 0);
|
||||
return '<header class="page-head"><div><h1>Dashboard</h1><p class="sum">Friday 18 September · <b>' + PRINTERS.length + '</b> printers · <b>' + CANARY.spools + '</b> spools</p></div><button class="btn btn-primary primary-desktop" type="button" data-add-spool>+ Add spool</button></header>' +
|
||||
'<section aria-labelledby="needs-h" class="stack" style="gap:12px"><h2 class="section" id="needs-h" style="margin:0">Needs you</h2><div class="attn-list">' + attention() + '</div></section>' +
|
||||
'<section class="stats" aria-label="Stock">' +
|
||||
'<a class="stat nm-raised tile-link" href="02-filament.html"><span class="stat-label">Spools</span><span class="stat-value">' + CANARY.spools + '</span><span class="stat-foot">' + CANARY.brands + ' brands · 6 materials →</span></a>' +
|
||||
'<div class="stat nm-raised"><span class="stat-label">Filament left</span><span class="stat-value">' + CANARY.kg.toFixed(2) + ' kg</span><span class="stat-foot">net: filament only, no spools</span></div>' +
|
||||
'<div class="stat nm-raised"><span class="stat-label">Stock value</span><span class="stat-value">' + x.usd(CANARY.value) + '</span><span class="stat-foot">what\'s left × each $/kg</span></div>' +
|
||||
'<a class="stat nm-raised tile-link" href="03-prints.html"><span class="stat-label">Used today</span><span class="stat-value">' + x.g(usedG) + '</span><span class="stat-foot">' + x.usd(usedC) + ' · ' + today.length + ' done · 2 running →</span></a>' +
|
||||
'</section>' +
|
||||
'<div class="dash"><section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="act-h"><div class="card-h"><h2 id="act-h">Recent prints</h2><a href="03-prints.html">All prints →</a></div><div class="feed">' + feed() + '</div></section>' +
|
||||
'<div class="stack" style="gap:24px"><section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="pr-h"><div class="card-h"><h2 id="pr-h">Printers</h2><a href="04-printers.html">Open →</a></div>' + printers() + '</section>' +
|
||||
'<section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="low-h"><div class="card-h"><h2 id="low-h">Running low</h2><a href="02-filament.html?f=low">All →</a></div>' + lows() + '</section></div></div>' +
|
||||
'<button class="btn btn-primary fab" type="button" data-add-spool aria-label="Add spool">+</button>';
|
||||
}
|
||||
X.init({ name: 'dashboard', refresh: function () { document.getElementById('page').innerHTML = render(); } }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,115 @@
|
||||
<style>
|
||||
.toolbar { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.toolbar .row1 { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
|
||||
.searchbox { position: relative; flex: 1; min-width: 220px; }
|
||||
.searchbox svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-secondary); }
|
||||
.searchbox .input { padding-left: 36px; }
|
||||
.ctl { display: inline-flex; gap: 8px; align-items: center; font-size: var(--fs-label); color: var(--text-secondary); }
|
||||
.ctl .select { min-height: 36px; padding: 6px 10px; width: auto; font-size: 13px; }
|
||||
.grp td { background: var(--surface-inset); font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); font-weight: var(--fw-bold); color: var(--text-secondary); padding: 8px 12px; }
|
||||
.spcell { display: flex; gap: var(--space-3); align-items: center; min-width: 220px; }
|
||||
.remcell { min-width: 160px; }
|
||||
.remcell .top { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 5px; }
|
||||
.rowact { display: flex; gap: 6px; justify-content: flex-end; }
|
||||
.rowact .btn { min-height: 30px; padding: 4px 10px; }
|
||||
.weigh-hint { display: flex; gap: var(--space-3); align-items: center; padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); background: var(--accent-wash); border: 1px solid var(--text-accent); font-size: 13px; }
|
||||
@media (max-width: 1180px) { .wl { display: none; } .rowact .btn { white-space: nowrap; } }
|
||||
@media (max-width: 760px) { .hide-m { display: none; } .spcell { min-width: 0; } .remcell { min-width: 110px; } .rowact .menu-wrap .btn { padding: 4px 8px; } }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var q = new URLSearchParams(location.search);
|
||||
var S = { view: ({ tare: 'unverified' })[q.get('f')] || q.get('f') || 'all', q: q.get('q') || '', mat: '', sort: 'rem', dir: 1, cols: 'stock', group: 'none' };
|
||||
var VIEWS = [
|
||||
['all', 'All', function () { return true; }],
|
||||
['low', 'Low', x.low],
|
||||
['loaded', 'Loaded', function (s) { return !!s.cont; }],
|
||||
['storage', 'In storage', function (s) { return !s.cont; }],
|
||||
['unverified', 'Empty spool weight unverified', x.unverified]
|
||||
];
|
||||
function viewFn(k) { for (var i = 0; i < VIEWS.length; i++) if (VIEWS[i][0] === k) return VIEWS[i]; return VIEWS[0]; }
|
||||
function list() {
|
||||
var f = viewFn(S.view)[2], qq = S.q.toLowerCase();
|
||||
var l = SPOOLS.filter(function (s) { return f(s) && (!S.mat || s.mat === S.mat) && (!qq || ('#' + s.id + ' ' + x.fullName(s) + ' ' + s.color + ' ' + x.where(s).text).toLowerCase().indexOf(qq) >= 0); });
|
||||
var key = { rem: function (s) { return s.rem; }, name: function (s) { return (s.color + s.brand).toLowerCase(); }, value: function (s) { return x.value(s); }, gross: function (s) { return x.gross(s) == null ? -1 : x.gross(s); } }[S.sort];
|
||||
l.sort(function (a, b) { var A = key(a), B = key(b); return (A < B ? -1 : A > B ? 1 : 0) * S.dir; });
|
||||
if (S.group !== 'none') { var gk = S.group === 'mat' ? function (s) { return s.mat; } : function (s) { return x.where(s).loaded ? x.where(s).printer.name : 'Storage'; }; l.sort(function (a, b) { return gk(a) < gk(b) ? -1 : gk(a) > gk(b) ? 1 : 0; }); l.gk = gk; }
|
||||
return l;
|
||||
}
|
||||
function th(label, key, cls) { var s = S.sort === key ? (S.dir === 1 ? 'ascending' : 'descending') : 'none'; return '<th class="' + (cls || '') + '" aria-sort="' + s + '"><button class="sort" type="button" data-sort="' + key + '">' + label + '</button></th>'; }
|
||||
function rowMenu(s) {
|
||||
return '<div class="menu-wrap"><button class="btn btn-sm btn-icon" type="button" data-menu="rm' + s.id + '" aria-haspopup="menu" aria-expanded="false" aria-label="Actions for ' + x.esc(s.color + ' ' + s.brand) + '">⋯</button><div class="menu" id="rm' + s.id + '" role="menu">' +
|
||||
'<button role="menuitem" type="button" data-spool="' + s.id + '">Open<span class="hint">↵</span></button><button role="menuitem" type="button" data-spool="' + s.id + '" data-tab-open="weigh">Weigh</button><button role="menuitem" type="button" data-spool="' + s.id + '" data-tab-open="move">' + (s.cont ? 'Move / unload' : 'Load into printer') + '</button><button role="menuitem" type="button" data-spool="' + s.id + '" data-tab-open="edit">Edit</button>' +
|
||||
(s.label ? '' : '<button role="menuitem" type="button" data-fake="Label PDF generated for #' + s.id + '">Print label</button>') + '<hr><button role="menuitem" type="button" class="danger" data-spool="' + s.id + '" data-then-delete>Delete…</button></div></div>';
|
||||
}
|
||||
function rows(l) {
|
||||
var out = '', last = null, cols = S.cols === 'stock' ? 5 : 5;
|
||||
l.forEach(function (s) {
|
||||
if (l.gk) { var gv = l.gk(s); if (gv !== last) { var n = l.filter(function (y) { return l.gk(y) === gv; }), tot = n.reduce(function (a, y) { return a + y.rem; }, 0); out += '<tr class="grp"><td colspan="' + cols + '">' + x.esc(gv) + ' · ' + n.length + ' spools · ' + x.kg(tot) + '</td></tr>'; last = gv; } }
|
||||
var w = x.where(s), gr = x.gross(s);
|
||||
out += '<tr class="clickable" tabindex="0" data-row-spool="' + s.id + '" aria-label="' + x.esc(s.color + ', ' + x.fullName(s) + ', ' + s.rem + ' grams') + '">' +
|
||||
'<td><div class="spcell">' + x.sw(s.c) + '<span style="min-width:0"><span class="strong">' + x.esc(s.color) + '</span> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">' + x.esc(x.fullName(s)) + '</span></span></div></td>' +
|
||||
'<td><div class="remcell"><div class="top"><b class="num"' + (x.low(s) ? ' style="color:var(--status-warning)"' : '') + '>' + x.g(s.rem) + '</b><span class="sub" style="display:inline">of ' + x.g(s.tot) + '</span></div>' + x.meter(s) + '</div></td>' +
|
||||
(S.cols === 'stock'
|
||||
? '<td class="hide-m">' + (w.loaded ? '<span class="st run" style="font-weight:500">' + x.esc(w.short) + '</span>' : '<span class="muted">' + x.esc(w.text) + '</span>') + '</td><td class="r hide-m hide-t"><span class="num">' + x.usd(x.value(s)) + '</span><span class="sub">' + x.usd(s.kg) + '/kg</span></td>'
|
||||
: '<td class="r hide-m">' + (s.tare == null ? '<span class="prov warn"><i>?</i>unknown</span>' : '<span class="num">' + x.g(s.tare) + '</span><span class="sub">' + x.tareProv(s) + '</span>') + '</td><td class="r hide-m">' + (gr == null ? '<span class="muted">unknown</span>' : '<b class="num">' + x.g(gr) + '</b><span class="sub">' + x.remProv(s) + '</span>') + '</td>') +
|
||||
'<td><div class="rowact">' + (s.tare != null ? '<button class="btn btn-sm hide-m" type="button" data-spool="' + s.id + '" data-tab-open="weigh" aria-label="Weigh ' + x.esc(s.color) + ' #' + s.id + '">⚖<span class="wl"> Weigh</span></button>' : '') + rowMenu(s) + '</div></td></tr>';
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function render() {
|
||||
var l = list(), all = SPOOLS.length;
|
||||
x.D.list = l.map(function (s) { return s.id; });
|
||||
var chips = [];
|
||||
if (S.view !== 'all') chips.push(['view', viewFn(S.view)[1]]);
|
||||
if (S.mat) chips.push(['mat', S.mat]);
|
||||
if (S.q) chips.push(['q', '“' + S.q + '”']);
|
||||
var weighable = l.filter(function (s) { return s.tare != null; });
|
||||
return '<header class="page-head"><div><h1>Filament</h1><p class="sum"><b>' + CANARY.spools + '</b> spools · <b>' + CANARY.kg.toFixed(2) + ' kg</b> net · <b>' + x.usd(CANARY.value) + '</b> · sample page of ' + all + '</p></div>' +
|
||||
'<div class="actions"><div class="menu-wrap"><button class="btn" type="button" data-menu="pgmenu" aria-haspopup="menu" aria-expanded="false">More ▾</button><div class="menu" id="pgmenu" role="menu"><button role="menuitem" type="button" data-fake="Labels PDF for 4 spools generated">Print ' + SPOOLS.filter(function (s) { return !s.label; }).length + ' missing labels</button><button role="menuitem" type="button" data-soon="Lists">Manage lists (brands, materials…)</button></div></div>' +
|
||||
'<button class="btn btn-primary primary-desktop" type="button" data-add-spool>+ Add spool</button></div></header>' +
|
||||
'<section class="toolbar nm-raised" aria-label="Find spools">' +
|
||||
'<div class="tabs" role="tablist" aria-label="Saved views">' + VIEWS.map(function (v) { var n = SPOOLS.filter(v[2]).length; return '<button role="tab" type="button" data-view="' + v[0] + '" aria-selected="' + (S.view === v[0]) + '">' + v[1] + ' <span class="n' + ((v[0] === 'low' || v[0] === 'unverified') && n ? ' warn' : '') + '">' + n + '</span></button>'; }).join('') + '</div>' +
|
||||
'<div class="row1"><label class="searchbox">' + X.ICON.search + '<input class="input" id="fq" type="search" value="' + x.esc(S.q) + '" placeholder="Filter by color, brand, #id, location…" aria-label="Filter spools"></label>' +
|
||||
'<label class="ctl">Material <select class="select" id="fmat"><option value="">Any</option>' + ['PLA', 'PETG', 'ASA', 'TPU', 'PA6', 'ABS'].map(function (m) { return '<option' + (S.mat === m ? ' selected' : '') + '>' + m + '</option>'; }).join('') + '</select></label>' +
|
||||
'<label class="ctl">Group <select class="select" id="fgroup"><option value="none">None</option><option value="mat"' + (S.group === 'mat' ? ' selected' : '') + '>Material</option><option value="loc"' + (S.group === 'loc' ? ' selected' : '') + '>Location</option></select></label>' +
|
||||
'<div class="tabs" role="group" aria-label="Columns"><button type="button" data-cols="stock" aria-selected="' + (S.cols === 'stock') + '">Stock</button><button type="button" data-cols="weights" aria-selected="' + (S.cols === 'weights') + '">Weights</button></div></div>' +
|
||||
(chips.length ? '<div class="fchips" aria-label="Active filters"><span class="sub" style="display:inline">' + l.length + ' of ' + all + ' shown</span>' + chips.map(function (c) { return '<span class="fchip">' + x.esc(c[1]) + '<button type="button" data-unchip="' + c[0] + '" aria-label="Remove filter ' + x.esc(c[1]) + '">✕</button></span>'; }).join('') + '<button class="linkish small" type="button" data-unchip="all">Clear all</button></div>' : '') +
|
||||
'</section>' +
|
||||
(weighable.length > 1 && S.view !== 'all' ? '<div class="weigh-hint"><b>⚖</b><span style="flex:1">Weighing these? Weigh all ' + weighable.length + ', lightest first. Each step shows where the spool lives and the result before you save.</span><button class="btn btn-sm btn-primary" type="button" data-weigh-session>Start weighing</button></div>' : '') +
|
||||
'<section class="nm-raised table-card" aria-label="Spools">' + (l.length ? '<div class="table-scroll"><table class="tbl"><thead><tr>' + th('Spool', 'name') + th('Remaining <abbr title="net filament">net</abbr>', 'rem') +
|
||||
(S.cols === 'stock' ? '<th class="hide-m">Location</th>' + th('Value', 'value', 'r hide-m hide-t') : '<th class="r hide-m">Empty spool</th>' + th('Gross (scale)', 'gross', 'r hide-m')) + '<th class="r"><span class="sr-only">Actions</span></th></tr></thead><tbody>' + rows(l) + '</tbody></table></div>' +
|
||||
'<div class="table-foot"><span class="muted">Click a row or press ↵ to open · <span class="kbd">j</span>/<span class="kbd">k</span> step through in the panel</span><span class="muted">1–' + l.length + ' of ' + (S.view === 'all' && !S.q && !S.mat ? CANARY.spools : l.length) + '</span></div>'
|
||||
: '<div class="empty"><h3>No spools match</h3><p>' + (S.q ? 'Nothing matches “' + x.esc(S.q) + '”' : 'Nothing in this view') + (S.mat ? ' in ' + S.mat : '') + '.</p><button class="btn btn-sm" type="button" data-unchip="all">Clear filters</button></div>') + '</section>' +
|
||||
'<button class="btn btn-primary fab" type="button" data-add-spool aria-label="Add spool">+</button>';
|
||||
}
|
||||
function redraw(keepFocus) {
|
||||
var a = document.activeElement, id = a && a.id, pos = a && a.selectionStart;
|
||||
document.getElementById('page').innerHTML = render();
|
||||
if (keepFocus && id && document.getElementById(id)) { var el = document.getElementById(id); el.focus(); if (pos != null && el.setSelectionRange) el.setSelectionRange(pos, pos); }
|
||||
var p = new URLSearchParams(); if (S.view !== 'all') p.set('f', S.view); if (S.q) p.set('q', S.q);
|
||||
history.replaceState(history.state, '', location.pathname + (p.toString() ? '?' + p : '') + location.hash);
|
||||
if (x.D.kind === 'spool') document.querySelectorAll('[data-row-spool="' + x.D.id + '"]').forEach(function (r) { r.classList.add('is-selected'); });
|
||||
}
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if ((a = t.closest('[data-view]'))) { S.view = a.getAttribute('data-view'); return redraw(); }
|
||||
if ((a = t.closest('[data-cols]'))) { S.cols = a.getAttribute('data-cols'); return redraw(); }
|
||||
if ((a = t.closest('[data-sort]'))) { var k = a.getAttribute('data-sort'); S.dir = S.sort === k ? -S.dir : 1; S.sort = k; return redraw(); }
|
||||
if ((a = t.closest('[data-unchip]'))) { var c = a.getAttribute('data-unchip'); if (c === 'view' || c === 'all') S.view = 'all'; if (c === 'mat' || c === 'all') S.mat = ''; if (c === 'q' || c === 'all') S.q = ''; return redraw(); }
|
||||
if (t.closest('[data-weigh-session]')) { var first = list().filter(function (s) { return s.tare != null; }).sort(function (a, b) { return a.rem - b.rem; }); x.D.list = first.map(function (s) { return s.id; }); x.D.session = true; x.openDrawer('spool', first[0].id, 'weigh'); x.D.session = true; x.D.list = first.map(function (s) { return s.id; }); return; }
|
||||
if ((a = t.closest('[data-then-delete]'))) { setTimeout(function () { var b = document.querySelector('#drawer [data-delete]'); if (b) b.click(); }, 50); return; }
|
||||
if (t.closest('button, a, .menu')) return;
|
||||
if ((a = t.closest('[data-row-spool]'))) { x.D.list = list().map(function (s) { return s.id; }); x.openDrawer('spool', +a.getAttribute('data-row-spool')); }
|
||||
});
|
||||
document.addEventListener('input', function (e) {
|
||||
if (e.target.id === 'fq') { S.q = e.target.value; redraw(true); }
|
||||
});
|
||||
document.addEventListener('change', function (e) {
|
||||
if (e.target.id === 'fmat') { S.mat = e.target.value; redraw(); }
|
||||
if (e.target.id === 'fgroup') { S.group = e.target.value; redraw(); }
|
||||
});
|
||||
X.init({ name: 'filament', refresh: function () { redraw(); } }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
<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>
|
||||
@@ -0,0 +1,84 @@
|
||||
<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>
|
||||
@@ -0,0 +1,76 @@
|
||||
<style>
|
||||
.namebar { padding: var(--space-4) var(--space-5); display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
|
||||
.part { padding: 6px 12px; border-radius: var(--radius-sm); border: 1px dashed var(--border-control); font-weight: 700; color: var(--text-secondary); }
|
||||
.part.on { border: 2px solid var(--text-accent); background: var(--accent-wash); color: var(--text-accent); }
|
||||
.part small { display: block; font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.ltool { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: center; padding: var(--space-4); }
|
||||
.ltool .input { width: auto; flex: 1; min-width: 180px; min-height: 38px; }
|
||||
.rename { display: flex; gap: 6px; align-items: center; }
|
||||
.rename .input { min-height: 34px; padding: 5px 10px; max-width: 280px; }
|
||||
.used a { font-variant-numeric: tabular-nums; }
|
||||
.addrow td { background: var(--surface-inset); }
|
||||
@media (max-width: 699px) {
|
||||
.tbl.stackable thead { display: none; }
|
||||
.tbl.stackable tr { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; padding: 12px 14px; border-bottom: 1px solid var(--border-hairline); }
|
||||
.tbl.stackable td { border: 0; padding: 0; }
|
||||
.tbl.stackable td.acts { grid-column: 1 / -1; }
|
||||
.tbl.stackable tr.addrow { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var KINDS = [['brands', 'Brands', 0], ['speeds', 'Print speeds', 1], ['materials', 'Materials', 2], ['types', 'Filament types', 3], ['finishes', 'Finishes', 4], ['spool_types', 'Spool types', -1]];
|
||||
var q = new URLSearchParams(location.search);
|
||||
var S = { kind: q.get('list') || 'brands', f: '', unused: false, editing: null, conflict: false, addErr: false };
|
||||
var SRC = { manufacturer: '<span class="prov"><i>◆</i>manufacturer</span>', community: '<span class="prov"><i>◇</i>community</span>', estimated: '<span class="prov warn"><i>≈</i>estimated</span>' };
|
||||
function kindRow(k) { for (var i = 0; i < KINDS.length; i++) if (KINDS[i][0] === k) return KINDS[i]; }
|
||||
function namebar() {
|
||||
var k = kindRow(S.kind), parts = [['Sunlu', 'brand', 1], ['Rapid', 'speed', 0], ['PETG', 'material', 1], ['CF', 'type', 0], ['Silk', 'finish', 1]];
|
||||
if (k[2] < 0) return '<section class="namebar nm-inset" aria-label="What this list does"><span class="small"><b>Spool types</b> are physical spool designs and what they weigh empty. A spool\'s empty spool weight comes from its type, never its brand, because one brand ships spools that differ by 100 g or more.</span></section>';
|
||||
return '<section class="namebar nm-inset" aria-label="Where this list appears in a spool name"><span class="small muted" style="margin-right:6px">A spool name is built from</span>' + parts.map(function (p, i) { return '<span class="part' + (i === k[2] ? ' on' : '') + '">' + p[0] + '<small>' + p[1] + (p[2] ? '' : ' · opt') + '</small></span>'; }).join('') + '</section>';
|
||||
}
|
||||
function rows() {
|
||||
var list = LOOKUPS[S.kind].filter(function (r) { return (!S.f || r[0].toLowerCase().indexOf(S.f.toLowerCase()) >= 0) && (!S.unused || !r[1]); });
|
||||
var st = S.kind === 'spool_types', br = S.kind === 'brands';
|
||||
var head = '<thead><tr><th>Name</th><th class="r">Used by</th>' + (st ? '<th class="r">Empty weight</th><th>Source</th>' : br ? '<th class="r hide-m">Brand empty spool weight <span class="sub" style="display:inline;text-transform:none">last resort</span></th>' : '') + '<th class="r"><span class="sr-only">Actions</span></th></tr></thead>';
|
||||
var body = list.map(function (r, i) {
|
||||
var name = r[0], used = r[1], editing = S.editing === name;
|
||||
var nameCell = editing ? '<div class="rename"><input class="input" id="rn" value="' + x.esc(name) + '" aria-label="New name for ' + x.esc(name) + '"><button class="btn btn-sm btn-primary" type="button" data-rn-save>Save</button><button class="btn btn-sm" type="button" data-rn-cancel>Cancel</button></div>' +
|
||||
(S.conflict ? '<p class="field-error" style="margin:6px 0 0">Someone renamed this to “' + x.esc(name) + ' 3D” after you loaded it. Nothing was saved. <button class="linkish" type="button" data-rn-reload>Reload and reapply</button></p>' : '') : '<b>' + x.esc(name) + '</b>';
|
||||
return '<tr><td>' + nameCell + '</td><td class="r used">' + (used ? '<a href="02-filament.html?q=' + encodeURIComponent(name) + '">' + used + ' spool' + (used > 1 ? 's' : '') + '</a>' : '<span class="muted">unused</span>') + '</td>' +
|
||||
(st ? '<td class="r num">' + x.g(r[2]) + '</td><td>' + SRC[r[3]] + '</td>' : br ? '<td class="r hide-m">' + (r[2] ? x.g(r[2]) : '<span class="muted">none</span>') + '</td>' : '') +
|
||||
'<td class="r acts"><div class="row" style="gap:6px;justify-content:flex-end">' + (editing ? '' : '<button class="btn btn-sm" type="button" data-rn="' + x.esc(name) + '">Rename</button>') +
|
||||
(used ? '<button class="btn btn-sm" type="button" disabled title="In use by ' + used + ' spools. Change those spools first; the database refuses to remove a value that is still used.">Remove</button>' : '<button class="btn btn-sm btn-danger" type="button" data-fake="Removed “' + x.esc(name) + '” from ' + x.esc(kindRow(S.kind)[1]) + '">Remove</button>') + '</div></td></tr>';
|
||||
}).join('');
|
||||
var add = '<tr class="addrow"><td colspan="' + (st ? 5 : br ? 4 : 3) + '"><div class="row" style="gap:8px"><input class="input" id="addv" style="max-width:280px;min-height:36px" placeholder="Add a ' + (st ? 'spool type, e.g. eSun — 1 kg cardboard' : 'value') + '" aria-label="New value">' +
|
||||
(st ? '<input class="input num" style="max-width:110px;min-height:36px" placeholder="Empty g" aria-label="Empty spool weight in grams"><select class="select" style="width:auto;min-height:36px" aria-label="Where the empty spool weight came from"><option>Source…</option><option>manufacturer</option><option>community</option><option>estimated</option></select>' : '') +
|
||||
'<button class="btn btn-sm btn-primary" type="button" data-add>Add</button></div>' +
|
||||
(S.addErr ? '<p class="field-error" style="margin:8px 0 0">' + (st ? 'An empty spool weight and its source go together, or neither. 0 g is refused: leave it blank if nobody has weighed one.' : '“petg” looks like <b>PETG</b>, which already exists. Use PETG so spools group together. <span class="sub" style="display:inline">Proposed check: the database compares names case-sensitively today, so it would accept this.</span>') + '</p>' : '') + '</td></tr>';
|
||||
return '<table class="tbl stackable">' + head + '<tbody>' + (body || '<tr><td colspan="5"><div class="empty"><h3>Nothing matches</h3><p>Try another filter.</p></div></td></tr>') + add + '</tbody></table>';
|
||||
}
|
||||
function render() {
|
||||
var k = kindRow(S.kind);
|
||||
return '<header class="page-head"><div><h1>Lists</h1><p class="sum">The values a spool\'s name is built from. <b>Operator</b> or admin can change them; values still in use can\'t be removed.</p></div></header>' +
|
||||
'<div class="tabs" role="tablist" aria-label="Lists">' + KINDS.map(function (kk) { return '<button role="tab" type="button" data-kind="' + kk[0] + '" aria-selected="' + (S.kind === kk[0]) + '">' + kk[1] + ' <span class="n">' + LOOKUPS[kk[0]].length + '</span></button>'; }).join('') + '</div>' +
|
||||
namebar() +
|
||||
'<section class="nm-raised table-card"><div class="ltool"><input class="input" id="lf" type="search" value="' + x.esc(S.f) + '" placeholder="Filter ' + k[1].toLowerCase() + '…" aria-label="Filter"><button class="btn btn-sm" type="button" aria-pressed="' + S.unused + '" data-unused>Unused only</button>' +
|
||||
'<span class="spacer"></span><span class="small muted">Demo</span><button class="btn btn-sm" type="button" data-demo-add>Show add error</button><button class="btn btn-sm" type="button" data-demo-conflict>Show rename conflict</button></div><div class="table-scroll">' + rows() + '</div></section>';
|
||||
}
|
||||
function redraw(fid) { var pos = fid && document.getElementById(fid) && document.getElementById(fid).selectionStart; document.getElementById('page').innerHTML = render(); if (fid && document.getElementById(fid)) { var el = document.getElementById(fid); el.focus(); if (pos != null) el.setSelectionRange(pos, pos); } history.replaceState(null, '', location.pathname + '?list=' + S.kind); }
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if ((a = t.closest('[data-kind]'))) { S.kind = a.getAttribute('data-kind'); S.editing = null; S.conflict = S.addErr = false; return redraw(); }
|
||||
if (t.closest('[data-unused]')) { S.unused = !S.unused; return redraw(); }
|
||||
if ((a = t.closest('[data-rn]'))) { S.editing = a.getAttribute('data-rn'); S.conflict = false; redraw('rn'); return; }
|
||||
if (t.closest('[data-rn-cancel]')) { S.editing = null; S.conflict = false; return redraw(); }
|
||||
if (t.closest('[data-rn-save]')) { var v = document.getElementById('rn').value; S.editing = null; X.toast('Renamed to <b>' + x.esc(v) + '</b>. Every spool using it now reads the new name.'); return redraw(); }
|
||||
if (t.closest('[data-rn-reload]')) { S.conflict = false; return redraw('rn'); }
|
||||
if (t.closest('[data-demo-conflict]')) { S.editing = LOOKUPS[S.kind][0][0]; S.conflict = true; return redraw(); }
|
||||
if (t.closest('[data-demo-add]')) { S.addErr = true; return redraw('addv'); }
|
||||
if (t.closest('[data-add]')) { var nv = document.getElementById('addv').value.trim(); if (!nv) { S.addErr = true; return redraw('addv'); } S.addErr = false; X.toast('Added <b>' + x.esc(nv) + '</b> to ' + kindRow(S.kind)[1]); return redraw(); }
|
||||
});
|
||||
document.addEventListener('input', function (e) { if (e.target.id === 'lf') { S.f = e.target.value; redraw('lf'); } });
|
||||
X.init({ name: 'lists' }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<style>
|
||||
.lab { aspect-ratio: 51 / 21; width: 100%; max-width: 340px; background: #fff; color: #111; border-radius: 6px; display: grid; grid-template-columns: 37.25% 1fr; align-items: center; padding: 2% 3% 2% 2%; box-shadow: 0 1px 0 rgba(0,0,0,.2), var(--nm-raised-1); border: 1px solid var(--border-control); font-family: var(--font-mono); overflow: hidden; }
|
||||
.lab svg { width: 92%; height: auto; display: block; }
|
||||
.lab .t { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.lab .h { font-weight: 800; font-size: clamp(10px, 3.6cqw, 15px); line-height: 1.1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lab .d { font-size: clamp(8px, 2.6cqw, 11px); line-height: 1.2; }
|
||||
.lab .id { font-size: clamp(9px, 3cqw, 12px); font-weight: 700; }
|
||||
.labwrap { container-type: inline-size; }
|
||||
.lgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: var(--space-4); }
|
||||
.lcard { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.lcard.off { opacity: .55; }
|
||||
.lcard .top { display: flex; gap: var(--space-3); align-items: center; }
|
||||
.chk { width: 20px; height: 20px; accent-color: var(--text-accent); }
|
||||
.bar { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; padding: var(--space-3) var(--space-4); }
|
||||
.spec { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: var(--fs-label); margin: 0; }
|
||||
.spec dt { color: var(--text-secondary); }
|
||||
.spec dd { margin: 0; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var q = new URLSearchParams(location.search);
|
||||
var S = { tab: q.get('tab') || 'needed', sel: {} };
|
||||
SPOOLS.filter(function (s) { return !s.label; }).forEach(function (s) { S.sel[s.id] = true; });
|
||||
/* A deterministic QR-looking pattern (not a real code) with the three finder squares. */
|
||||
function qr(seed) {
|
||||
var n = 25, cells = '', r = seed * 9301 + 49297;
|
||||
function rnd() { r = (r * 9301 + 49297) % 233280; return r / 233280; }
|
||||
function finder(ox, oy) { return '<rect x="' + ox + '" y="' + oy + '" width="7" height="7" fill="#111"/><rect x="' + (ox + 1) + '" y="' + (oy + 1) + '" width="5" height="5" fill="#fff"/><rect x="' + (ox + 2) + '" y="' + (oy + 2) + '" width="3" height="3" fill="#111"/>'; }
|
||||
for (var yy = 0; yy < n; yy++) for (var xx = 0; xx < n; xx++) {
|
||||
var inF = (xx < 8 && yy < 8) || (xx > n - 9 && yy < 8) || (xx < 8 && yy > n - 9);
|
||||
if (!inF && rnd() > 0.52) cells += '<rect x="' + xx + '" y="' + yy + '" width="1" height="1"/>';
|
||||
}
|
||||
return '<svg viewBox="-1 -1 ' + (n + 2) + ' ' + (n + 2) + '" aria-hidden="true" shape-rendering="crispEdges"><rect x="-1" y="-1" width="' + (n + 2) + '" height="' + (n + 2) + '" fill="#fff"/><g fill="#111">' + cells + '</g>' + finder(0, 0) + finder(n - 7, 0) + finder(0, n - 7) + '</svg>';
|
||||
}
|
||||
function spoolLabel(s) {
|
||||
var d = [s.mat, s.type, s.speed, s.fin].filter(Boolean).join(' ');
|
||||
return '<div class="labwrap"><div class="lab" role="img" aria-label="Label preview: ' + x.esc(s.brand + ' / ' + s.color + ', ' + d + ', #' + s.id) + '">' + qr(s.id) + '<div class="t"><span class="h">' + x.esc(s.brand + ' / ' + s.color) + '</span><span class="d">' + x.esc(d) + '</span><span class="id">#' + s.id + '</span></div></div></div>';
|
||||
}
|
||||
function holderLabel(pr, c) {
|
||||
var kind = { ams: 'AMS', ams_lite: 'AMS Lite', external: 'External' }[c.kind], id = X.holderIds[c.id];
|
||||
return '<div class="labwrap"><div class="lab" role="img" aria-label="Holder label preview">' + qr(100 + id) + '<div class="t"><span class="h" style="font-size:clamp(9px,3cqw,13px)">Printer: ' + x.esc(pr.name) + '</span><span class="d">Container: ' + x.esc(c.label + ' / ' + kind) + '</span><span class="id">#' + id + '</span></div></div></div>';
|
||||
}
|
||||
function needed() {
|
||||
var l = SPOOLS.filter(function (s) { return !s.label; }), n = l.filter(function (s) { return S.sel[s.id]; }).length;
|
||||
if (!l.length) return '<div class="nm-raised empty"><h3>✓ Every spool has a label</h3><p>New spools land here until their label is generated.</p></div>';
|
||||
return '<div class="bar nm-inset"><label class="row small" style="gap:8px"><input type="checkbox" class="chk" data-all ' + (n === l.length ? 'checked' : '') + '> All ' + l.length + '</label><span class="spacer"></span><span class="small muted">One PDF, one 51 × 21 mm page per label</span><button class="btn btn-primary" type="button" ' + (n ? 'data-gen="' + n + '"' : 'disabled') + '>Download ' + n + ' label' + (n === 1 ? '' : 's') + ' (PDF)</button></div>' +
|
||||
'<div class="lgrid">' + l.map(function (s) { return '<article class="lcard nm-raised' + (S.sel[s.id] ? '' : ' off') + '"><div class="top"><input type="checkbox" class="chk" data-s="' + s.id + '" ' + (S.sel[s.id] ? 'checked' : '') + ' aria-label="Include ' + x.esc(s.color) + ' #' + s.id + '"><span style="flex:1;min-width:0"><b>' + x.esc(s.color) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">' + x.esc(x.where(s).text) + '</span></span><button class="btn btn-sm" type="button" data-spool="' + s.id + '">Open</button></div>' + spoolLabel(s) + '</article>'; }).join('') + '</div>' +
|
||||
'<p class="small muted" style="margin:0">A spool counts as labeled once its PDF is generated successfully. Editing the spool later doesn\'t clear that. Reprint any label from the <b>All spools</b> tab.</p>';
|
||||
}
|
||||
function all() {
|
||||
return '<section class="nm-raised table-card"><div class="table-scroll"><table class="tbl"><thead><tr><th>Spool</th><th class="hide-m">Label text</th><th>Status</th><th class="r"><span class="sr-only">Actions</span></th></tr></thead><tbody>' +
|
||||
SPOOLS.map(function (s) { return '<tr><td><div class="row" style="gap:10px;flex-wrap:nowrap">' + x.sw(s.c, 'swatch-sm') + '<span><b>' + x.esc(s.color) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span></span></div></td><td class="hide-m"><span class="small">' + x.esc(s.brand + ' / ' + s.color) + '</span><span class="sub">' + x.esc([s.mat, s.type, s.speed, s.fin].filter(Boolean).join(' ')) + '</span></td><td>' + (s.label ? '<span class="st ok">Printed</span>' : '<span class="st warn">Not printed</span>') + '</td><td class="r"><button class="btn btn-sm" type="button" data-fake="Label PDF for #' + s.id + ' generated">' + (s.label ? 'Reprint' : 'Print') + '</button></td></tr>'; }).join('') + '</tbody></table></div></section>';
|
||||
}
|
||||
function holders() {
|
||||
return '<p class="small muted" style="margin:0">Stick one on each AMS and external holder. Scanning it with a phone starts a <a href="09-scan-holder.html">holder scan session</a>: every spool label scanned next is loaded, returned or moved there. No mode to pick.</p>' +
|
||||
PRINTERS.map(function (pr) { return '<section class="stack" style="gap:10px"><h2 class="section" style="margin:0">' + x.esc(pr.name) + '</h2><div class="lgrid">' + pr.containers.map(function (c) { return '<article class="lcard nm-raised"><div class="top"><b style="flex:1">' + x.esc(c.label) + '</b><span class="sub">' + x.contents(c.id).length + ' / ' + c.cap + '</span><button class="btn btn-sm" type="button" data-fake="Holder label #' + X.holderIds[c.id] + ' PDF generated">Print</button></div>' + holderLabel(pr, c) + '</article>'; }).join('') + '</div></section>'; }).join('');
|
||||
}
|
||||
function render() {
|
||||
var need = SPOOLS.filter(function (s) { return !s.label; }).length;
|
||||
return '<header class="page-head"><div><h1>Labels</h1><p class="sum">51 × 21 mm labels with a QR code. Scanning a spool label opens it; scanning a holder label starts loading into it.</p></div></header>' +
|
||||
'<div class="tabs" role="tablist" aria-label="Label lists">' + [['needed', 'Needs a label', need, 1], ['all', 'All spools', SPOOLS.length], ['holders', 'Holders', 6]].map(function (t) { return '<button role="tab" type="button" data-ltab="' + t[0] + '" aria-selected="' + (S.tab === t[0]) + '">' + t[1] + ' <span class="n' + (t[3] && t[2] ? ' warn' : '') + '">' + t[2] + '</span></button>'; }).join('') + '</div>' +
|
||||
(S.tab === 'needed' ? needed() : S.tab === 'all' ? all() : holders()) +
|
||||
'<details class="nm-inset panel"><summary class="small strong" style="cursor:pointer">Label format</summary><dl class="spec" style="margin-top:10px"><dt>Size</dt><dd>51 × 21 mm, one label per PDF page</dd><dt>QR</dt><dd>19 mm, left; encodes <code>/s/‹id›</code> or <code>/c/‹id›</code></dd><dt>Line 1</dt><dd>Brand / Color (holder: Printer: name)</dd><dt>Line 2</dt><dd>Material, type, speed, finish (holder: Container: label / kind)</dd><dt>Line 3</dt><dd>#id</dd></dl></details>';
|
||||
}
|
||||
function redraw() { document.getElementById('page').innerHTML = render(); history.replaceState(null, '', location.pathname + '?tab=' + S.tab); }
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if ((a = t.closest('[data-ltab]'))) { S.tab = a.getAttribute('data-ltab'); return redraw(); }
|
||||
if (t.matches('[data-s]')) { S.sel[t.getAttribute('data-s')] = t.checked; return redraw(); }
|
||||
if (t.matches('[data-all]')) { SPOOLS.filter(function (s) { return !s.label; }).forEach(function (s) { S.sel[s.id] = t.checked; }); return redraw(); }
|
||||
if ((a = t.closest('[data-gen]'))) { SPOOLS.forEach(function (s) { if (S.sel[s.id]) s.label = true; }); X.toast('Downloaded <b>extrudex-labels.pdf</b> · ' + a.getAttribute('data-gen') + ' labels, now marked printed'); return redraw(); }
|
||||
});
|
||||
X.init({ name: 'labels', refresh: redraw }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,58 @@
|
||||
<style>
|
||||
.roles { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: var(--space-3); }
|
||||
.role { padding: var(--space-4); display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-label); }
|
||||
.role b { font-size: 14px; }
|
||||
.person { padding: var(--space-4) var(--space-5); display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; gap: var(--space-4); align-items: center; }
|
||||
.avatar { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-control); }
|
||||
.sessions { grid-column: 2 / -1; display: flex; flex-direction: column; gap: 6px; }
|
||||
.sess { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; padding: 8px 12px; border-radius: var(--radius-sm); background: var(--surface-inset); border: 1px solid var(--border-hairline); font-size: var(--fs-label); }
|
||||
.token-once { padding: var(--space-4); border: 2px solid var(--status-warning); background: var(--wash-warning); border-radius: var(--radius-md); display: flex; flex-direction: column; gap: 8px; }
|
||||
.token-once code { font-size: 13px; padding: 8px 10px; display: block; overflow-wrap: anywhere; }
|
||||
.gap-tag { font-size: var(--fs-caption); }
|
||||
@media (max-width: 699px) { .person { grid-template-columns: 36px minmax(0, 1fr); } .person > .act { grid-column: 1 / -1; } .avatar { width: 36px; height: 36px; } .sessions { grid-column: 1 / -1; } }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var S = { revoke: null, newToken: null, expanded: { 1: true } };
|
||||
function rel(iso) { return x.when(iso); }
|
||||
var ROLE = { reader: ['Reader', 'Sees everything: inventory, prints, printers, camera snapshots, holder labels.'], operator: ['Operator', 'Reader, plus changing inventory: spools, weigh-ins, moves, labels, prints, lists.'], admin: ['Admin', 'Operator, plus printers and holders, access and system settings.'] };
|
||||
function people() {
|
||||
return PEOPLE.map(function (p) {
|
||||
var active = p.sessions.filter(function (s) { return !s.revoked; });
|
||||
var sess = S.expanded[p.id] ? '<div class="sessions">' + (active.length ? active.map(function (s) { return '<div class="sess"><span><b>' + (s.you ? 'This browser' : 'Session') + '</b> · signed in ' + rel(s.created) + '</span><span class="muted">last seen ' + rel(s.lastSeen) + '</span><span class="muted">idle expiry ' + rel(s.idleExp) + '</span><span class="muted">hard expiry ' + rel(s.maxExp) + '</span></div>'; }).join('') : '<span class="small muted">No local sessions recorded.</span>') + '</div>' : '';
|
||||
var act = S.revoke === p.id
|
||||
? '<div class="stack act" style="gap:6px;align-items:flex-end"><span class="small">Sign ' + x.esc(p.name) + ' out of ' + active.length + ' session' + (active.length > 1 ? 's' : '') + '?' + (p.id === 1 ? ' <b>Including this one.</b>' : '') + '</span><div class="row" style="gap:6px"><button class="btn btn-sm" type="button" data-keep>Cancel</button><button class="btn btn-sm btn-danger" type="button" data-do-revoke="' + p.id + '">Revoke</button></div></div>'
|
||||
: '<div class="row act" style="gap:6px;justify-content:flex-end">' + (active.length ? '<button class="btn btn-sm" type="button" data-expand="' + p.id + '" aria-expanded="' + !!S.expanded[p.id] + '">' + active.length + ' session' + (active.length > 1 ? 's' : '') + ' ' + (S.expanded[p.id] ? '▴' : '▾') + '</button><button class="btn btn-sm" type="button" data-revoke="' + p.id + '">Revoke…</button>' : '<span class="small muted">no sessions</span>') + '</div>';
|
||||
return '<article class="person nm-raised"><span class="avatar" aria-hidden="true">' + x.esc(p.name[0]) + '</span><div style="min-width:0"><b>' + x.esc(p.name) + '</b>' + (p.id === 1 ? ' <span class="sub" style="display:inline">(you)</span>' : '') + '<span class="sub">' + (p.email ? x.esc(p.email) + ' · ' : '') + '<span class="st ' + (p.role === 'admin' ? 'run' : p.role === 'operator' ? 'ok' : 'idle') + '" style="font-size:11px">' + ROLE[p.role][0] + '</span> · last seen ' + rel(p.lastSeen) + '</span></div>' + act + sess + '</article>';
|
||||
}).join('');
|
||||
}
|
||||
function machines() {
|
||||
return MACHINES.map(function (m) {
|
||||
return '<article class="nm-raised" style="padding:0;overflow:hidden"><div class="row" style="padding:14px 16px;gap:12px"><span class="avatar" style="border-radius:12px" aria-hidden="true">⚙</span><span style="flex:1;min-width:0"><b>' + x.esc(m.name) + '</b><span class="sub">machine · <span class="st ' + (m.role === 'operator' ? 'ok' : 'idle') + '" style="font-size:11px">' + ROLE[m.role][0] + '</span></span></span><button class="btn btn-sm" type="button" data-newtoken="' + m.id + '">+ New token</button></div>' +
|
||||
(S.newToken === m.id ? '<div style="padding:0 16px 16px"><div class="token-once" role="alert"><b>Copy this token now. It won\'t be shown again.</b><code>exd_9f2c…(sample, the full token appears once)</code><span class="small">Only its hash is stored. If it\'s lost, revoke it and make a new one.</span><div class="row" style="gap:8px"><button class="btn btn-sm btn-primary" type="button" data-fake="Token copied to clipboard">Copy</button><button class="btn btn-sm" type="button" data-done-token>I\'ve stored it</button></div></div></div>' : '') +
|
||||
'<table class="tbl"><thead><tr><th>Token</th><th class="hide-m">Created</th><th>Last used</th><th class="r"><span class="sr-only">Actions</span></th></tr></thead><tbody>' + m.tokens.map(function (t) { return '<tr' + (t.revoked ? ' style="opacity:.6"' : '') + '><td><b>' + x.esc(t.name) + '</b>' + (t.revoked ? '<span class="sub">revoked ' + rel(t.revoked) + '</span>' : '') + '</td><td class="hide-m">' + rel(t.created) + '</td><td>' + (t.lastUsed ? rel(t.lastUsed) : '<span class="muted">never</span>') + '</td><td class="r">' + (t.revoked ? '' : '<button class="btn btn-sm btn-danger" type="button" data-fake="Revoked ' + x.esc(t.name) + '. Requests using it now get 401.">Revoke</button>') + '</td></tr>'; }).join('') + '</tbody></table></article>';
|
||||
}).join('');
|
||||
}
|
||||
function render() {
|
||||
return '<header class="page-head"><div><h1>Access</h1><p class="sum">Admin only. Who can use Extrudex, and what they can do.</p></div><a class="btn" href="#" data-fake="Opens Authentik in a new tab">Open Authentik ↗</a></header>' +
|
||||
'<div class="banner" style="border-color:var(--status-info);background:var(--wash-info)"><b style="color:var(--status-info)">i</b><span style="flex:1">People, roles, passwords and account activation are managed in <b>Authentik</b>. This page shows Extrudex\'s local view: roles as Extrudex sees them, and sign-in sessions you can revoke here.</span></div>' +
|
||||
'<section class="roles" aria-label="Roles">' + ['reader', 'operator', 'admin'].map(function (r) { return '<div class="role nm-inset"><b>' + ROLE[r][0] + '</b><span class="muted">' + ROLE[r][1] + '</span></div>'; }).join('') + '</section>' +
|
||||
'<section class="stack" style="gap:12px" aria-labelledby="ppl"><h2 class="section" id="ppl" style="margin:0">People · ' + PEOPLE.length + '</h2>' + people() + '</section>' +
|
||||
'<section class="stack" style="gap:12px" aria-labelledby="mach"><h2 class="section" id="mach" style="margin:0">Machines & API tokens <span class="prov warn gap-tag"><i>⚠</i>API exists · no screen on dev yet</span></h2><p class="small muted" style="margin:0">For integrations like the gateway and health checks. Each machine gets a role; tokens are bearer credentials and can be revoked one at a time.</p>' + machines() +
|
||||
'<button class="btn" type="button" style="align-self:flex-start" data-fake="New machine form: name + role">+ Add machine</button></section>' +
|
||||
'<details class="nm-inset panel"><summary class="small strong" style="cursor:pointer">Runbook: changing someone\'s access</summary><ol class="small" style="margin:10px 0 0;padding-left:18px"><li>Change the group in Authentik.</li><li>Existing sessions pick it up when Extrudex next re-checks authorization (each session records when it was last checked). Revoke their sessions here to make it take effect now.</li><li>Removed people keep their name on audit history.</li></ol></details>';
|
||||
}
|
||||
function redraw() { document.getElementById('page').innerHTML = render(); }
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if ((a = t.closest('[data-expand]'))) { var id = +a.getAttribute('data-expand'); S.expanded[id] = !S.expanded[id]; return redraw(); }
|
||||
if ((a = t.closest('[data-revoke]'))) { S.revoke = +a.getAttribute('data-revoke'); return redraw(); }
|
||||
if (t.closest('[data-keep]')) { S.revoke = null; return redraw(); }
|
||||
if ((a = t.closest('[data-do-revoke]'))) { var p = PEOPLE.filter(function (pp) { return pp.id === +a.getAttribute('data-do-revoke'); })[0]; p.sessions.forEach(function (s) { s.revoked = '2026-09-18T09:41'; }); S.revoke = null; X.toast('Revoked ' + x.esc(p.name) + '\'s local sessions. They\'ll need to sign in again.'); return redraw(); }
|
||||
if ((a = t.closest('[data-newtoken]'))) { S.newToken = +a.getAttribute('data-newtoken'); return redraw(); }
|
||||
if (t.closest('[data-done-token]')) { S.newToken = null; return redraw(); }
|
||||
});
|
||||
X.init({ name: 'access' }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<style>
|
||||
.sysgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: var(--space-5); align-items: start; }
|
||||
.syscard { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.syscard h2 { margin: 0; font-size: var(--fs-h3); display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.afilters { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: center; padding: var(--space-4); }
|
||||
.afilters .select, .afilters .input { width: auto; min-height: 36px; padding: 6px 10px; font-size: 13px; }
|
||||
.act-code { font-size: 12px; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var S = { who: '', type: '', sent: false, update: false, installed: false };
|
||||
var LINK = { spool: function (id) { return '<a href="#spool=' + id + '" data-spool="' + id + '">spool #' + id + '</a>'; }, print: function (id) { return '<a href="#print=' + id + '" data-print="' + id + '">print #' + id + '</a>'; }, printer: function (id) { return '<a href="04-printers.html#printer=' + id + '">printer #' + id + '</a>'; }, spool_type: function (id) { return '<a href="05-lists.html?list=spool_types">spool type #' + id + '</a>'; }, principal: function (id) { return '<a href="07-access.html">person #' + id + '</a>'; } };
|
||||
var WST = { delivered: ['ok', 'Delivered'], no_low: ['idle', 'Nothing low'], pending: ['warn', 'Retrying'], expired: ['err', 'Expired'] };
|
||||
function webhook() {
|
||||
var lowNow = SPOOLS.filter(x.low).length;
|
||||
return '<section class="syscard nm-raised" aria-labelledby="wh"><h2 id="wh">Low-filament report <span class="st ok" style="font-size:12px">On</span><span class="prov warn" style="font-size:11px"><i>⚠</i>API exists · no screen on dev yet</span></h2>' +
|
||||
'<dl class="kv"><dt>Sends</dt><dd>' + WEBHOOK.schedule + '</dd><dt>Includes</dt><dd>spools under ' + WEBHOOK.threshold + ' g (' + lowNow + ' right now)</dd><dt>Destination</dt><dd class="muted">' + WEBHOOK.target + '</dd></dl>' +
|
||||
'<table class="tbl"><thead><tr><th>Week</th><th>Result</th><th class="r">Spools</th><th class="hide-m">Detail</th></tr></thead><tbody>' + WEBHOOK.events.map(function (ev) { var w = WST[ev.state]; return '<tr><td><b>' + ev.period + '</b><span class="sub">' + x.when(ev.observed) + '</span></td><td><span class="st ' + w[0] + '">' + w[1] + '</span>' + (ev.attempts > 1 ? '<span class="sub">' + ev.attempts + ' attempts</span>' : '') + '</td><td class="r">' + (ev.spools || '—') + '</td><td class="hide-m small">' + (ev.lastError ? '<span class="prov warn" style="white-space:normal"><i>!</i>' + x.esc(ev.lastError) + '</span>' : ev.delivered ? 'at ' + x.when(ev.delivered).split(' ').pop() : '<span class="muted">nothing sent</span>') + '</td></tr>'; }).join('') + '</tbody></table>' +
|
||||
(S.sent ? '<div class="banner" style="border-color:var(--status-success);background:var(--wash-success)"><b style="color:var(--status-success)">✓</b><span>This week\'s report (2026-W38) was already delivered Monday 08:00, so nothing was sent again. A week that\'s still retrying would be retried now.</span></div>' : '') +
|
||||
'<div class="row"><button class="btn btn-sm" type="button" data-send>Run this week\'s report now</button><span class="sub" style="display:inline">One report per week. Running it again never sends a duplicate.</span></div></section>';
|
||||
}
|
||||
function app() {
|
||||
return '<section class="syscard nm-raised" aria-labelledby="ap"><h2 id="ap">This device</h2>' +
|
||||
'<dl class="kv"><dt>Version</dt><dd>dev <code>321ce0d</code></dd><dt>Offline use</dt><dd><span class="st ok">Ready</span><span class="sub">App shell is cached; data still needs the server</span></dd><dt>Installed</dt><dd>' + (S.installed ? '<span class="st ok">Yes</span>' : '<span class="st idle">No</span>') + '</dd></dl>' +
|
||||
(S.update ? '<div class="banner"><b>↻</b><span style="flex:1">An update is ready. Finish any open form, then reload.</span><button class="btn btn-sm btn-primary" type="button" data-fake="Reloading with the update">Reload now</button></div>' : '') +
|
||||
'<div class="row" style="gap:8px">' + (S.installed ? '' : '<button class="btn btn-sm btn-primary" type="button" data-install>Install Extrudex</button>') + '<button class="btn btn-sm" type="button" data-update>Demo: update ready</button></div>' +
|
||||
'<p class="sub" style="margin:0">On iPhone or iPad: open in Safari, tap Share, then <b>Add to Home Screen</b>.</p></section>';
|
||||
}
|
||||
function audit() {
|
||||
var who = {}, types = {}; AUDIT.forEach(function (a) { who[a.who] = 1; types[a.type] = 1; });
|
||||
var l = AUDIT.filter(function (a) { return (!S.who || a.who === S.who) && (!S.type || a.type === S.type); });
|
||||
return '<section class="nm-raised table-card" aria-labelledby="au"><div class="afilters"><h2 id="au" style="margin:0;font-size:var(--fs-h3);flex:1">Audit log <span class="prov warn" style="font-size:11px"><i>⚠</i>API exists · no screen on dev yet</span></h2>' +
|
||||
'<select class="select" id="aw" aria-label="Who"><option value="">Anyone</option>' + Object.keys(who).map(function (w) { return '<option' + (S.who === w ? ' selected' : '') + '>' + x.esc(w) + '</option>'; }).join('') + '</select>' +
|
||||
'<select class="select" id="at" aria-label="Resource"><option value="">Any resource</option>' + Object.keys(types).map(function (w) { return '<option' + (S.type === w ? ' selected' : '') + '>' + w + '</option>'; }).join('') + '</select></div>' +
|
||||
'<div class="table-scroll"><table class="tbl"><thead><tr><th>When</th><th>Who</th><th>What</th><th class="hide-m">Detail</th></tr></thead><tbody>' + (l.length ? l.map(function (a) { return '<tr><td class="nowrap">' + x.when(a.at) + '</td><td>' + x.esc(a.who) + '</td><td><code class="act-code">' + a.action + '</code><span class="sub">' + (LINK[a.type] ? LINK[a.type](a.rid) : a.type + ' #' + a.rid) + '</span></td><td class="hide-m small">' + x.esc(a.ctx) + '</td></tr>'; }).join('') : '<tr><td colspan="4"><div class="empty"><h3>No events match</h3></div></td></tr>') + '</tbody></table></div>' +
|
||||
'<div class="table-foot"><span class="muted">Newest first · every write records who, what and when</span><button class="btn btn-sm" type="button" data-fake="Loaded older events (paged by id)">Older →</button></div></section>';
|
||||
}
|
||||
function render() {
|
||||
return '<header class="page-head"><div><h1>System</h1><p class="sum">Admin only. Scheduled reports, the audit trail, and this device.</p></div></header>' +
|
||||
'<div class="sysgrid">' + webhook() + app() + '</div>' + audit();
|
||||
}
|
||||
function redraw() { document.getElementById('page').innerHTML = render(); }
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target;
|
||||
if (t.closest('[data-send]')) { S.sent = true; return redraw(); }
|
||||
if (t.closest('[data-update]')) { S.update = true; return redraw(); }
|
||||
if (t.closest('[data-install]')) { S.installed = true; X.toast('Installed. Extrudex opens in its own window now.'); return redraw(); }
|
||||
});
|
||||
document.addEventListener('change', function (e) { if (e.target.id === 'aw') { S.who = e.target.value; redraw(); } if (e.target.id === 'at') { S.type = e.target.value; redraw(); } });
|
||||
X.init({ name: 'system' }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<style>
|
||||
.scanwrap { max-width: 560px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.hhead { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.hhead h1 { margin: 0; font-size: var(--fs-h2); line-height: 1.2; }
|
||||
.cap { display: flex; gap: 6px; }
|
||||
.cap i { flex: 1; height: 12px; border-radius: 4px; border: 1px solid var(--border-control); background: var(--surface-inset); }
|
||||
.cap i.on { background: var(--text-accent); border-color: var(--text-accent); }
|
||||
.bigscan { min-height: 64px; font-size: 17px; justify-content: center; width: 100%; border-radius: var(--radius-lg); }
|
||||
.rule { font-size: var(--fs-label); color: var(--text-secondary); display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; }
|
||||
.rule b { color: var(--text-primary); }
|
||||
.result { padding: var(--space-4); border-radius: var(--radius-lg); border: 2px solid var(--status-success); background: var(--wash-success); display: grid; grid-template-columns: 44px 1fr; gap: 4px 12px; align-items: center; }
|
||||
.result.err { border-color: var(--status-error); background: var(--wash-error); }
|
||||
.result .verb { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); font-weight: 800; }
|
||||
.result.ok .verb { color: var(--status-success); } .result.err .verb { color: var(--status-error); }
|
||||
.row-sp { all: unset; box-sizing: border-box; cursor: pointer; width: 100%; display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; padding: 12px 16px; border-top: 1px solid var(--border-hairline); font-size: 14px; min-height: 56px; }
|
||||
.row-sp:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
|
||||
.cam-sheet { position: fixed; inset: 0; z-index: 80; background: #0b0b0d; display: none; flex-direction: column; color: #f4f4f6; }
|
||||
.cam-sheet.open { display: flex; }
|
||||
.vf { flex: 1; position: relative; display: grid; place-items: center; background: radial-gradient(ellipse at 50% 55%, #2a2a31 0, #0b0b0d 70%); }
|
||||
.vf .frame { width: min(64vw, 280px); aspect-ratio: 1; border-radius: 24px; box-shadow: 0 0 0 2000px rgba(0,0,0,.45); border: 3px solid #a99af0; position: relative; }
|
||||
.vf .hint { position: absolute; bottom: 18%; left: 0; right: 0; text-align: center; font-size: 14px; }
|
||||
.cam-list { background: #1b1b1e; padding: 12px 16px 24px; display: flex; flex-direction: column; gap: 8px; max-height: 45vh; overflow: auto; }
|
||||
.cam-list button { all: unset; cursor: pointer; display: flex; gap: 12px; align-items: center; padding: 12px; border-radius: 12px; background: #2c2c30; border: 1px solid #85858f; color: #f4f4f6; font-size: 14px; }
|
||||
.cam-list button:focus-visible { outline: 2px solid #a99af0; outline-offset: 2px; }
|
||||
.reader-note { display: none; }
|
||||
body.as-reader .reader-note { display: flex; }
|
||||
body.as-reader [data-needs-op] { opacity: .5; pointer-events: none; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var ids = X.holderIds, byNum = {}; Object.keys(ids).forEach(function (k) { byNum[ids[k]] = k; });
|
||||
var q = new URLSearchParams(location.search), cid = byNum[+q.get('c')] || 'p3-lite';
|
||||
var S = { last: null, log: [] };
|
||||
function hold() { return x.container(cid); }
|
||||
function apply(sid) {
|
||||
var s = x.spool(sid), h = hold(), here = x.contents(cid), before = x.where(s).text;
|
||||
if (!s) return { err: true, verb: 'Not found', text: 'No spool #' + sid + '. Scan a spool label, or type its number.' };
|
||||
if (s.cont === cid) { s.cont = null; s.loc = s.loc || 'Storage'; return { verb: 'Returned to storage', s: s, text: 'Was in ' + h.c.label + '. Put it back on its shelf' + (s.loc && s.loc !== 'Storage' ? ' (' + s.loc + ')' : '') + '.' }; }
|
||||
if (here.length >= h.c.cap) return { err: true, verb: 'Full', s: s, text: h.c.label + ' already holds ' + h.c.cap + '. Scan a spool that\'s in it to return that one first. Nothing changed.' };
|
||||
var was = s.cont; s.cont = cid; s.v++;
|
||||
return { verb: was ? 'Moved here' : 'Loaded', s: s, text: (was ? 'Was in ' : 'Was on ') + before + '.' };
|
||||
}
|
||||
function resultCard(r) {
|
||||
if (!r) return '';
|
||||
return '<div class="result ' + (r.err ? 'err' : 'ok') + '" role="status" aria-live="polite">' + (r.s ? x.sw(r.s.c, '" style="width:44px;height:44px') : '<span class="avatar" style="font-size:22px;text-align:center">?</span>') + '<span><span class="verb">' + (r.err ? '✕ ' : '✓ ') + r.verb + '</span><br>' + (r.s ? '<b>' + x.esc(r.s.color) + '</b> <span class="muted">#' + r.s.id + ' · ' + x.esc(r.s.brand) + '</span>' : '') + '</span><span></span><span class="small">' + x.esc(r.text) + '</span></div>';
|
||||
}
|
||||
function render() {
|
||||
var h = hold(), here = x.contents(cid), free = h.c.cap - here.length;
|
||||
return '<div class="scanwrap">' +
|
||||
'<nav class="crumbs" aria-label="Breadcrumb"><a href="04-printers.html#printer=' + h.printer.id + '">' + x.esc(h.printer.name) + '</a> › <span>Holder #' + ids[cid] + '</span></nav>' +
|
||||
'<section class="hhead nm-raised"><span class="sub">Scan session · holder #' + ids[cid] + '</span><h1>' + x.esc(h.c.label) + ' <span class="muted" style="font-weight:500">· ' + x.esc(h.printer.model) + '</span></h1>' +
|
||||
'<div class="cap" aria-label="' + here.length + ' of ' + h.c.cap + ' in use">' + Array.apply(null, Array(h.c.cap)).map(function (_, i) { return '<i class="' + (i < here.length ? 'on' : '') + '"></i>'; }).join('') + '</div><span class="small">' + here.length + ' in use · <b>' + free + ' free</b></span></section>' +
|
||||
'<div class="banner reader-note" role="note"><span>You\'re signed in as a <b>reader</b>. You can see what\'s recorded here; an operator or admin can move spools.</span></div>' +
|
||||
'<button class="btn btn-primary bigscan" type="button" data-cam data-needs-op>▣ Scan a spool label</button>' +
|
||||
'<div class="row" style="gap:8px" data-needs-op><input class="input num" id="manual" inputmode="numeric" placeholder="or type spool # (e.g. 49)" aria-label="Spool number" style="flex:1"><button class="btn" type="button" data-manual>Go</button></div>' +
|
||||
resultCard(S.last) +
|
||||
'<details class="nm-inset panel" ' + (S.log.length ? '' : 'open') + '><summary class="small strong" style="cursor:pointer">What a scan does</summary><div class="rule" style="margin-top:10px"><b>In storage</b><span>→ loads here (if there\'s room)</span><b>Already here</b><span>→ returns to storage</span><b>In another holder</b><span>→ moves here</span></div><p class="sub" style="margin:8px 0 0">No mode to choose. This holder stays active for this browser tab, so every spool label you scan next lands here until you tap Done.</p></details>' +
|
||||
'<section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="inh"><div class="card-h" style="display:flex;padding:14px 16px 8px"><h2 id="inh" style="margin:0;font-size:var(--fs-label);text-transform:uppercase;letter-spacing:var(--tracking-caps-wide);color:var(--text-secondary)">Recorded in ' + x.esc(h.c.label) + '</h2></div>' +
|
||||
(here.length ? here.map(function (s) { return '<button class="row-sp" type="button" data-spool="' + s.id + '">' + x.sw(s.c) + '<span style="min-width:0"><b>' + x.esc(s.color) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">' + x.esc(x.fullName(s)) + '</span></span><span class="num' + (x.low(s) ? '" style="color:var(--status-warning);font-weight:700' : '') + '">' + x.g(s.rem) + '</span></button>'; }).join('') : '<p class="small muted" style="padding:0 16px 16px;margin:0">Empty.</p>') + '</section>' +
|
||||
(S.log.length ? '<section class="stack" style="gap:6px"><h2 class="section" style="margin:0">This session</h2>' + S.log.map(function (r) { return '<span class="small">' + (r.err ? '<span class="prov warn"><i>✕</i></span>' : '✓') + ' ' + r.verb + (r.s ? ' · ' + x.esc(r.s.color) + ' #' + r.s.id : '') + '</span>'; }).join('') + '</section>' : '') +
|
||||
'<div class="row" style="justify-content:space-between"><label class="row small" style="gap:6px"><input type="checkbox" data-reader> Demo: view as reader</label><a class="btn" href="04-printers.html#printer=' + h.printer.id + '">Done scanning</a></div></div>' +
|
||||
'<div class="cam-sheet" id="cam" role="dialog" aria-modal="true" aria-label="Camera"><div class="row" style="padding:14px 16px;justify-content:space-between"><b>Point at a spool label</b><button class="btn btn-sm" type="button" data-cam-close style="background:#2c2c30;color:#f4f4f6">Close</button></div><div class="vf"><div class="frame"></div><span class="hint">Demo: tap a label below to simulate a scan</span></div><div class="cam-list">' +
|
||||
SPOOLS.filter(function (s) { return [49, 41, 47, 56, 43].indexOf(s.id) >= 0; }).map(function (s) { return '<button type="button" data-scan="' + s.id + '">' + x.sw(s.c) + '<span style="flex:1"><b>#' + s.id + ' ' + x.esc(s.color) + '</b><br><small style="color:#b2b2ba">' + x.esc(x.where(s).text) + '</small></span></button>'; }).join('') + '</div></div>';
|
||||
}
|
||||
function redraw() { document.getElementById('page').innerHTML = render(); }
|
||||
function doScan(id) { var r = apply(id); S.last = r; S.log.unshift(r); var cam = document.getElementById('cam'); if (cam) cam.classList.remove('open'); redraw(); if (navigator.vibrate) try { navigator.vibrate(r.err ? [60, 40, 60] : 30); } catch (e) {} }
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if (t.closest('[data-cam]')) { document.getElementById('cam').classList.add('open'); var f = document.querySelector('#cam [data-scan]'); if (f) f.focus(); return; }
|
||||
if (t.closest('[data-cam-close]')) { document.getElementById('cam').classList.remove('open'); return; }
|
||||
if ((a = t.closest('[data-scan]'))) return doScan(+a.getAttribute('data-scan'));
|
||||
if (t.closest('[data-manual]')) { var v = +document.getElementById('manual').value.replace(/\D/g, ''); if (v) doScan(v); return; }
|
||||
if (t.matches('[data-reader]')) { document.body.classList.toggle('as-reader', t.checked); }
|
||||
});
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { var c = document.getElementById('cam'); if (c && c.classList.contains('open')) c.classList.remove('open'); } if (e.key === 'Enter' && e.target.id === 'manual') { var v = +e.target.value.replace(/\D/g, ''); if (v) doScan(v); } });
|
||||
X.init({ name: 'printers', refresh: redraw }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<style>
|
||||
.scanwrap { max-width: 560px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.hero { padding: var(--space-5); display: grid; grid-template-columns: 64px 1fr; gap: 6px 16px; align-items: center; }
|
||||
.hero h1 { margin: 0; font-size: var(--fs-h2); line-height: 1.15; }
|
||||
.hero .meter { grid-column: 1 / -1; height: 12px; margin-top: 8px; }
|
||||
.big { font-size: 40px; font-weight: 800; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.scale { font-size: 28px !important; min-height: 68px !important; text-align: center; }
|
||||
.res { display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: center; text-align: center; padding: var(--space-4); }
|
||||
.res .n { font-size: 26px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.bigbtn { min-height: 60px; font-size: 16px; justify-content: center; width: 100%; border-radius: var(--radius-lg); }
|
||||
.pick { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
|
||||
.pick a { flex: none; text-decoration: none; color: inherit; padding: 8px 12px; border-radius: var(--radius-pill); border: 1px solid var(--border-control); font-size: 13px; background: var(--surface-raised); }
|
||||
.pick a[aria-current] { border: 2px solid var(--text-accent); color: var(--text-accent); font-weight: 700; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var q = new URLSearchParams(location.search), sid = +(q.get('s') || 41);
|
||||
var S = { reading: '', saved: null };
|
||||
function render() {
|
||||
var s = x.spool(sid), w = x.where(s);
|
||||
var demo = '<nav class="pick" aria-label="Demo: pick a scanned spool">' + [41, 44, 47, 49].map(function (i) { return '<a href="?s=' + i + '"' + (i === sid ? ' aria-current="page"' : '') + '>#' + i + ' ' + x.esc(x.spool(i).color) + '</a>'; }).join('') + '</nav>';
|
||||
var weigh;
|
||||
if (s.tare == null) {
|
||||
weigh = '<section class="nm-raised panel stack" style="gap:12px"><h2 class="section" style="margin:0">Weigh it</h2><div class="banner"><b>?</b><span>No empty spool weight is known for this spool, so a scale reading can\'t be turned into filament. It is never assumed to be 0.</span></div><select class="select"><option>Pick its spool type…</option><option>Generic — 1 kg spool (assumed) · 180 g · estimated</option><option>Creality — Hyper cardboard · 176 g · community</option></select><button class="btn bigbtn" type="button" data-fake="Spool type saved. Weigh-in unlocked.">Save spool type</button></section>';
|
||||
} else {
|
||||
var v = +S.reading, r = Math.round(v - s.tare), ok = v > 0 && v >= s.tare && r <= s.tot + 1;
|
||||
weigh = '<section class="nm-raised panel stack" style="gap:12px"><h2 class="section" style="margin:0">Weigh it</h2><label class="stack" style="gap:6px"><span class="small strong">Whole spool on the scale</span><input class="input scale num" id="sc" inputmode="numeric" value="' + x.esc(S.reading) + '" placeholder="grams" autocomplete="off"><span class="sub">minus ' + x.g(s.tare) + ' empty spool (' + x.tareProv(s) + ') = what\'s left</span></label>' +
|
||||
'<div class="res nm-inset"><span><span class="sub">now</span><br><span class="n">' + x.g(s.rem) + '</span></span><span class="muted" style="font-size:22px">→</span><span><span class="sub">after</span><br><span class="n"' + (v && !ok ? ' style="color:var(--status-error)"' : '') + '>' + (v ? x.g(r) : '—') + '</span></span></div>' +
|
||||
(v && v < s.tare ? '<p class="field-error" style="margin:0">' + x.g(v) + ' is less than the ' + x.g(s.tare) + ' empty spool. Re-weigh, or correct the empty spool weight.</p>' : v && r > s.tot + 1 ? '<p class="field-error" style="margin:0">That leaves ' + x.g(r) + ', more than a full spool (' + x.g(s.tot) + '). Check the total or the empty spool weight.</p>' : '') +
|
||||
'<button class="btn btn-primary bigbtn" type="button" data-wsave ' + (ok ? '' : 'disabled') + '>Save weigh-in</button></section>';
|
||||
}
|
||||
return '<div class="scanwrap">' + demo +
|
||||
'<section class="hero nm-raised">' + x.sw(s.c, '" style="width:64px;height:64px') + '<div style="min-width:0"><span class="sub">Scanned spool #' + s.id + '</span><h1>' + x.esc(s.color) + '</h1><span class="sub">' + x.esc(x.fullName(s)) + '</span></div>' +
|
||||
'<div style="grid-column:1/-1;display:flex;align-items:baseline;gap:10px;margin-top:8px"><span class="big"' + (x.low(s) ? ' style="color:var(--status-warning)"' : '') + '>' + Math.round(s.rem).toLocaleString('en-US') + '<span style="font-size:16px"> g</span></span><span class="muted">of ' + x.g(s.tot) + ' net</span><span class="spacer"></span>' + x.remProv(s) + '</div>' + x.meter(s) + '</section>' +
|
||||
(S.saved ? '<div class="result ok banner" style="border-color:var(--status-success);background:var(--wash-success)" role="status"><b style="color:var(--status-success)">✓</b><span>Saved: ' + x.g(S.saved[0]) + ' → <b>' + x.g(S.saved[1]) + '</b>. Scan the next label whenever you\'re ready.</span></div>' : '') +
|
||||
'<div class="findit"><span class="sub">Lives in</span><b>' + x.esc(w.text) + '</b></div>' +
|
||||
weigh +
|
||||
'<div class="fr2"><a class="btn bigbtn" href="09-scan-holder.html?c=' + (s.cont ? X.holderIds[s.cont] : 5) + '">' + (s.cont ? 'Open its holder' : 'Load into a holder…') + '</a><a class="btn bigbtn" href="02-filament.html#spool=' + s.id + '">Full details</a></div>' +
|
||||
'<p class="small muted" style="margin:0">Tip: scan a holder\'s label first to start a <a href="09-scan-holder.html">scan session</a>. Spool labels scanned after that load or move into it instead of opening this page.</p></div>';
|
||||
}
|
||||
function redraw(focus) { var pos = focus && document.getElementById('sc') && document.getElementById('sc').selectionStart; document.getElementById('page').innerHTML = render(); if (focus && document.getElementById('sc')) { var el = document.getElementById('sc'); el.focus(); el.setSelectionRange(pos, pos); } }
|
||||
document.addEventListener('input', function (e) { if (e.target.id === 'sc') { S.reading = e.target.value.replace(/[^\d]/g, ''); S.saved = null; redraw(true); } });
|
||||
document.addEventListener('click', function (e) { if (e.target.closest('[data-wsave]')) { var s = x.spool(sid), before = s.rem; s.rem = Math.round(+S.reading - s.tare); s.remSrc = 'weighed'; s.weighedAt = '18 Sep 09:41'; S.saved = [before, s.rem]; S.reading = ''; redraw(); } });
|
||||
X.init({ name: 'filament', refresh: redraw }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<style>
|
||||
.dv-bar { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; }
|
||||
.dv-row { display: flex; gap: var(--space-6); align-items: flex-start; flex-wrap: wrap; }
|
||||
.dev { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
|
||||
.dev h2 { margin: 0; font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); }
|
||||
.bezel { border-radius: 36px; padding: 14px; background: linear-gradient(145deg, #3a3a40, #1b1b1e); box-shadow: var(--nm-raised-3); border: 1px solid var(--border-control); }
|
||||
.bezel.tab { border-radius: 28px; padding: 18px; }
|
||||
.screen { overflow: hidden; border-radius: 24px; background: var(--surface-page); position: relative; }
|
||||
.bezel.tab .screen { border-radius: 12px; }
|
||||
.screen iframe { border: 0; transform-origin: 0 0; position: absolute; left: 0; top: 0; }
|
||||
.dv-list { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.dv-list button { font: var(--fw-medium) 12px/1 var(--font-body); padding: 8px 12px; border-radius: var(--radius-pill); border: 1px solid var(--border-control); background: var(--surface-raised); color: var(--text-primary); cursor: pointer; }
|
||||
.dv-list button[aria-pressed="true"] { background: var(--accent-wash); border-color: var(--text-accent); color: var(--text-accent); font-weight: 700; }
|
||||
.dv-list button[aria-pressed="true"]::before { content: "✓ "; }
|
||||
.dv-list button:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
|
||||
.bp { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: var(--space-3); }
|
||||
.bp div { padding: var(--space-4); font-size: 13px; }
|
||||
.bp b { display: block; margin-bottom: 4px; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var SCREENS = [['01-dashboard.html', 'Dashboard'], ['02-filament.html', 'Filament'], ['02-filament.html?f=low#spool=41&tab=weigh', 'Weigh panel'], ['03-prints.html', 'Prints'], ['04-printers.html', 'Printers'], ['05-lists.html', 'Lists'], ['06-labels.html', 'Labels'], ['07-access.html', 'Access'], ['08-system.html', 'System'], ['09-scan-holder.html', 'Scan: holder'], ['10-scan-spool.html?s=44', 'Scan: spool'], ['02-filament.html?palette=weigh', 'Palette']];
|
||||
var DEV = [['Phone', 390, 844, 0.62, ''], ['Tablet portrait', 820, 1180, 0.46, 'tab'], ['Tablet landscape', 1180, 820, 0.5, 'tab']];
|
||||
var q = new URLSearchParams(location.search);
|
||||
var S = { i: +(q.get('i') || 0), theme: q.get('t') || 'dark' };
|
||||
function url(u) { return u.replace(/(\?[^#]*)?(#.*)?$/, function (m, qs, h) { return (qs ? qs + '&' : '?') + 'theme=' + S.theme + (h || ''); }); }
|
||||
function frames() {
|
||||
return DEV.map(function (d) {
|
||||
var w = Math.round(d[1] * d[3]), h = Math.round(d[2] * d[3]);
|
||||
return '<div class="dev"><h2>' + d[0] + ' · ' + d[1] + ' × ' + d[2] + '</h2><div class="bezel ' + d[4] + '"><div class="screen" style="width:' + w + 'px;height:' + h + 'px"><iframe title="' + SCREENS[S.i][1] + ' on ' + d[0] + '" src="' + url(SCREENS[S.i][0]) + '" width="' + d[1] + '" height="' + d[2] + '" style="transform:scale(' + d[3] + ')"></iframe></div></div><a class="small" href="' + url(SCREENS[S.i][0]) + '" target="_blank" rel="noopener">Open full size ↗</a></div>';
|
||||
}).join('');
|
||||
}
|
||||
function render() {
|
||||
return '<header class="page-head"><div><h1>Devices</h1><p class="sum">Every screen on a phone and on a tablet, portrait and landscape. The frames are live, so you can click inside them.</p></div>' +
|
||||
'<div class="tabs" role="group" aria-label="Preview theme"><button type="button" data-t="dark" aria-selected="' + (S.theme === 'dark') + '">Dark</button><button type="button" data-t="light" aria-selected="' + (S.theme === 'light') + '">Light</button></div></header>' +
|
||||
'<div class="dv-list" role="group" aria-label="Screen">' + SCREENS.map(function (s, i) { return '<button type="button" data-i="' + i + '" aria-pressed="' + (i === S.i) + '">' + s[1] + '</button>'; }).join('') + '</div>' +
|
||||
'<div class="dv-row">' + frames() + '</div>' +
|
||||
'<section class="bp" aria-label="Breakpoints"><div class="nm-inset"><b>Phone · under 700 px</b>Bottom tab bar (4 + More) with badges, a floating + for the page\'s main action, panels as full-screen sheets, tables drop to 2–3 columns, 44 px touch targets.</div>' +
|
||||
'<div class="nm-inset"><b>Tablet · 700–1180 px</b>Compact icon rail with labels and badges, panels as a 480 px side sheet over the page, low-priority columns hidden, no keyboard hints on touch.</div>' +
|
||||
'<div class="nm-inset"><b>Desktop · over 1180 px</b>Full rail with the Manage group, a 520 px panel, all columns, keyboard shortcuts shown.</div></section>';
|
||||
}
|
||||
function redraw() { document.getElementById('page').innerHTML = render(); history.replaceState(null, '', location.pathname + '?i=' + S.i + '&t=' + S.theme); }
|
||||
document.addEventListener('click', function (e) {
|
||||
var a = e.target.closest('[data-i]'); if (a) { S.i = +a.getAttribute('data-i'); return redraw(); }
|
||||
a = e.target.closest('[data-t]'); if (a) { S.theme = a.getAttribute('data-t'); return redraw(); }
|
||||
});
|
||||
X.init({ name: 'devices' }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<style>
|
||||
.map { padding: var(--space-5); }
|
||||
.map svg { width: 100%; height: auto; display: block; }
|
||||
.map .node rect { fill: var(--surface-raised); stroke: var(--border-control); stroke-width: 1.5; }
|
||||
.map .node.hub rect { fill: var(--accent-wash); stroke: var(--text-accent); stroke-width: 2; }
|
||||
.map .node.page rect { stroke: var(--text-primary); }
|
||||
.map text { fill: var(--text-primary); font-family: var(--font-mono); font-size: 13px; }
|
||||
.map text.s { fill: var(--text-secondary); font-size: 11px; }
|
||||
.map .e { stroke: var(--text-secondary); stroke-width: 1.5; fill: none; }
|
||||
.map .e.hot { stroke: var(--text-accent); stroke-width: 2; }
|
||||
.map .lbl { fill: var(--text-secondary); font-size: 10.5px; }
|
||||
.map a:focus-visible rect { stroke: var(--focus-ring); stroke-width: 3; }
|
||||
.flows { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 330px), 1fr)); gap: var(--space-4); }
|
||||
.flow { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.flow h3 { margin: 0; font-size: 15px; }
|
||||
.flow ol { margin: 0; padding-left: 20px; font-size: 13px; color: var(--text-secondary); display: flex; flex-direction: column; gap: 4px; }
|
||||
.flow ol b { color: var(--text-primary); }
|
||||
.flow .btn { align-self: flex-start; }
|
||||
.compare { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: var(--space-4); }
|
||||
.compare > div { padding: var(--space-4); font-size: 13px; }
|
||||
.compare h3 { margin: 0 0 8px; font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); }
|
||||
.compare ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
function node(x, y, w, h, t, s, cls, href) {
|
||||
var inner = '<g class="node ' + cls + '"><rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" rx="14"/><text x="' + (x + 16) + '" y="' + (y + 26) + '" font-weight="700">' + t + '</text>' + s.map(function (l, i) { return '<text class="s" x="' + (x + 16) + '" y="' + (y + 46 + i * 16) + '">' + l + '</text>'; }).join('') + '</g>';
|
||||
return href ? '<a href="' + href + '">' + inner + '</a>' : inner;
|
||||
}
|
||||
function edge(d, hot, lx, ly, label) { return '<path class="e' + (hot ? ' hot' : '') + '" d="' + d + '" marker-end="url(#ar' + (hot ? 'h' : '') + ')"/>' + (label ? '<text class="lbl" x="' + lx + '" y="' + ly + '">' + label + '</text>' : ''); }
|
||||
function svg() {
|
||||
return '<svg viewBox="0 -50 1100 600" role="img" aria-labelledby="map-t map-d"><title id="map-t">Extrudex iteration 2 flow map</title><desc id="map-d">Four pages share one navigation, a command palette and a detail panel. The Dashboard links into each page\'s filtered views; spools, prints and holders open in the panel from any page.</desc>' +
|
||||
'<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 10 5 0 10z" fill="var(--text-secondary)"/></marker><marker id="arh" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 10 5 0 10z" fill="var(--text-accent)"/></marker></defs>' +
|
||||
node(30, 30, 230, 110, 'Dashboard', ['“Needs you” queue', 'stock · recent prints', 'printers · running low'], 'page', '01-dashboard.html') +
|
||||
node(310, 30, 230, 110, 'Filament', ['saved views + counts', 'Stock / Weights columns', 'weigh session'], 'page', '02-filament.html') +
|
||||
node(590, 30, 230, 110, 'Prints', ['Needs attention queue', 'All · Running · Archived', 'batch attribute'], 'page', '03-prints.html') +
|
||||
node(870, 30, 200, 110, 'Printers', ['urgency order', 'holders + Load', 'connection state'], 'page', '04-printers.html') +
|
||||
node(250, 300, 600, 130, 'Detail panel (#spool= · #print= · #load=)', ['Spool: Overview · Weigh · Move · Edit (+ 409 / 400 states)', 'Print: what it needs, fixed in place · provenance explained', 'Load: pick a stored spool for a holder with space', 'Opens over any page · Esc / back closes · ↑↓ / j k step through the list'], 'hub', '02-filament.html#spool=47') +
|
||||
node(30, 450, 300, 90, '⌘K / “/” palette', ['spools · prints · printers', '“weigh copper” jumps straight in'], 'hub', null) +
|
||||
node(770, 450, 300, 90, 'Toasts + badges', ['every save confirms what changed', 'nav counts update immediately'], '', null) +
|
||||
edge('M262 85 L306 85', 1, 262, 76, 'views') +
|
||||
edge('M180 30 C180 -18 705 -18 705 26', 1, 395, -8, 'Review N prints') +
|
||||
edge('M110 30 C110 -38 970 -38 970 26', 1, 520, -30, 'Open printer') +
|
||||
edge('M145 140 C145 230 330 250 400 298', 1, 150, 225, 'spool / print rows') +
|
||||
edge('M425 140 L470 298', 1, 452, 230, 'row / ↵') +
|
||||
edge('M705 140 L640 298', 1, 680, 230, 'row / fix') +
|
||||
edge('M970 140 C970 230 860 270 800 298', 1, 900, 235, 'spool · + Load') +
|
||||
edge('M180 450 C180 400 240 380 248 380', 0, 120, 420, 'open anything') +
|
||||
edge('M850 380 C900 400 920 420 920 448', 0, 930, 415, 'after save') +
|
||||
'</svg>';
|
||||
}
|
||||
var FLOWS = [
|
||||
['Morning check (≈1 min)', ['<b>Dashboard</b> opens on “Needs you”', 'Review 3 → <b>Use</b> the same-color suggestion', 'Badge on <b>Prints</b> drops as each one clears'], '01-dashboard.html', 'Start on the Dashboard'],
|
||||
['Weigh the low spools', ['<b>Filament → Low</b> view', '<b>Start weighing</b>: type the scale reading', 'See before → after, then <b>Save & weigh next</b>'], '02-filament.html?f=low', 'Open low stock'],
|
||||
['Load a spool into a printer', ['<b>Printers</b> → A1 AMS Lite → <b>+ Load</b>', 'Filter stored spools, pick one', 'Toast confirms; the holder fill updates'], '04-printers.html#printer=3', 'Open A1'],
|
||||
['Find anything', ['Press <b>/</b> or <b>⌘K</b> on any page', 'Type <i>copper</i>, <i>#47</i> or <i>weigh black</i>', '↵ opens the panel right there'], '02-filament.html', 'Try it on Filament'],
|
||||
['Someone changed it first (409)', ['Spool #47 → <b>Edit</b>', 'Demo state: <b>409 conflict</b>', 'Side-by-side; reapply your change, or discard it'], '02-filament.html#spool=47&tab=edit', 'Open the editor'],
|
||||
['A spool with no empty spool weight', ['<b>Empty spool weight unverified</b> view (7)', 'Weigh tab explains why it can\'t weigh yet', 'Pick a spool type, or weigh the empty spool'], '02-filament.html?f=tare#spool=44&tab=weigh', 'Open #44'],
|
||||
['A printer drops off', ['<b>Printers</b>: A1 sits first with a warning border', 'Toggle <b>dev today / + feature branch</b>', 'What happens on reconnect is spelled out'], '04-printers.html#printer=3', 'Open Printers'],
|
||||
['Label new spools', ['<b>Labels</b> → Needs a label (4)', 'Previews at real 51 × 21 mm proportions', '<b>Download</b>: they\'re marked printed on success'], '06-labels.html', 'Open Labels'],
|
||||
['Load spools at the printer (phone)', ['Scan the <b>AMS Lite</b> holder label', 'Scan spool labels: each loads, returns or moves', 'A full holder says so; nothing changes'], '09-scan-holder.html?c=5', 'Open scan session'],
|
||||
['Weigh from a label (phone)', ['Scan a spool label with no session active', 'See where it lives; type the scale reading', 'Before → after, then <b>Save</b>'], '10-scan-spool.html?s=41', 'Open #41'],
|
||||
['Fix a wrong attribution', ['Print <b>Benchy_x4</b> → <b>Change spool</b>', 'See: 46 g goes back to Elegoo Black', 'Pick the right spool; cost re-rates'], '03-prints.html?tab=all#print=211', 'Open Benchy_x4'],
|
||||
['Add or remove a printer', ['<b>Printers → + Add printer</b>: brand filters models', 'Remove asks for the exact name', 'Refused while its holders still hold spools'], '04-printers.html#premove=3', 'Try removing A1'],
|
||||
['Palette commands', ['<b>/</b> then <i>load copper into p1s ext</i>', 'or <i>attribute bracket to black</i>', '↵ does it; a toast confirms'], '02-filament.html?palette=load%20', 'Open palette'],
|
||||
['When the data is not live', ['Mockup pill (bottom left) → <b>Simulate polling failure</b>', 'Header turns amber, banner says the data\'s age', '<b>Replay first load</b> shows skeletons'], '01-dashboard.html', 'Open Dashboard']
|
||||
];
|
||||
function render() {
|
||||
return '<header class="page-head"><div><h1>Flow map</h1><p class="sum">Iteration 3 · every screen, how they connect, and <b>' + FLOWS.length + ' flows</b> to click through</p></div><div class="actions"><a class="btn" href="../../iteration-2/screens/index.html">← Iteration 2</a><a class="btn btn-primary" href="devices.html">Devices →</a></div></header>' +
|
||||
'<section class="map nm-raised" aria-label="Flow diagram">' + svg() + '</section>' +
|
||||
'<section class="stack" style="gap:12px" aria-labelledby="sc-h"><h2 class="section" id="sc-h" style="margin:0">All screens · <a href="devices.html">see them on phone and tablet →</a></h2><div class="flows">' +
|
||||
[['01-dashboard.html', 'Dashboard', 'Needs you · stock · recent prints'], ['02-filament.html', 'Filament', 'views · weigh session · panel'], ['03-prints.html', 'Prints', 'attention queue · history'], ['04-printers.html', 'Printers', 'holders · load · admin panels'], ['05-lists.html', 'Lists', 'the 5 name parts + spool types'], ['06-labels.html', 'Labels', 'spool + holder QR labels'], ['07-access.html', 'Access', 'people · sessions · API tokens'], ['08-system.html', 'System', 'weekly report · audit · device'], ['09-scan-holder.html', 'Scan: holder', 'phone · load/return/move'], ['10-scan-spool.html', 'Scan: spool', 'phone · weigh from a label']].map(function (s2) { return '<a class="flow nm-raised" style="text-decoration:none;color:inherit;padding:14px 16px" href="' + s2[0] + '"><h3>' + s2[1] + ' →</h3><span class="small muted">' + s2[2] + '</span></a>'; }).join('') + '</div></section>' +
|
||||
'<section aria-labelledby="fl-h" class="stack" style="gap:12px"><h2 class="section" id="fl-h" style="margin:0">Try these flows</h2><div class="flows">' + FLOWS.map(function (f) { return '<article class="flow nm-raised"><h3>' + f[0] + '</h3><ol>' + f[1].map(function (s) { return '<li>' + s + '</li>'; }).join('') + '</ol><a class="btn btn-sm" href="' + f[2] + '">' + f[3] + ' →</a></article>'; }).join('') + '</div></section>' +
|
||||
'<section class="compare" aria-label="What changed"><div class="nm-inset"><h3>Iteration 1</h3><ul><li>“Prints” and “All prints” were dead ends</li><li>Edit left the list for a separate page</li><li>A spool in an AMS slot or low-stock list went nowhere</li><li>Every data point was a loud chip, so a legend was needed</li><li>No freshness, loading or error state</li><li>Invented “slot 2” positions the database doesn\'t store</li></ul></div>' +
|
||||
'<div class="nm-raised"><h3>Iteration 2</h3><ul><li>Every spool, print and holder opens one shared panel, deep-linkable</li><li>Dashboard leads with what needs doing, each item one click from the fix</li><li>Chips only for state; provenance is quiet text with a tooltip</li><li>Palette, badges, toasts, j/k stepping, Esc/back to close</li><li>“Live · updated 3s ago”, with an honest offline state</li><li>Holders show fill (“3 / 4”), not tray positions</li></ul></div></section>';
|
||||
}
|
||||
X.init({ name: 'map' }, render);
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user