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>
183 lines
16 KiB
HTML
183 lines
16 KiB
HTML
<style>
|
||
.filters { padding: var(--space-4) var(--space-5); display: grid; grid-template-columns: minmax(220px, 2fr) repeat(3, minmax(130px, 1fr)); gap: var(--space-3) var(--space-4); align-items: end; }
|
||
.filters .field { display: flex; flex-direction: column; gap: 6px; }
|
||
.filters label > span, .lbl-t { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps); color: var(--text-secondary); font-weight: var(--fw-bold); }
|
||
.filters .toggles { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; }
|
||
.search { position: relative; }
|
||
.search svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-secondary); pointer-events: none; }
|
||
.search .input { padding-left: 36px; }
|
||
.name-main { font-weight: var(--fw-bold); }
|
||
.name-parts { display: block; font-size: var(--fs-caption); color: var(--text-secondary); }
|
||
.name-parts .opt { opacity: 1; font-style: italic; }
|
||
.colorcell { display: flex; gap: var(--space-3); align-items: center; }
|
||
.rem { min-width: 150px; }
|
||
.rem .track { margin-top: 5px; height: 8px; }
|
||
.wt { line-height: var(--lh-snug); }
|
||
.wt .k { font-size: var(--fs-caption); color: var(--text-secondary); text-transform: uppercase; letter-spacing: var(--tracking-caps); }
|
||
.loc { min-width: 140px; font-size: 13px; line-height: var(--lh-snug); }
|
||
.fn { display: block; font-size: 13px; }
|
||
.acts { display: flex; gap: var(--space-2); justify-content: flex-end; }
|
||
.low-row td:first-child { box-shadow: inset 3px 0 0 var(--status-warning); }
|
||
.cards { display: none; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: var(--space-5); }
|
||
.view-cards .cards { display: grid; }
|
||
.view-cards .table-card { display: none; }
|
||
.scard { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-3); }
|
||
.scard .head { display: flex; gap: var(--space-3); align-items: center; }
|
||
.scard dl { display: grid; grid-template-columns: auto 1fr; gap: 2px var(--space-3); margin: 0; font-size: var(--fs-label); }
|
||
.scard dt { color: var(--text-secondary); }
|
||
.scard dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
|
||
.pager { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-4); }
|
||
dialog.confirm { padding: 0; border: 0; background: transparent; color: inherit; max-width: 480px; width: calc(100% - 32px); }
|
||
dialog.confirm::backdrop { background: rgba(10, 10, 14, 0.55); }
|
||
dialog.confirm .nm-dialog { padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-4); }
|
||
dialog.confirm h2 { margin: 0; font-size: var(--fs-h3); }
|
||
.empty-note { padding: var(--space-8); text-align: center; }
|
||
@media (max-width: 640px) { #view:not(.view-table) .table-card { display: none; } #view:not(.view-table) .cards { display: grid; } }
|
||
@media (max-width: 900px) { .filters { grid-template-columns: 1fr 1fr; } .filters .search-f { grid-column: 1 / -1; } }
|
||
</style>
|
||
|
||
<header class="page-head">
|
||
<div>
|
||
<h1>Filament</h1>
|
||
<p class="muted"><b class="num">105</b> spools · <b>18</b> brands · <b class="num">95.72 kg</b> net · <b class="num">$1,721.18</b></p>
|
||
</div>
|
||
<div class="actions">
|
||
<span class="small muted">Labels outstanding: <b>12</b></span>
|
||
<button class="btn" type="button">Download unlabeled labels</button>
|
||
<button class="btn" type="button">Manage lists</button>
|
||
<a class="btn btn-primary" href="03-spool-form.html#add">+ Add spool</a>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="filters nm-raised" aria-label="Search and filter">
|
||
<label class="field search-f"><span>Search</span>
|
||
<span class="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>
|
||
<input class="input" id="q" type="search" placeholder="Brand, color, material, location…" autocomplete="off">
|
||
</span>
|
||
</label>
|
||
<label class="field"><span>Brand</span>
|
||
<select class="select" id="brand"><option value="">All brands (105)</option><option>Sunlu</option><option>Elegoo</option><option>Overture</option><option>TECSONAR</option><option>AMOLEN</option><option>JAYO</option><option>iSANMATE</option></select></label>
|
||
<label class="field"><span>Material</span>
|
||
<select class="select" id="mat"><option value="">All materials (105)</option><option>PLA</option><option>PETG</option><option>ASA</option><option>TPU</option><option>PA6</option><option>ABS</option></select></label>
|
||
<label class="field"><span>Sort</span>
|
||
<select class="select" id="sort"><option value="asc">Least remaining</option><option value="desc">Most remaining</option><option value="brand">Brand A–Z</option><option value="value">Highest value</option></select></label>
|
||
<div class="toggles">
|
||
<button class="btn btn-sm" type="button" aria-pressed="false" id="t-low">Low stock only</button>
|
||
<button class="btn btn-sm" type="button" aria-pressed="false" id="t-store">In storage only</button>
|
||
<button class="btn btn-sm" type="button" aria-pressed="false" id="t-tare">Tare unknown only</button>
|
||
<span class="spacer"></span>
|
||
<div class="seg" role="group" aria-label="Inventory view">
|
||
<button type="button" aria-pressed="true" data-view="table">Table</button>
|
||
<button type="button" aria-pressed="false" data-view="cards">Cards</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div id="view">
|
||
<section class="card nm-raised table-card" aria-label="Filament inventory">
|
||
<div class="table-wrap">
|
||
<table class="tbl">
|
||
<thead><tr>
|
||
<th>Spool <span class="th-note tiny muted" style="text-transform:none;letter-spacing:0">color · brand speed material type finish</span></th>
|
||
<th class="r">Remaining <abbr title="Net filament only">net</abbr></th>
|
||
<th class="r">Empty spool (tare)</th>
|
||
<th class="r">Gross <abbr title="What the scale reads: tare + remaining">on scale</abbr></th>
|
||
<th class="r">Value</th><th>Location</th><th class="r">Actions</th>
|
||
</tr></thead>
|
||
<tbody id="rows"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="pager"><span class="small muted" id="count"></span>
|
||
<div class="actions"><button class="btn btn-sm" disabled type="button">← Previous</button><span class="small">Page 1 of 9</span><button class="btn btn-sm" type="button">Next →</button></div></div>
|
||
</section>
|
||
<div class="cards" id="cards" aria-label="Filament inventory cards"></div>
|
||
</div>
|
||
|
||
<dialog class="confirm" id="confirm" aria-labelledby="confirm-h">
|
||
<form method="dialog" class="nm-dialog">
|
||
<h2 id="confirm-h">Delete <span id="confirm-name"></span>?</h2>
|
||
<p style="margin:0">This removes the spool from inventory. <b>Its print history is kept</b>: prints that used it keep their grams and cost, and only lose the link to this spool.</p>
|
||
<p class="small muted" style="margin:0">This can't be undone. If you just ran the spool out, you can also set remaining to 0 g and keep the record.</p>
|
||
<div class="actions" style="justify-content:flex-end">
|
||
<button class="btn" value="cancel" autofocus>Keep spool</button>
|
||
<button class="btn btn-danger" value="delete">Delete spool</button>
|
||
</div>
|
||
</form>
|
||
</dialog>
|
||
|
||
<script>
|
||
var SPOOLS = [
|
||
{c:['#1f3fbf'], color:'Klein Blue', brand:'Sunlu', speed:null, mat:'PLA', type:'+ 2.0', fin:'Basic', rem:64, tot:250, tare:60, tsrc:'community', tfrom:'Sunlu — 250 g mini', kg:25.20, loc:'Shelf B2', store:true},
|
||
{c:['#b87333'], color:'Copper', brand:'Sunlu', speed:null, mat:'PLA', type:'+', fin:'Silk', rem:97, tot:1000, tare:140, tsrc:'community', tfrom:'Sunlu — 1 kg plastic', kg:19.99, loc:'P1S · AMS 1 · slot 2', store:false},
|
||
{c:['#141414'], color:'Black', brand:'Elegoo', speed:'Rapid', mat:'PETG', type:null, fin:'Basic', rem:142, tot:1000, tare:154, tsrc:'manufacturer', tfrom:'Elegoo — 1 kg cardboard', kg:11.74, loc:'H2C · AMS 1 · slot 1', store:false},
|
||
{c:['#ece8df'], color:'Rock White', brand:'Overture', speed:null, mat:'PLA', type:null, fin:'Marble', rem:171, tot:1000, tare:155, tsrc:'community', tfrom:'Overture — cardboard, PLA lines', kg:17.99, loc:'Shelf C', store:true},
|
||
{c:['#161616','#c0232f'], color:'Black Red', brand:'TECSONAR', speed:null, mat:'PLA', type:null, fin:'Matte', rem:188, tot:1000, tare:null, kg:15.99, loc:'Shelf C', store:true},
|
||
{c:['#101010'], color:'Black', brand:'JAYO', speed:'Rapid', mat:'PETG', type:null, fin:'Matte', rem:655, tot:1000, tare:null, kg:12.72, loc:'P1S · AMS 1 · slot 4', store:false},
|
||
{c:['#8a8f7a','#e9e4d8'], color:'Marble', brand:'iSANMATE', speed:null, mat:'ASA', type:null, fin:'Marble', rem:430, tot:1000, tare:180, tsrc:'estimated', tfrom:'Generic — 1 kg spool (assumed)', kg:25.99, loc:'Dry box 1', store:true},
|
||
{c:['#1b1b1b'], color:'Black', brand:'Sunlu', speed:null, mat:'PETG', type:'CF', fin:'Matte', rem:812, tot:1000, tare:262, tsrc:'weighed', tfrom:'this spool, weighed 14 Sep', kg:22.79, loc:'A1 · AMS Lite · slot 1', store:false, weighed:true},
|
||
{c:['#b8864b'], color:'Natural Wood', brand:'Sunlu', speed:null, mat:'PLA', type:null, fin:'Wood', rem:1000, tot:1000, tare:35, tsrc:'estimated', tfrom:'Refill — bare cardboard core', kg:16.50, loc:'Shelf A3', store:true},
|
||
{c:['#a3402c'], color:'Brick Red', brand:'Sunlu', speed:'High Speed', mat:'PLA', type:null, fin:'Marble', rem:1000, tot:1000, tare:140, tsrc:'community', tfrom:'Sunlu — 1 kg plastic', kg:17.50, loc:'Shelf A2', store:true},
|
||
{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', tfrom:'Generic — 1 kg spool (assumed)', kg:23.99, loc:'Shelf D', store:true},
|
||
{c:['#1d4fa8'], color:'Blue', brand:'Elegoo', speed:'Rapid', mat:'PETG', type:null, fin:'Basic', rem:1000, tot:1000, tare:154, tsrc:'manufacturer', tfrom:'Elegoo — 1 kg cardboard', kg:17.99, loc:'Shelf A1', store:true}
|
||
];
|
||
var LOW = 200;
|
||
function esc(s){return String(s).replace(/[&<>"]/g,function(ch){return {'&':'&','<':'<','>':'>','"':'"'}[ch]})}
|
||
function sw(c){return '<span class="swatch" title="'+esc(c.join(' / '))+'">'+c.map(function(h){return '<i style="background:'+h+'"></i>'}).join('')+'</span>'}
|
||
function fullName(s){return [s.brand,s.speed,s.mat,s.type,s.fin].filter(Boolean).join(' ')}
|
||
function parts(s){
|
||
function p(v,req){return v?esc(v):'<span class="opt" title="optional, not set">–</span>'}
|
||
return '<span class="name-parts" title="brand · speed · material · type · finish">'+[p(s.brand),p(s.speed),p(s.mat),p(s.type),p(s.fin)].join(' · ')+'</span>'}
|
||
var SRC = {manufacturer:'<span class="chip chip-ok">manufacturer</span>', community:'<span class="chip chip-info">community</span>', estimated:'<span class="chip chip-warn chip-dashed">estimated</span>', weighed:'<span class="chip chip-accent">this spool</span>'};
|
||
function tareCell(s){
|
||
if (s.tare == null) return '<span class="chip chip-warn chip-dashed" title="No empty-spool weight is known. Shown as unknown, never 0.">? unknown</span><span class="name-parts">weigh the bare spool</span>';
|
||
return '<span class="wt"><b class="num">'+s.tare+' g</b><br>'+SRC[s.tsrc]+'<span class="name-parts">'+esc(s.tfrom)+'</span></span>'}
|
||
function grossCell(s){
|
||
if (s.tare == null) return '<span class="muted" title="Gross = tare + remaining. Tare is unknown, so gross is too.">unknown</span>';
|
||
return '<b class="num">'+(s.tare+s.rem).toLocaleString()+' g</b><span class="name-parts">'+s.tare+' + '+s.rem+'</span>'}
|
||
function remCell(s){
|
||
var pct=Math.max(0,Math.min(100,s.rem/s.tot*100)); var low=s.rem<LOW;
|
||
return '<div class="rem"><b class="num" style="'+(low?'color:var(--status-warning)':'')+'">'+(low?'⚠ ':'')+s.rem+'</b> <span class="muted">of '+s.tot+' g</span>'+
|
||
(s.weighed?'<span class="name-parts">weighed on a scale</span>':'')+
|
||
'<div class="track'+(low?' is-low':'')+'" role="progressbar" aria-valuenow="'+Math.round(pct)+'" aria-valuemin="0" aria-valuemax="100" aria-label="'+esc(s.color)+' remaining"><span style="width:'+pct+'%"></span></div></div>'}
|
||
function value(s){return (s.rem/1000*s.kg)}
|
||
function render(){
|
||
var q=document.getElementById('q').value.trim().toLowerCase();
|
||
var b=document.getElementById('brand').value, m=document.getElementById('mat').value, sort=document.getElementById('sort').value;
|
||
var low=document.getElementById('t-low').getAttribute('aria-pressed')==='true';
|
||
var store=document.getElementById('t-store').getAttribute('aria-pressed')==='true';
|
||
var tare=document.getElementById('t-tare').getAttribute('aria-pressed')==='true';
|
||
var list=SPOOLS.filter(function(s){
|
||
var hay=(fullName(s)+' '+s.color+' '+s.loc).toLowerCase();
|
||
return (!q||hay.indexOf(q)>=0)&&(!b||s.brand===b)&&(!m||s.mat===m)&&(!low||s.rem<LOW)&&(!store||s.store)&&(!tare||s.tare==null)});
|
||
list.sort(function(a,z){return sort==='desc'?z.rem-a.rem:sort==='brand'?a.brand.localeCompare(z.brand):sort==='value'?value(z)-value(a):a.rem-z.rem});
|
||
var rows=list.map(function(s,i){var idx=SPOOLS.indexOf(s);
|
||
return '<tr class="'+(s.rem<LOW?'low-row':'')+'"><td><div class="colorcell">'+sw(s.c)+'<span><span class="name-main">'+esc(s.color)+' <span class="muted tiny">#'+(40+idx)+'</span></span><span class="fn">'+esc(fullName(s))+'</span>'+parts(s)+'</span></div></td>'+
|
||
'<td class="r">'+remCell(s)+'</td><td class="r">'+tareCell(s)+'</td><td class="r">'+grossCell(s)+'</td>'+
|
||
'<td class="r num">$'+value(s).toFixed(2)+'<span class="name-parts">$'+s.kg.toFixed(2)+'/kg</span></td>'+
|
||
'<td class="loc">'+(s.store?'<span class="name-parts">In storage</span>':'<span class="chip chip-accent">loaded</span><br>')+esc(s.loc)+'</td>'+
|
||
'<td class="r"><div class="acts"><a class="btn btn-sm" href="03-spool-form.html" aria-label="Edit '+esc(s.color)+', '+esc(s.brand)+'">Edit</a>'+
|
||
'<button class="btn btn-sm btn-danger" type="button" data-del="'+idx+'" aria-label="Delete '+esc(s.color)+', '+esc(s.brand)+'">Delete</button></div></td></tr>'}).join('');
|
||
document.getElementById('rows').innerHTML = rows || '<tr><td colspan="7"><div class="empty-note muted">No spools match that filter. <button class="btn btn-sm" type="button" id="clear">Clear filters</button></div></td></tr>';
|
||
document.getElementById('cards').innerHTML = list.map(function(s){var idx=SPOOLS.indexOf(s);
|
||
return '<article class="scard nm-raised"><div class="head">'+sw(s.c)+'<div><h3 style="margin:0;font-size:15px">'+esc(s.color)+'</h3><span class="name-parts">'+esc(fullName(s))+'</span></div></div>'+remCell(s)+
|
||
'<dl><dt>Tare</dt><dd>'+tareCell(s)+'</dd><dt>Gross</dt><dd>'+grossCell(s)+'</dd><dt>Value</dt><dd>$'+value(s).toFixed(2)+'</dd><dt>Location</dt><dd>'+esc(s.loc)+'</dd></dl>'+
|
||
'<div class="acts"><a class="btn btn-sm" href="03-spool-form.html">Edit</a><button class="btn btn-sm btn-danger" type="button" data-del="'+idx+'">Delete</button></div></article>'}).join('');
|
||
document.getElementById('count').textContent = 'Showing '+list.length+' of 105 spools (sample page)';
|
||
}
|
||
document.addEventListener('input', function(e){ if (e.target.closest('.filters')) render(); });
|
||
document.addEventListener('click', function(e){
|
||
var t=e.target.closest('#t-low,#t-store,#t-tare');
|
||
if (t){t.setAttribute('aria-pressed', String(t.getAttribute('aria-pressed')!=='true')); render(); return;}
|
||
var v=e.target.closest('[data-view]');
|
||
if (v){document.querySelectorAll('[data-view]').forEach(function(b){b.setAttribute('aria-pressed', String(b===v))});
|
||
document.getElementById('view').className = v.getAttribute('data-view')==='cards'?'view-cards':'view-table'; return;}
|
||
var d=e.target.closest('[data-del]');
|
||
if (d){var s=SPOOLS[+d.getAttribute('data-del')]; document.getElementById('confirm-name').textContent=s.color+' · '+fullName(s);
|
||
var dlg=document.getElementById('confirm'); if (dlg.showModal) dlg.showModal(); return;}
|
||
if (e.target.id==='clear'){document.getElementById('q').value='';['t-low','t-store','t-tare'].forEach(function(id){document.getElementById(id).setAttribute('aria-pressed','false')});
|
||
document.getElementById('brand').value='';document.getElementById('mat').value='';render();}
|
||
});
|
||
document.addEventListener('DOMContentLoaded', function(){ render(); if (matchMedia('(max-width: 640px)').matches) document.querySelectorAll('[data-view]').forEach(function(b){b.setAttribute('aria-pressed', String(b.getAttribute('data-view')==='cards'))}); });
|
||
</script>
|