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:
Joshua King
2026-09-19 11:31:50 -04:00
co-authored by Claude Opus 5
parent 9ba0d409c8
commit d9ed1898b9
67 changed files with 33632 additions and 1 deletions
+176
View File
@@ -0,0 +1,176 @@
<style>
.dash { display: grid; grid-template-columns: minmax(0, 2.4fr) minmax(280px, 1fr); gap: var(--space-6); align-items: start; }
.fleet { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-4); }
.fleet a { text-decoration: none; color: inherit; padding: var(--space-4); display: flex; flex-direction: column; gap: 6px; border-radius: var(--radius-md); }
.fleet a:hover { border-color: var(--text-accent); }
.fleet .name { font-weight: var(--fw-bold); font-size: 13px; }
.mat { display: grid; grid-template-columns: 56px minmax(0, 1fr) 84px; gap: var(--space-3); align-items: center; font-size: 13px; }
.mat .track { height: 12px; }
.low li { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; padding: var(--space-3) 0; border-bottom: 1px solid var(--border-hairline); }
.low li:last-child { border-bottom: 0; }
.low ul { list-style: none; margin: 0; padding: 0; }
.low .nm { font-weight: var(--fw-bold); font-size: 13px; line-height: var(--lh-snug); }
.low .sub { font-size: var(--fs-caption); color: var(--text-secondary); line-height: var(--lh-snug); }
.low .g { text-align: right; font-variant-numeric: tabular-nums; line-height: var(--lh-snug); }
.low .g strong { color: var(--status-warning); font-size: 15px; }
.job { font-weight: var(--fw-bold); }
.job-sub { display: block; font-size: var(--fs-caption); color: var(--text-secondary); font-weight: var(--fw-regular); }
.dur-ub { color: var(--status-warning); font-weight: var(--fw-bold); }
.legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: var(--space-3) var(--space-5); font-size: var(--fs-label); color: var(--text-secondary); margin-top: var(--space-4); padding: var(--space-4); }
.legend div { display: flex; gap: var(--space-2); align-items: baseline; }
@media (max-width: 1280px) { .dash { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 640px) { .fleet { grid-template-columns: 1fr; } }
</style>
<header class="page-head">
<div>
<h1>Dashboard</h1>
<p class="muted">Friday 18 September · refreshes every 10 s · last update 09:41:07</p>
</div>
<div class="actions">
<a class="btn" href="02-filament.html">Open inventory</a>
<a class="btn btn-primary" href="03-spool-form.html">+ Add spool</a>
</div>
</header>
<section aria-labelledby="stock-h">
<h2 class="section" id="stock-h">Stock</h2>
<div class="stats">
<div class="stat nm-raised"><span class="stat-label">Spools</span><span class="stat-value">105</span><span class="stat-foot">18 brands · 6 materials</span></div>
<div class="stat nm-raised"><span class="stat-label">Filament left <abbr title="Net filament, not including the empty spools">net</abbr></span><span class="stat-value">95.72 kg</span><span class="stat-foot">filament only, empty spools excluded</span></div>
<div class="stat nm-raised"><span class="stat-label">Stock value</span><span class="stat-value">$1,721.18</span><span class="stat-foot">remaining net g × each spool’s $/kg</span></div>
<div class="stat nm-raised warn"><span class="stat-label">Running low</span><span class="stat-value">⚠ 5</span><span class="stat-foot">under 200 g net each</span></div>
</div>
</section>
<div class="dash">
<div class="stack" style="gap: var(--space-6)">
<section class="card nm-raised" aria-labelledby="prints-h">
<div class="row" style="margin-bottom: var(--space-3)">
<h2 class="section" id="prints-h" style="margin:0">Recent prints</h2>
<span class="spacer"></span>
<a href="#" class="small">All prints →</a>
</div>
<div class="table-wrap">
<table class="tbl">
<thead><tr>
<th>Job</th><th>Status</th><th class="r">Duration</th>
<th class="r">Filament</th><th>Weight source <span class="th-note"><span class="chip chip-warn chip-dashed" title="Stored in print.weight_source; GET /api/prints does not return it yet">API gap</span></span></th><th class="r">Cost</th>
</tr></thead>
<tbody>
<tr>
<td><span class="job">Gridfinity_bins_3x2</span><span class="job-sub">P1S - Prints Charming · 08:37</span></td>
<td><span class="chip chip-accent">● Running</span></td>
<td class="r">1h 04m <span class="job-sub">so far</span></td>
<td class="r muted">—</td>
<td><span class="small muted">weighed when it ends</span></td>
<td class="r muted">pending</td>
</tr>
<tr>
<td><span class="job">Benchy_x4</span><span class="job-sub">H2C - Constipation Orion · 02:15</span></td>
<td><span class="chip chip-ok">✓ Finished</span></td>
<td class="r"><span class="dur-ub" title="The connection dropped once during this print. That time is inside the duration and nothing watched it, so this is an upper bound, not a measurement.">≤ 3h 12m</span>
<span class="job-sub"><span class="chip chip-warn chip-dashed">upper bound · 1 gap</span></span></td>
<td class="r">46 g</td>
<td><span class="chip chip-info">Sliced</span> <span class="job-sub">from this run's 3MF</span></td>
<td class="r">$0.53</td>
</tr>
<tr>
<td><span class="job">Sign_CubeCraft</span><span class="job-sub">H2C - Constipation Orion · yesterday 21:48 · 2 colors</span></td>
<td><span class="chip chip-ok">✓ Finished</span></td>
<td class="r">2h 05m</td>
<td class="r">64 g <span class="job-sub">41 g + 23 g</span></td>
<td><span class="chip chip-info">Sliced</span></td>
<td class="r">$0.92</td>
</tr>
<tr>
<td><span class="job">Cable_clips</span><span class="job-sub">A1 - Jordyns Layer Slayer · yesterday 19:30</span></td>
<td><span class="chip chip-warn chip-dashed">? Unknown</span></td>
<td class="r"><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="job-sub">end not seen</span></td>
<td class="r">12 g</td>
<td><span class="chip chip-neutral">Typed</span> <span class="job-sub">someone weighed it</span></td>
<td class="r">$0.14</td>
</tr>
<tr>
<td><span class="job">Lid_v3</span><span class="job-sub">P1S - Prints Charming · yesterday 16:02</span></td>
<td><span class="chip chip-err">✕ Failed</span></td>
<td class="r">47m</td>
<td class="r">118 g <span class="job-sub">full job weight</span></td>
<td><span class="chip chip-neutral">Typed</span></td>
<td class="r">$1.38</td>
</tr>
<tr>
<td><span class="job">Planter_small</span><span class="job-sub">A1 - Jordyns Layer Slayer · 16 Sep 10:20</span></td>
<td><span class="chip chip-ok">✓ Finished</span></td>
<td class="r">5h 40m</td>
<td class="r">212 g</td>
<td><span class="chip chip-info chip-dashed">Recalled</span> <span class="job-sub">remembered for this job</span></td>
<td class="r">$2.49</td>
</tr>
<tr>
<td><span class="job">Test_cube</span><span class="job-sub">P1S - Prints Charming · 7 Aug 14:11</span></td>
<td><span class="chip chip-ok">✓ Finished</span></td>
<td class="r">31m <span class="job-sub" title="This print predates gap counting (connection_gaps is null), so nobody asked whether the connection dropped.">gaps not counted</span></td>
<td class="r">9 g</td>
<td><span class="chip chip-warn chip-dashed">Unknown</span> <span class="job-sub">predates provenance</span></td>
<td class="r">$0.10</td>
</tr>
</tbody>
</table>
</div>
<div class="legend nm-inset" aria-label="How to read this table">
<div><span class="dur-ub">≤</span><span><b>Upper bound.</b> The connection dropped during the print, so the time includes a gap nobody watched.</span></div>
<div><span class="chip chip-neutral">Typed</span><span>A measurement. It survives an identity correction.</span></div>
<div><span class="chip chip-info">Sliced</span><span>Read from the job's 3MF over FTPS.</span></div>
<div><span class="chip chip-info chip-dashed">Recalled</span><span>A copy of a weight remembered for the same gcode path.</span></div>
<div><span class="chip chip-warn chip-dashed">Unknown</span><span>Written before the source was recorded. Neither path will move it.</span></div>
</div>
</section>
</div>
<aside class="stack" style="gap: var(--space-6)" aria-label="Stock detail">
<section class="card nm-raised low" aria-labelledby="low-h">
<h2 class="section" id="low-h">⚠ Running low <span class="muted">· under 200 g</span></h2>
<ul>
<li><span class="swatch"><i style="background:#1f3fbf"></i></span>
<span><span class="nm">Klein Blue</span><br><span class="sub">Sunlu PLA + 2.0 Basic · 250 g mini</span></span>
<span class="g"><strong>64 g</strong><br><span class="sub">gross 124 g</span></span></li>
<li><span class="swatch"><i style="background:#b87333"></i></span>
<span><span class="nm">Copper</span><br><span class="sub">Sunlu PLA + Silk</span></span>
<span class="g"><strong>97 g</strong><br><span class="sub">gross 237 g</span></span></li>
<li><span class="swatch"><i style="background:#141414"></i></span>
<span><span class="nm">Black</span><br><span class="sub">Elegoo Rapid PETG Basic</span></span>
<span class="g"><strong>142 g</strong><br><span class="sub">gross 296 g</span></span></li>
<li><span class="swatch"><i style="background:#ece8df"></i></span>
<span><span class="nm">Rock White</span><br><span class="sub">Overture PLA Marble</span></span>
<span class="g"><strong>171 g</strong><br><span class="sub">gross 326 g</span></span></li>
<li><span class="swatch"><i style="background:#161616"></i><i style="background:#c0232f"></i></span>
<span><span class="nm">Black Red</span><br><span class="sub">TECSONAR PLA Matte</span></span>
<span class="g"><strong>188 g</strong><br><span class="sub">gross <span class="chip chip-warn chip-dashed" title="No tare is known for this spool, so gross weight cannot be worked out. It is never shown as 0.">tare unknown</span></span></span></li>
</ul>
<p class="tiny muted" style="margin: var(--space-3) 0 0">Net grams are filament only. Gross = empty-spool tare + net, what a scale reads.</p>
</section>
<section class="card nm-raised" aria-labelledby="mat-h">
<h2 class="section" id="mat-h">Net filament by material</h2>
<div class="stack">
<div class="mat"><b>PLA</b><div class="track"><span style="width:100%"></span></div><span class="r num">48.12 kg</span></div>
<div class="mat"><b>PETG</b><div class="track"><span style="width:68.8%"></span></div><span class="r num">33.10 kg</span></div>
<div class="mat"><b>ASA</b><div class="track"><span style="width:19.7%"></span></div><span class="r num">9.50 kg</span></div>
<div class="mat"><b>TPU</b><div class="track"><span style="width:6.2%"></span></div><span class="r num">3.00 kg</span></div>
<div class="mat"><b>PA6</b><div class="track"><span style="width:2.1%"></span></div><span class="r num">1.00 kg</span></div>
<div class="mat"><b>ABS</b><div class="track"><span style="width:2.1%"></span></div><span class="r num">1.00 kg</span></div>
</div>
<p class="tiny muted" style="margin: var(--space-3) 0 0">55 PLA · 34 PETG · 10 ASA · 3 TPU · 2 PA6 · 1 ABS spools</p>
</section>
<section class="card nm-raised" aria-labelledby="fleet-h">
<h2 class="section" id="fleet-h">Printers</h2>
<div class="fleet" style="grid-template-columns: 1fr">
<a class="nm-raised-sm" href="04-printers.html"><span class="name">P1S - Prints Charming</span><span><span class="chip chip-accent">● Printing</span> <span class="small muted">Gridfinity_bins_3x2</span></span></a>
<a class="nm-raised-sm" href="04-printers.html"><span class="name">H2C - Constipation Orion</span><span><span class="chip chip-neutral">Idle</span> <span class="small muted">last: Benchy_x4</span></span></a>
<a class="nm-raised-sm" href="04-printers.html" style="border-color: var(--status-warning)"><span class="name">A1 - Jordyns Layer Slayer</span><span><span class="chip chip-warn">⚡ Connection lost</span> <span class="small muted">last contact 6m ago</span></span></a>
</div>
</section>
</aside>
</div>
+182
View File
@@ -0,0 +1,182 @@
<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>
+286
View File
@@ -0,0 +1,286 @@
<style>
.scenarios { padding: var(--space-4) var(--space-5); display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; }
.form-wrap { position: relative; border-radius: var(--radius-xl); padding: var(--space-6); background: repeating-linear-gradient(135deg, transparent 0 14px, var(--accent-wash) 14px 15px); }
.sheet { max-width: 880px; margin: 0 auto; padding: 0; overflow: hidden; }
.sheet-head { display: flex; gap: var(--space-4); align-items: flex-start; padding: var(--space-6) var(--space-6) var(--space-4); border-bottom: 1px solid var(--border-hairline); }
.sheet-head h2 { margin: 0; font-size: var(--fs-h2); }
.sheet-body { padding: var(--space-5) var(--space-6); display: flex; flex-direction: column; gap: var(--space-6); }
.sheet-foot { padding: var(--space-4) var(--space-6); border-top: 1px solid var(--border-hairline); display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; justify-content: flex-end; }
fieldset.sec { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-4); }
fieldset.sec > legend { padding: 0; margin-bottom: var(--space-3); font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); font-weight: var(--fw-bold); }
.fr { display: grid; gap: var(--space-4); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.field > .lab { display: flex; gap: var(--space-2); align-items: baseline; font-size: var(--fs-label); font-weight: var(--fw-bold); }
.field > .lab em { font-style: normal; font-weight: var(--fw-regular); color: var(--text-secondary); font-size: var(--fs-caption); }
.req { color: var(--text-accent); font-size: var(--fs-caption); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: var(--tracking-caps); }
.hint { font-size: var(--fs-caption); color: var(--text-secondary); line-height: var(--lh-snug); }
.has-error > .lab { color: var(--status-error); }
.has-error > .lab::before { content: "⚠"; }
.srv { font-size: var(--fs-caption); color: var(--text-secondary); }
.namebar { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
.slots { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-2); }
.slot { padding: 8px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-control); background: var(--surface-raised); box-shadow: var(--nm-raised-1); display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.slot .k { font-size: var(--fs-caption); color: var(--text-secondary); text-transform: uppercase; letter-spacing: var(--tracking-caps); }
.slot .v { font-weight: var(--fw-bold); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.slot.empty { border-style: dashed; box-shadow: none; background: transparent; }
.slot.empty .v { color: var(--text-secondary); font-weight: var(--fw-regular); font-style: italic; }
.fullname { font-size: var(--fs-h3); font-weight: var(--fw-display); }
.weights { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: var(--space-5); align-items: start; }
.scale { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-3); }
.stackbar { display: flex; height: 26px; border-radius: var(--radius-pill); overflow: hidden; border: 1px solid var(--border-control); box-shadow: var(--nm-inset-1); background: var(--surface-inset); }
.stackbar .t { background: repeating-linear-gradient(45deg, var(--border-strong) 0 5px, transparent 5px 10px); border-right: 2px solid var(--text-primary); }
.stackbar .f { background: var(--text-accent); }
.stackbar .u { background: transparent; }
.eq { display: grid; grid-template-columns: auto 1fr auto; gap: 4px var(--space-3); font-size: 13px; font-variant-numeric: tabular-nums; }
.eq .tot { border-top: 1px solid var(--border-strong); padding-top: 4px; font-weight: var(--fw-bold); }
.key { display: inline-block; width: 12px; height: 12px; border-radius: 3px; border: 1px solid var(--border-control); vertical-align: -1px; }
.colors-row { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.conflict, .errsum { padding: var(--space-5); border: 2px solid var(--status-warning); border-radius: var(--radius-lg); background: var(--wash-warning); display: flex; flex-direction: column; gap: var(--space-3); }
.errsum { border-color: var(--status-error); background: var(--wash-error); }
.conflict h3, .errsum h3 { margin: 0; font-size: var(--fs-body-lg); display: flex; gap: var(--space-2); align-items: center; }
.conflict h3 { color: var(--status-warning); } .errsum h3 { color: var(--status-error); }
.cmp th, .cmp td { padding: 8px 10px; }
.cmp .mine { color: var(--text-accent); font-weight: var(--fw-bold); }
.cmp .theirs { color: var(--status-warning); font-weight: var(--fw-bold); }
.specimen { padding: var(--space-6); }
.spec-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: var(--space-6) var(--space-5); align-items: start; }
.spec { display: flex; flex-direction: column; gap: var(--space-3); align-items: flex-start; }
.spec .cap { font-size: var(--fs-caption); color: var(--text-secondary); line-height: var(--lh-snug); }
.spec .cap b { color: var(--text-primary); text-transform: uppercase; letter-spacing: var(--tracking-caps); }
.btn.is-hover { border-color: var(--text-accent); }
[data-only] { display: none; }
body.s-conflict [data-only~="conflict"], body.s-errors [data-only~="errors"], body.s-add [data-only~="add"], body.s-tare [data-only~="tare"], body.s-edit [data-only~="edit"] { display: revert; }
body.s-conflict .fr[data-only~="conflict"], body.s-errors .fr[data-only~="errors"] { display: grid; }
body.s-conflict .conflict[data-only], body.s-errors .errsum[data-only] { display: flex; }
@media (max-width: 760px) { .weights { grid-template-columns: 1fr; } .slots { grid-template-columns: repeat(2, minmax(0, 1fr)); } .form-wrap { padding: var(--space-2); } .sheet-head, .sheet-body, .sheet-foot { padding-left: var(--space-4); padding-right: var(--space-4); } }
</style>
<header class="page-head">
<div>
<h1>Add / Edit spool</h1>
<p class="muted">Opens as a dialog over Filament. Pick a scenario to see each state.</p>
</div>
<a class="btn" href="02-filament.html">← Back to Filament</a>
</header>
<section class="scenarios nm-raised" aria-label="Scenario">
<span class="lbl-t small muted">Scenario</span>
<div class="seg" role="group" aria-label="Form scenario" id="scen">
<button type="button" data-s="conflict" aria-pressed="true">409 · changed since opened</button>
<button type="button" data-s="errors" aria-pressed="false">400 · field errors</button>
<button type="button" data-s="tare" aria-pressed="false">Tare unknown</button>
<button type="button" data-s="add" aria-pressed="false">Add new</button>
</div>
</section>
<div class="form-wrap">
<section class="sheet nm-dialog" role="dialog" aria-labelledby="form-h" aria-modal="false">
<div class="sheet-head">
<span class="swatch" style="width:40px;height:40px"><i style="background:#1b1b1b"></i></span>
<div style="flex:1;min-width:0">
<h2 id="form-h"><span data-only="conflict errors tare edit">Edit spool #47</span><span data-only="add">Add spool</span></h2>
<p class="small muted" style="margin:0"><span data-only="conflict errors tare edit">Opened 09:32 at version 4 · created 2 Aug · label printed 3 Aug</span><span data-only="add">New spools carry no version: nobody could have read one yet.</span></p>
</div>
<button class="btn btn-icon" type="button" aria-label="Close">✕</button>
</div>
<div class="sheet-body">
<!-- 409 -->
<div class="conflict" role="alert" data-only="conflict">
<h3>⚠ This spool changed since you opened it. Nothing was saved.</h3>
<p style="margin:0">Someone weighed it at <b>09:39</b>, after you opened this form at 09:32 (version 4 → 5). Saving now would have put the old remaining weight back and lost that measurement.</p>
<div class="table-wrap" style="margin:0;padding:0">
<table class="tbl cmp">
<thead><tr><th>Field</th><th class="r">When you opened (v4)</th><th class="r">Now (v5)</th><th class="r">Your edit</th></tr></thead>
<tbody>
<tr><td>Remaining <abbr title="net filament">net</abbr></td><td class="r num">860 g</td><td class="r num theirs">812 g · weighed</td><td class="r muted">not changed</td></tr>
<tr><td>Empty spool (tare)</td><td class="r num">262 g</td><td class="r num">262 g</td><td class="r muted">not changed</td></tr>
<tr><td>Location</td><td class="r">Shelf A3</td><td class="r">Shelf A3</td><td class="r mine">A1 · AMS Lite · slot 1</td></tr>
<tr><td>Cost per kg</td><td class="r num">$22.79</td><td class="r num">$22.79</td><td class="r muted">not changed</td></tr>
</tbody>
</table>
</div>
<p class="small" style="margin:0">Your only change doesn't overlap theirs, so it's safe to reapply it to the new version.</p>
<div class="actions">
<button class="btn btn-primary" type="button">Reload and reapply my location change</button>
<button class="btn" type="button">Reload and discard my edit</button>
</div>
<p class="srv" style="margin:0">Server said (409): <code>this spool changed after you loaded it — reload it and reapply your edit, so a newer weigh-in is not overwritten (you loaded version 4, it is now version 5)</code></p>
</div>
<!-- 400 summary -->
<div class="errsum" role="alert" data-only="errors">
<h3>⚠ 2 fields need fixing. Nothing was saved.</h3>
<ul style="margin:0;padding-left:20px" class="small">
<li><a href="#f-remaining">Remaining is more than the spool holds</a></li>
<li><a href="#f-colorname">Color name is empty</a></li>
</ul>
</div>
<fieldset class="sec">
<legend>Name: 5 parts</legend>
<div class="namebar nm-inset">
<div class="slots" aria-label="Name parts">
<div class="slot"><span class="k">Brand <span class="req">req</span></span><span class="v" id="sl-b">Sunlu</span></div>
<div class="slot empty"><span class="k">Speed · opt</span><span class="v">none</span></div>
<div class="slot"><span class="k">Material <span class="req">req</span></span><span class="v" id="sl-m">PETG</span></div>
<div class="slot" id="sl-t"><span class="k">Type · opt</span><span class="v">CF</span></div>
<div class="slot"><span class="k">Finish <span class="req">req</span></span><span class="v">Matte</span></div>
</div>
<div class="row"><span class="small muted">Reads as</span><span class="fullname">Sunlu PETG CF Matte</span><span class="chip chip-neutral">Black</span></div>
</div>
<div class="fr">
<label class="field"><span class="lab">Brand <span class="req">required</span></span><select class="select"><option>Sunlu</option></select></label>
<label class="field"><span class="lab">Print speed <em>optional</em></span><select class="select"><option>None</option><option>Rapid</option><option>High Speed</option></select></label>
<label class="field"><span class="lab">Material <span class="req">required</span></span><select class="select"><option>PETG</option></select></label>
<label class="field"><span class="lab">Filament type <em>optional</em></span><select class="select"><option>CF</option></select></label>
<label class="field"><span class="lab">Finish <span class="req">required</span></span><select class="select"><option>Matte</option></select></label>
</div>
</fieldset>
<fieldset class="sec">
<legend>Color</legend>
<div class="fr">
<div class="field" id="f-colorname">
<label class="lab" for="cn">Color name <span class="req">required</span></label>
<input class="input" id="cn" value="Black" data-only="conflict tare add edit">
<div data-only="errors" class="field has-error" style="gap:6px">
<input class="input" value="" placeholder="e.g. Charcoal" aria-invalid="true" aria-describedby="cn-err">
<p class="field-error" id="cn-err" style="margin:0">Give the color a name, even "Unknown". <span class="srv">(server: <code>color_name is required</code>)</span></p>
</div>
</div>
<div class="field"><span class="lab">Colors <em>1–3, in order</em></span>
<div class="colors-row"><span class="swatch"><i style="background:#1b1b1b"></i></span><input class="input" value="#1b1b1b" style="max-width:120px" aria-label="Color 1 hex"><button class="btn btn-sm" type="button">+ Add color</button></div></div>
</div>
</fieldset>
<fieldset class="sec">
<legend>Weight: net filament vs what the scale reads</legend>
<div class="weights">
<div class="stack">
<div class="fr">
<label class="field"><span class="lab">Total <em>net, as sold</em></span><input class="input num" value="1000 g"><span class="hint">Filament only. A "1 kg spool" is 1000 g plus the spool.</span></label>
<div class="field" id="f-remaining">
<label class="lab" for="rem">Remaining <em>net</em></label>
<input class="input num" id="rem" value="812 g" data-only="conflict tare edit">
<input class="input num" value="600 g" data-only="add">
<div data-only="errors" class="field has-error" style="gap:6px">
<input class="input num is-error" value="1040 g" aria-invalid="true" aria-describedby="rem-err">
<p class="field-error" id="rem-err" style="margin:0">1,040 g is more than this spool holds (1,000 g net). Lower remaining, or correct total. <span class="srv">(server: <code>remaining_grams cannot exceed total_grams</code>)</span></p>
</div>
<span class="hint" data-only="conflict edit">⚖ From a scale reading on 18 Sep 09:39, not typed</span>
</div>
</div>
<label class="field"><span class="lab">Spool type <em>supplies the tare</em></span>
<select class="select"><option>Sunlu — 1 kg plastic · 140 g · community</option><option>Elegoo — 1 kg cardboard · 154 g · manufacturer</option><option>Generic — 1 kg spool (assumed) · 180 g · estimated</option></select></label>
<div class="field">
<span class="lab">Empty spool (tare) <em>this reel</em></span>
<div data-only="conflict errors edit">
<input class="input num" value="262 g" aria-describedby="tare-src">
<p class="hint" id="tare-src" style="margin:6px 0 0"><span class="chip chip-accent">this spool</span> Weighed bare on 14 Sep. It beats the spool type's 140 g (community).</p>
</div>
<div data-only="add">
<input class="input num" value="140 g" aria-describedby="tare-add">
<p class="hint" id="tare-add" style="margin:6px 0 0"><span class="chip chip-info">community</span> From the Sunlu — 1 kg plastic spool type. Measured by users and corroborated. Only saved on this spool if you weigh it.</p>
</div>
<div data-only="tare">
<input class="input" value="" placeholder="unknown: type it, or pick a spool type" aria-describedby="tare-unk">
<p class="hint" id="tare-unk" style="margin:6px 0 0"><span class="chip chip-warn chip-dashed">? unknown</span> No spool type chosen, and TECSONAR has no published tare. <b>Not treated as 0</b>: 0 would count the whole scale reading as filament.</p>
</div>
</div>
</div>
<div class="scale nm-raised">
<span class="lab-t small" style="font-weight:700">On the scale</span>
<div data-only="conflict errors edit">
<div class="stackbar" aria-hidden="true"><span class="t" style="width:20.8%"></span><span class="f" style="width:64.4%"></span><span class="u"></span></div>
<div class="eq" style="margin-top:12px">
<span><span class="key" style="background:repeating-linear-gradient(45deg,var(--border-strong) 0 3px,transparent 3px 6px)"></span></span><span>Empty spool (tare)</span><span>262 g</span>
<span><span class="key" style="background:var(--text-accent)"></span></span><span>Filament remaining (net)</span><span>812 g</span>
<span></span><span class="tot">Gross: should read</span><span class="tot">1,074 g</span>
</div>
<label class="field" style="margin-top:12px"><span class="lab">Weigh it <em>whole spool</em></span><input class="input num" placeholder="full reads 1,262 g"></label>
<span class="hint">Type what the scale shows and remaining is worked out for you: reading − 262 g.</span>
</div>
<div data-only="add">
<div class="stackbar" aria-hidden="true"><span class="t" style="width:12.3%"></span><span class="f" style="width:52.6%"></span><span class="u"></span></div>
<div class="eq" style="margin-top:12px">
<span><span class="key" style="background:repeating-linear-gradient(45deg,var(--border-strong) 0 3px,transparent 3px 6px)"></span></span><span>Empty spool (tare, community)</span><span>140 g</span>
<span><span class="key" style="background:var(--text-accent)"></span></span><span>Filament remaining (net)</span><span>600 g</span>
<span></span><span class="tot">Gross: should read</span><span class="tot">740 g</span>
</div>
<label class="field" style="margin-top:12px"><span class="lab">Weigh it <em>whole spool</em></span><input class="input num" placeholder="full reads 1,140 g"></label>
<span class="hint">Weighing saves the 140 g tare on this spool, marked community, alongside the reading.</span>
</div>
<div data-only="tare">
<div class="stackbar" aria-hidden="true" style="border-style:dashed"><span class="f" style="width:18.8%"></span></div>
<div class="eq" style="margin-top:12px">
<span></span><span>Empty spool (tare)</span><span class="muted">unknown</span>
<span><span class="key" style="background:var(--text-accent)"></span></span><span>Filament remaining (net)</span><span>188 g</span>
<span></span><span class="tot">Gross</span><span class="tot muted">unknown</span>
</div>
<label class="field" style="margin-top:12px"><span class="lab">Weigh it <em>whole spool</em></span><input class="input is-disabled" disabled placeholder="needs a tare first"></label>
<span class="hint">Set an empty-spool weight (pick a spool type, or weigh the bare spool) and you can weigh the whole spool instead of guessing.</span>
</div>
</div>
</div>
</fieldset>
<fieldset class="sec">
<legend>Cost and place</legend>
<div class="fr">
<label class="field"><span class="lab">Cost per kg</span><input class="input num" value="$22.79"></label>
<label class="field"><span class="lab">Location <em>optional</em></span>
<input class="input" value="Shelf A3" data-only="errors tare add">
<input class="input" value="A1 · AMS Lite · slot 1" data-only="conflict edit" style="border-color:var(--text-accent)">
</label>
</div>
</fieldset>
</div>
<div class="sheet-foot">
<span class="small muted" style="margin-right:auto" data-only="conflict">Save is off until you choose how to handle the newer version.</span>
<button class="btn" type="button">Cancel</button>
<button class="btn btn-primary is-disabled" type="button" disabled data-only="conflict">Save changes</button>
<button class="btn btn-primary" type="button" data-only="errors tare edit">Save changes</button>
<button class="btn btn-primary" type="button" data-only="add">Add spool</button>
</div>
</section>
</div>
<section class="specimen nm-raised" aria-labelledby="spec-h">
<h2 class="section" id="spec-h">Control states: v3 specimen</h2>
<p class="small muted" style="margin-top:0">Every state changes something other than shadow direction. Switch themes with the toggle to check both.</p>
<div class="spec-grid">
<div class="spec"><button class="btn" type="button">Save</button><span class="cap"><b>Rest</b><br>raised + 1px border (3:1)</span></div>
<div class="spec"><button class="btn is-hover" type="button">Save</button><span class="cap"><b>Hover</b><br>border turns accent</span></div>
<div class="spec"><button class="btn is-pressed" type="button">Low stock</button><span class="cap"><b>Pressed / on</b><br>inset + wash fill + ✓ + accent border</span></div>
<div class="spec"><button class="btn is-focused" type="button">Save</button><span class="cap"><b>Focused</b><br>2px outline, 3px offset</span></div>
<div class="spec"><button class="btn" disabled type="button">Save</button><span class="cap"><b>Disabled</b><br>flat + dashed border + muted text</span></div>
<div class="spec"><button class="btn btn-primary" type="button">Add spool</button><span class="cap"><b>Primary</b><br>ink on light violet (6.14:1 dark)</span></div>
<div class="spec"><button class="btn btn-primary is-pressed" type="button">Add spool</button><span class="cap"><b>Primary pressed</b><br>deeper fill + ✓ + ring</span></div>
<div class="spec"><button class="btn btn-danger" type="button">Delete</button><span class="cap"><b>Danger</b><br>red text + red border</span></div>
<div class="spec" style="width:100%"><input class="input" placeholder="Rest"><span class="cap"><b>Input rest</b><br>inset + border</span></div>
<div class="spec" style="width:100%"><input class="input is-focused" value="Focused"><span class="cap"><b>Input focus</b><br>outline, border unchanged</span></div>
<div class="spec" style="width:100%"><input class="input" disabled value="Disabled"><span class="cap"><b>Input disabled</b><br>dashed, flat</span></div>
<div class="spec field has-error" style="width:100%;gap:6px"><input class="input" value="1040 g" aria-invalid="true"><p class="field-error" style="margin:0">Too much</p><span class="cap"><b>Input error</b><br>2px red + ⚠ + message</span></div>
</div>
</section>
<script>
(function(){
function set(s){document.body.className='s-'+s;
document.querySelectorAll('#scen [data-s]').forEach(function(b){b.setAttribute('aria-pressed',String(b.getAttribute('data-s')===s))});
var tareSheet = s==='tare';
document.querySelector('.sheet-head .swatch').innerHTML = tareSheet ? '<i style="background:#161616"></i><i style="background:#c0232f"></i>' : '<i style="background:#1b1b1b"></i>';
document.querySelector('.fullname').textContent = tareSheet ? 'TECSONAR PLA Matte' : 'Sunlu PETG CF Matte';
document.getElementById('sl-b').textContent = tareSheet ? 'TECSONAR' : 'Sunlu';
document.getElementById('sl-m').textContent = tareSheet ? 'PLA' : 'PETG';
var t = document.getElementById('sl-t'); t.className = tareSheet ? 'slot empty' : 'slot'; t.querySelector('.v').textContent = tareSheet ? 'none' : 'CF';
document.querySelector('.sheet-head p span').textContent = tareSheet ? 'Opened 09:32 at version 2 · created 2 Aug' : 'Opened 09:32 at version 4 · created 2 Aug · label printed 3 Aug';
document.getElementById('form-h').firstElementChild.textContent = tareSheet ? 'Edit spool #44' : 'Edit spool #47';
}
document.addEventListener('click',function(e){var b=e.target.closest('#scen [data-s]'); if(b) set(b.getAttribute('data-s'));});
document.addEventListener('DOMContentLoaded',function(){ set(location.hash==='#add'?'add':'conflict'); });
})();
</script>
+209
View File
@@ -0,0 +1,209 @@
<style>
.datasrc { padding: var(--space-4) var(--space-5); display: flex; flex-wrap: wrap; gap: var(--space-3) var(--space-4); align-items: center; }
.group-h { display: flex; align-items: center; gap: var(--space-3); margin: 0 0 var(--space-4); font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); font-weight: var(--fw-bold); }
.group-h .n { display: inline-grid; place-items: center; min-width: 24px; height: 24px; border-radius: var(--radius-pill); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); color: var(--text-primary); }
.groups { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: var(--space-6); align-items: start; }
.pgrid { display: grid; gap: var(--space-6); }
.pcard { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
.pcard.attn { border: 2px solid var(--status-warning); }
.phead { display: flex; gap: var(--space-3); align-items: flex-start; }
.phead h3 { margin: 0; font-size: var(--fs-h3); line-height: var(--lh-snug); }
.phead .model { font-size: var(--fs-label); color: var(--text-secondary); }
.cam { aspect-ratio: 16 / 9; border-radius: var(--radius-md); background: radial-gradient(ellipse at 50% 90%, #5a4b3a 0 28%, transparent 29%), linear-gradient(180deg, #1c1d22 0%, #2b2c33 60%, #3a3326 100%); border: 1px solid var(--border-control); box-shadow: var(--nm-inset-2); position: relative; overflow: hidden; }
.cam::after { content: ""; position: absolute; left: 30%; right: 30%; bottom: 20%; height: 22%; background: repeating-linear-gradient(0deg, #6b8fd6 0 3px, #5a7cc2 3px 6px); border-radius: 4px 4px 0 0; opacity: .85; }
.cam .stamp { position: absolute; left: 10px; top: 8px; font-size: var(--fs-caption); color: #f4f4f6; background: rgba(0, 0, 0, .55); padding: 2px 8px; border-radius: var(--radius-pill); z-index: 1; }
.cam-off { min-height: 72px; border-radius: var(--radius-md); border: 1px dashed var(--border-control); display: grid; place-items: center; text-align: center; padding: var(--space-4); color: var(--text-secondary); font-size: var(--fs-label); }
.nowjob { padding: var(--space-4); display: grid; grid-template-columns: 1fr auto; gap: 2px var(--space-3); }
.nowjob .jn { font-weight: var(--fw-bold); }
.slots4 { display: grid; gap: var(--space-2); }
.cont { padding: var(--space-3) var(--space-4); }
.cont-h { display: flex; justify-content: space-between; font-size: var(--fs-label); margin-bottom: var(--space-2); }
.slotrow { display: grid; grid-template-columns: 22px auto minmax(0, 1fr) auto; gap: var(--space-2) var(--space-3); align-items: center; font-size: 13px; padding: 4px 0; }
.slotrow .i { color: var(--text-secondary); font-size: var(--fs-caption); }
.slotrow.empty .nm { color: var(--text-secondary); font-style: italic; }
.conn { display: grid; grid-template-columns: auto 1fr; gap: 4px var(--space-4); font-size: var(--fs-label); margin: 0; }
.conn dt { color: var(--text-secondary); }
.conn dd { margin: 0; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
details.cfg { border-top: 1px solid var(--border-hairline); padding-top: var(--space-3); }
details.cfg summary { cursor: pointer; font-size: var(--fs-label); font-weight: var(--fw-bold); }
details.cfg summary:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; border-radius: 4px; }
.lost { padding: var(--space-4); border-radius: var(--radius-md); border: 1px solid var(--status-warning); background: var(--wash-warning); display: flex; flex-direction: column; gap: var(--space-2); font-size: 13px; }
.lost h4 { margin: 0; color: var(--status-warning); font-size: 14px; }
.lost ul { margin: 0; padding-left: 18px; }
.stale { opacity: 1; border-style: dashed !important; }
.stale-tag { font-size: var(--fs-caption); color: var(--status-warning); font-weight: var(--fw-bold); }
.pulse { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--status-success); box-shadow: 0 0 0 3px var(--wash-success); }
.future { padding: var(--space-6); border: 2px dashed var(--border-control); border-radius: var(--radius-lg); background: transparent; }
.future h2 { margin: 0; font-size: var(--fs-h3); display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.fgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: var(--space-4); margin-top: var(--space-5); }
.fitem { padding: var(--space-4); border: 1px dashed var(--border-control); border-radius: var(--radius-md); display: flex; flex-direction: column; gap: var(--space-2); }
.fitem h3 { margin: 0; font-size: 14px; }
.fitem .why { font-size: var(--fs-caption); color: var(--text-secondary); line-height: var(--lh-snug); }
.ghost { opacity: .9; filter: grayscale(1); pointer-events: none; }
body.dev-only [data-feature] { display: none !important; }
body:not(.dev-only) [data-devonly] { display: none !important; }
</style>
<header class="page-head">
<div>
<h1>Printers</h1>
<p class="muted">3 printers · status polled every 10 s · MQTT is read-only</p>
</div>
<div class="actions">
<button class="btn" type="button">Manage AMS units</button>
<button class="btn btn-primary" type="button">+ Add printer</button>
</div>
</header>
<section class="datasrc nm-raised" aria-label="Data source">
<span class="small"><b>Data shown</b></span>
<div class="seg" role="group" aria-label="Which build's data to show" id="ds">
<button type="button" data-ds="dev" aria-pressed="false">dev today</button>
<button type="button" data-ds="feat" aria-pressed="true">dev + printer-connections branch</button>
</div>
<span class="small muted">Fields tagged <span class="chip chip-info chip-dashed">feature</span> exist only on <code>feature/7efc2030…-printer-connections</code> (4e17f9e), not in production.</span>
</section>
<div class="groups">
<section aria-labelledby="g-progress">
<h2 class="group-h" id="g-progress">In progress <span class="n">1</span></h2>
<div class="pgrid">
<article class="pcard nm-raised" aria-labelledby="p1">
<div class="phead">
<div style="flex:1;min-width:0"><h3 id="p1">P1S - Prints Charming</h3><span class="model">Bambu Lab · P1S</span></div>
<span class="chip chip-accent">● Printing</span>
<button class="btn btn-icon btn-sm" type="button" aria-label="Actions for P1S - Prints Charming" aria-haspopup="menu">⋯</button>
</div>
<div class="row small" data-feature><span class="pulse" aria-hidden="true"></span><span>Ready · last report <b>2s ago</b></span><span class="chip chip-info chip-dashed">feature</span></div>
<div class="row small" data-devonly><span class="pulse" aria-hidden="true"></span><span>Connected</span></div>
<div class="cam" role="img" aria-label="Camera snapshot from P1S - Prints Charming"><span class="stamp">snapshot 09:41:02</span></div>
<div class="nowjob nm-inset">
<span class="jn">Gridfinity_bins_3x2</span><span class="num">1h 04m</span>
<span class="small muted">started 08:37 · weight read from the 3MF when it ends</span><span class="small muted">so far</span>
</div>
<div class="slots4" aria-label="Filament loaded in P1S - Prints Charming">
<div class="row"><span class="small"><b>Loaded filament</b></span><span class="chip chip-neutral">not confirmed in use</span></div>
<div class="cont nm-inset">
<div class="cont-h"><b>AMS 1</b><span class="muted">4 / 4</span></div>
<div class="slotrow"><span class="i">1</span><span class="swatch swatch-sm"><i style="background:#141414"></i></span><span class="nm">Sunlu · Black <span class="muted">PLA + 2.0</span></span><span class="num">1000 g</span></div>
<div class="slotrow"><span class="i">2</span><span class="swatch swatch-sm"><i style="background:#b87333"></i></span><span class="nm">Sunlu · Copper <span class="muted">PLA + Silk</span></span><span class="num" style="color:var(--status-warning)">⚠ 97 g</span></div>
<div class="slotrow"><span class="i">3</span><span class="swatch swatch-sm"><i style="background:#ece8df"></i></span><span class="nm">Overture · Mist Gray <span class="muted">PLA Marble</span></span><span class="num">920 g</span></div>
<div class="slotrow"><span class="i">4</span><span class="swatch swatch-sm"><i style="background:#101010"></i></span><span class="nm">JAYO · Black <span class="muted">Rapid PETG Matte</span></span><span class="num">655 g</span></div>
</div>
<div class="cont nm-inset">
<div class="cont-h"><b>External</b><span class="muted">0 / 1</span></div>
<div class="slotrow empty"><span class="i">1</span><span></span><span class="nm">Empty</span><span></span></div>
</div>
</div>
<details class="cfg"><summary>Connection details (stored)</summary>
<dl class="conn" style="margin-top:8px">
<dt>IP</dt><dd>10.60.1.242</dd><dt>Serial</dt><dd>01P09A••••••4821</dd><dt>Access code</dt><dd>✓ stored, never shown</dd><dt>Added</dt><dd>7 Aug 2026</dd>
<dt data-feature>Session</dt><dd data-feature>#3 <span class="chip chip-info chip-dashed">feature</span></dd>
</dl></details>
<p class="small muted" style="margin:0">Last finished: Lid_v3 · failed yesterday 16:49</p>
</article>
</div>
</section>
<section aria-labelledby="g-attn">
<h2 class="group-h" id="g-attn">Needs attention <span class="n">1</span></h2>
<div class="pgrid">
<article class="pcard nm-raised attn" aria-labelledby="p3">
<div class="phead">
<div style="flex:1;min-width:0"><h3 id="p3">A1 - Jordyns Layer Slayer</h3><span class="model">Bambu Lab · A1</span></div>
<span class="chip chip-warn" data-feature>⟳ Reconnecting</span>
<span class="chip chip-warn" data-devonly>⚡ Offline</span>
<button class="btn btn-icon btn-sm" type="button" aria-label="Actions for A1 - Jordyns Layer Slayer" aria-haspopup="menu">⋯</button>
</div>
<div class="lost" role="status">
<h4>⚡ MQTT connection lost</h4>
<span data-feature>Disconnected, reconnecting. Last contact <b>6m ago</b> (09:35:11). <span class="chip chip-info chip-dashed">feature</span></span>
<span data-devonly>Extrudex's MQTT session to this printer dropped. The time it dropped isn't recorded on dev.</span>
<ul class="small">
<li>The printer itself may still be printing. This is Extrudex's connection, not the printer's power.</li>
<li><b>Print capture is paused.</b> A job that starts and finishes during the outage won't be recorded.</li>
<li>On reconnect: if <i>Coaster_set</i> is still running, its record stays open and gets <b>+1 gap</b> (its duration becomes an upper bound). Otherwise it closes as <b>unknown</b>, with no duration.</li>
</ul>
</div>
<div class="nowjob nm-inset stale" style="border:1px dashed var(--status-warning)">
<span class="jn">Coaster_set</span><span class="stale-tag">OPEN RECORD</span>
<span class="small muted">started 08:55 · watched until the connection dropped</span><span></span>
<span class="small" data-feature style="grid-column:1/-1"><span class="stale-tag">Last known:</span> Printing · 6m ago. Cached from the lost session, not live. <span class="chip chip-info chip-dashed">feature</span></span>
</div>
<div class="cam-off">Camera unavailable while the printer is offline</div>
<div class="slots4" aria-label="Filament loaded in A1 - Jordyns Layer Slayer">
<div class="row"><span class="small"><b>Loaded filament</b></span><span class="chip chip-neutral">from Extrudex records, not the printer</span></div>
<div class="cont nm-inset">
<div class="cont-h"><b>AMS Lite</b><span class="muted">2 / 4</span></div>
<div class="slotrow"><span class="i">1</span><span class="swatch swatch-sm"><i style="background:#1b1b1b"></i></span><span class="nm">Sunlu · Black <span class="muted">PETG CF Matte</span></span><span class="num">812 g</span></div>
<div class="slotrow"><span class="i">2</span><span class="swatch swatch-sm"><i style="background:#e8453c"></i><i style="background:#f2c230"></i><i style="background:#2a8fd6"></i></span><span class="nm">AMOLEN · Red Yellow Blue <span class="muted">PLA Matte</span></span><span class="num">744 g</span></div>
<div class="slotrow empty"><span class="i">3</span><span></span><span class="nm">Empty</span><span></span></div>
<div class="slotrow empty"><span class="i">4</span><span></span><span class="nm">Empty</span><span></span></div>
</div>
</div>
<details class="cfg" open><summary>Connection details (stored)</summary>
<dl class="conn" style="margin-top:8px">
<dt>IP</dt><dd>10.60.1.x <span class="muted">(sample)</span></dd><dt>Serial</dt><dd>039••••••••7710</dd><dt>Access code</dt><dd>✓ stored, never shown</dd>
<dt data-feature>Lifecycle</dt><dd data-feature>reconnecting · <code>stale: true</code> <span class="chip chip-info chip-dashed">feature</span></dd>
<dt data-feature>Failure</dt><dd data-feature><span class="muted">none reported. A reconnect can have no failure detail.</span></dd>
<dt data-feature>Session</dt><dd data-feature>#14 (increments on every reconnect)</dd>
</dl></details>
<div class="actions"><button class="btn btn-sm" type="button">Edit connection</button><span class="small muted">Retrying automatically. There's nothing to press.</span></div>
</article>
</div>
</section>
<section aria-labelledby="g-idle">
<h2 class="group-h" id="g-idle">Idle <span class="n">1</span></h2>
<div class="pgrid">
<article class="pcard nm-raised-sm" aria-labelledby="p2" style="padding: var(--space-5)">
<div class="phead">
<div style="flex:1;min-width:0"><h3 id="p2">H2C - Constipation Orion</h3><span class="model">Bambu Lab · H2C</span></div>
<span class="chip chip-neutral">Idle</span>
<button class="btn btn-icon btn-sm" type="button" aria-label="Actions for H2C - Constipation Orion" aria-haspopup="menu">⋯</button>
</div>
<div class="row small"><span class="pulse" aria-hidden="true"></span><span data-feature>Ready · last report 4s ago <span class="chip chip-info chip-dashed">feature</span></span><span data-devonly>Connected</span></div>
<p class="small" style="margin:0">Last: <b>Benchy_x4</b> · finished 05:27 · <span style="color:var(--status-warning);font-weight:700">≤ 3h 12m</span> <span class="chip chip-warn chip-dashed">upper bound · 1 gap</span></p>
<div class="cont nm-inset">
<div class="cont-h"><b>AMS 1</b><span class="muted">3 / 4</span></div>
<div class="slotrow"><span class="i">1</span><span class="swatch swatch-sm"><i style="background:#141414"></i></span><span class="nm">Elegoo · Black <span class="muted">Rapid PETG</span></span><span class="num" style="color:var(--status-warning)">⚠ 142 g</span></div>
<div class="slotrow"><span class="i">2</span><span class="swatch swatch-sm"><i style="background:#1d4fa8"></i></span><span class="nm">Elegoo · Blue <span class="muted">Rapid PETG</span></span><span class="num">1000 g</span></div>
<div class="slotrow"><span class="i">3</span><span class="swatch swatch-sm"><i style="background:#f4f4f0"></i></span><span class="nm">Elegoo · White <span class="muted">PLA Matte</span></span><span class="num">610 g</span></div>
<div class="slotrow empty"><span class="i">4</span><span></span><span class="nm">Empty</span><span></span></div>
</div>
<details class="cfg"><summary>Connection details (stored)</summary>
<dl class="conn" style="margin-top:8px"><dt>IP</dt><dd>10.60.1.202</dd><dt>Serial</dt><dd>31B••••••••0093</dd><dt>Access code</dt><dd>✓ stored, never shown</dd></dl></details>
</article>
</div>
</section>
</div>
<section class="future" aria-labelledby="future-h">
<h2 id="future-h"><span class="chip chip-neutral chip-dashed">Future, not built</span> Ideas that need data Extrudex doesn't store</h2>
<p class="small muted" style="margin:var(--space-2) 0 0">Nothing in this box can be built from today's data. Each item says what's missing.</p>
<div class="fgrid">
<div class="fitem"><h3>Progress % and time left</h3><div class="track ghost" aria-hidden="true"><span style="width:58%"></span></div>
<span class="why"><code>mc_percent</code> is parsed from MQTT but used only internally. It isn't stored or returned by <code>/api/printers</code>. ETA isn't read at all.</span></div>
<div class="fitem"><h3>Nozzle and bed temperature</h3><span class="ghost small">220 °C · 60 °C</span>
<span class="why">Temperatures aren't parsed from the report.</span></div>
<div class="fitem"><h3>Current layer</h3><span class="ghost small">Layer 112 / 190</span>
<span class="why">Layer fields aren't parsed.</span></div>
<div class="fitem"><h3>AMS remaining % and humidity</h3><span class="ghost small">82% · humidity 2</span>
<span class="why">Not read. AMS <code>remain</code> is only meaningful for genuine RFID spools, which isn't what's mostly run here.</span></div>
<div class="fitem"><h3>Alert when MQTT drops</h3><span class="ghost small">🔔 notify after 5 min</span>
<span class="why">Drops only show in container logs today (CONTEXT open work #4). On dev, nothing stores <i>when</i> it dropped.</span></div>
<div class="fitem"><h3>Which spool is feeding now</h3><span class="ghost small">▶ AMS 1 · slot 2</span>
<span class="why">"Loaded" is recorded; "in use" isn't. That's why the list says <i>not confirmed in use</i>.</span></div>
</div>
</section>
<script>
(function(){
function set(v){document.body.classList.toggle('dev-only', v==='dev');
document.querySelectorAll('#ds [data-ds]').forEach(function(b){b.setAttribute('aria-pressed',String(b.getAttribute('data-ds')===v))});}
document.addEventListener('click',function(e){var b=e.target.closest('#ds [data-ds]'); if(b) set(b.getAttribute('data-ds'));});
})();
</script>