Files
Joshua KingandClaude Opus 5 d9ed1898b9 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>
2026-09-19 11:31:50 -04:00

183 lines
16 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>