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
+87
View File
@@ -0,0 +1,87 @@
/* ── Mockup app shell (shared by all four screens) ── */
.shell { display: grid; grid-template-columns: 232px minmax(0, 1fr); gap: var(--space-6); max-width: 1440px; margin: 0 auto; padding: var(--space-6); min-height: 100vh; }
.rail { position: sticky; top: var(--space-6); align-self: start; padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-5); }
.wordmark { font-weight: var(--fw-display); font-size: var(--fs-h3); letter-spacing: var(--tracking-wordmark); display: flex; align-items: center; gap: var(--space-2); }
.wordmark-mark { width: 30px; height: 30px; border-radius: 10px; background: var(--accent-fill); color: var(--text-on-accent); display: grid; place-items: center; font-size: 15px; box-shadow: var(--nm-raised-1); border: 1px solid var(--border-control); }
.nav { display: flex; flex-direction: column; gap: var(--space-2); }
.nav a { display: flex; align-items: center; gap: var(--space-3); padding: 10px 12px; border-radius: var(--radius-md); color: var(--text-primary); text-decoration: none; border: 1px solid transparent; font-weight: var(--fw-medium); }
.nav a:hover { border-color: var(--border-control); }
.nav a:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: var(--focus-offset); }
.nav a[aria-current="page"] { background: var(--surface-inset); box-shadow: var(--nm-inset-1); border-color: var(--text-accent); color: var(--text-accent); font-weight: var(--fw-bold); }
.nav svg { width: 18px; height: 18px; flex: none; }
.nav .nav-new { margin-left: auto; }
.rail-foot { display: flex; flex-direction: column; gap: var(--space-3); border-top: 1px solid var(--border-hairline); padding-top: var(--space-4); }
.seg { display: inline-flex; padding: 4px; gap: 4px; border-radius: var(--radius-pill); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-control); }
.seg button { font: var(--fw-medium) var(--fs-label)/1 var(--font-body); color: var(--text-secondary); background: transparent; border: 1px solid transparent; border-radius: var(--radius-pill); padding: 7px 12px; cursor: pointer; display: inline-flex; gap: 6px; align-items: center; }
.seg button[aria-pressed="true"] { background: var(--surface-raised); color: var(--text-primary); box-shadow: var(--nm-raised-1); border-color: var(--border-control); font-weight: var(--fw-bold); }
.seg button[aria-pressed="true"]::before { content: "✓"; color: var(--text-accent); }
.seg button:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
.who { font-size: var(--fs-label); }
main { min-width: 0; display: flex; flex-direction: column; gap: var(--space-6); padding-bottom: var(--space-16); }
.proposal { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); align-items: center; font-size: var(--fs-label); color: var(--text-secondary); padding: var(--space-3) var(--space-4); }
.page-head { display: flex; flex-wrap: wrap; gap: var(--space-4); align-items: flex-end; justify-content: space-between; }
.page-head h1 { margin: 0; font-size: var(--fs-h1); line-height: var(--lh-tight); font-weight: var(--fw-display); }
.page-head p { margin: var(--space-1) 0 0; }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; }
h2.section { font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); margin: 0 0 var(--space-3); font-weight: var(--fw-bold); display: flex; align-items: center; gap: var(--space-2); }
.card { padding: var(--space-5); }
.card h3 { margin: 0; font-size: var(--fs-body-lg); }
.grid { display: grid; gap: var(--space-5); }
.stack { display: flex; flex-direction: column; gap: var(--space-3); }
.row { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.spacer { flex: 1; }
a { color: var(--text-accent); }
a:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; border-radius: 4px; }
small, .small { font-size: var(--fs-label); }
.tiny { font-size: var(--fs-caption); }
code { font-family: var(--font-mono); font-size: 0.92em; background: var(--surface-inset); padding: 1px 5px; border-radius: var(--radius-xs); border: 1px solid var(--border-hairline); }
.kbd-note { font-size: var(--fs-caption); color: var(--text-secondary); }
/* Data tables: square, numbers right-aligned, inside a soft card */
.table-wrap { overflow-x: auto; margin: 0 calc(var(--space-5) * -1); padding: 0 var(--space-5); }
table.tbl { width: 100%; border-collapse: collapse; border-radius: var(--radius-none); font-size: 13px; }
.tbl th { text-align: left; font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps); color: var(--text-secondary); font-weight: var(--fw-bold); padding: 10px 10px; border-bottom: 1px solid var(--border-strong); white-space: nowrap; }
.tbl td { padding: 11px 10px; border-bottom: 1px solid var(--border-hairline); vertical-align: middle; }
.tbl tr:last-child td { border-bottom: 0; }
.tbl .r { text-align: right; font-variant-numeric: tabular-nums; }
.tbl tbody tr:hover td { background: var(--accent-wash); }
/* Provenance marker used across screens */
.src { display: inline-flex; gap: 4px; align-items: center; font-size: var(--fs-caption); font-weight: var(--fw-bold); letter-spacing: var(--tracking-caps); text-transform: uppercase; }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex: none; }
/* Stat tiles */
.stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-5); }
.stat { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-1); }
.stat-value { font-size: 30px; font-weight: var(--fw-display); line-height: var(--lh-tight); font-variant-numeric: tabular-nums; }
.stat-label { font-size: var(--fs-label); color: var(--text-secondary); text-transform: uppercase; letter-spacing: var(--tracking-caps); font-weight: var(--fw-bold); }
.stat-foot { font-size: var(--fs-label); color: var(--text-secondary); }
.stat.warn .stat-value { color: var(--status-warning); }
/* Modal backdrop for dialogs */
.scrim { position: fixed; inset: 0; background: rgba(10, 10, 14, 0.55); }
:root[data-theme="light"] .scrim { background: rgba(60, 55, 80, 0.35); }
/* Responsive */
@media (max-width: 1100px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 860px) {
.shell { grid-template-columns: 1fr; padding: var(--space-4); gap: var(--space-4); }
.rail { position: static; flex-direction: row; flex-wrap: wrap; align-items: center; padding: var(--space-3) var(--space-4); gap: var(--space-3); }
.nav { flex-direction: row; flex-wrap: wrap; gap: var(--space-1); }
.nav a { padding: 8px 10px; }
.nav a span.lbl { display: none; }
.nav a[aria-current="page"] span.lbl { display: inline; }
.nav .nav-new { display: none; }
.rail-foot { border: 0; padding: 0; flex-direction: row; margin-left: auto; }
.who { display: none; }
}
@media (max-width: 520px) {
.stats { grid-template-columns: 1fr 1fr; gap: var(--space-3); }
.stat { padding: var(--space-4); }
.stat-value { font-size: 22px; }
.page-head h1 { font-size: var(--fs-h2); }
}
a.btn { text-decoration: none; }
.th-note { display: block; margin-top: 4px; }
+122
View File
@@ -0,0 +1,122 @@
"""Assemble self-contained mockup pages: tokens + fonts (base64) + shell + page."""
import base64, pathlib, re
HERE = pathlib.Path(__file__).parent
OUT = pathlib.Path.home() / 'Design/extrudex-v3'
FONTS = OUT / 'fonts'
tokens = (OUT / 'design-system-v3.css').read_text()
def embed_font(match):
name = match.group(1)
data = base64.b64encode((FONTS / name).read_bytes()).decode()
return f"url('data:font/woff2;base64,{data}')"
tokens = re.sub(r"url\('\./fonts/([^']+)'\)", embed_font, tokens)
app_css = (HERE / 'app.css').read_text()
ICONS = {
'dashboard': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="2"/><rect x="14" y="3" width="7" height="5" rx="2"/><rect x="14" y="12" width="7" height="9" rx="2"/><rect x="3" y="16" width="7" height="5" rx="2"/></svg>',
'filament': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v6M12 15v6"/></svg>',
'prints': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>',
'printers': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 17h8M9 7h6v5H9z"/></svg>',
}
NAV = [('dashboard', 'Dashboard', '01-dashboard.html'), ('filament', 'Filament', '02-filament.html'),
('prints', 'Prints', None), ('printers', 'Printers', '04-printers.html')]
THEME_HEAD = """<script>
(function () {
var root = document.documentElement, key = 'extrudex-v3-theme', t = null;
try { t = localStorage.getItem(key); } catch (e) {}
var q = /[?&]theme=(light|dark)/.exec(location.search); if (q) t = q[1];
if (t !== 'light' && t !== 'dark') {
t = window.matchMedia && matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
root.setAttribute('data-theme', t);
function set(v) {
root.setAttribute('data-theme', v);
document.querySelectorAll('[data-set-theme]').forEach(function (b) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-set-theme') === v));
});
try { localStorage.setItem(key, v); } catch (e) {}
}
document.addEventListener('DOMContentLoaded', function () { set(root.getAttribute('data-theme')); });
document.addEventListener('click', function (e) {
var b = e.target.closest && e.target.closest('[data-set-theme]');
if (b) set(b.getAttribute('data-set-theme'));
});
})();
</script>"""
def rail(active):
links = []
for key, label, href in NAV:
cur = ' aria-current="page"' if key == active else ''
extra = ''
if key == 'dashboard': extra = '<span class="chip chip-accent nav-new">new</span>'
if href is None:
links.append(f'<a href="#" title="Prints history: not part of this proposal"{cur}>{ICONS[key]}<span class="lbl">{label}</span></a>')
else:
links.append(f'<a href="{href}"{cur}>{ICONS[key]}<span class="lbl">{label}</span>{extra}</a>')
return f"""<nav class="rail nm-raised" aria-label="Main">
<div class="wordmark"><span class="wordmark-mark" aria-hidden="true">X</span>Extrudex</div>
<div class="nav">{''.join(links)}</div>
<div class="rail-foot">
<div class="seg" role="group" aria-label="Color theme">
<button type="button" data-set-theme="light" aria-pressed="false">Light</button>
<button type="button" data-set-theme="dark" aria-pressed="true">Dark</button>
</div>
<span class="who muted">Signed in · operator</span>
</div>
</nav>"""
def page(slug, title, active, description):
src = (HERE / 'pages' / f'{slug}.html').read_text()
css_m = re.search(r'<style>(.*?)</style>', src, re.S)
page_css = css_m.group(1) if css_m else ''
body = re.sub(r'<style>.*?</style>', '', src, flags=re.S).strip()
js_m = re.search(r'<script>(.*?)</script>', body, re.S)
page_js = ''
if js_m:
page_js = f'<script>{js_m.group(1)}</script>'
body = re.sub(r'<script>.*?</script>', '', body, flags=re.S)
html = f"""<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<meta name="description" content="{description}">
{THEME_HEAD}
<style>
{tokens}
{app_css}
{page_css}
</style>
</head>
<body>
<div class="shell">
{rail(active)}
<main id="main">
<div class="proposal nm-inset" role="note">
<span class="chip chip-accent">Design System v3 proposal</span>
<span>Mockup with sample data from the real inventory. Nothing here is wired to the API.</span>
<span class="spacer"></span>
<span class="row" style="gap:12px"><span class="src"><span class="chip chip-neutral">dev</span> on dev today</span>
<span class="src"><span class="chip chip-info chip-dashed">feature</span> feature branch only</span>
<span class="src"><span class="chip chip-warn chip-dashed">API gap</span> stored, not served</span></span>
</div>
{body}
</main>
</div>
{page_js}
</body>
</html>
"""
(OUT / 'screens' / f'{slug}.html').write_text(html)
print(slug, len(html) // 1024, 'KB')
page('01-dashboard', 'Extrudex Dashboard', 'dashboard', 'Stock summary and recent prints, Soft UI v3 mockup.')
page('02-filament', 'Extrudex Filament', 'filament', 'Filament inventory list with search, add, edit and delete, Soft UI v3 mockup.')
page('03-spool-form', 'Extrudex Spool Form', 'filament', 'Add/Edit spool form with 409 conflict and field-level 400 errors, Soft UI v3 mockup.')
page('04-printers', 'Extrudex Printers', 'printers', 'Printers screen with stored fields, dropped MQTT state and a Future section, Soft UI v3 mockup.')
+10
View File
@@ -0,0 +1,10 @@
def lum(h):
h=h.lstrip('#'); r,g,b=[int(h[i:i+2],16)/255 for i in (0,2,4)]
f=lambda c: c/12.92 if c<=0.03928 else ((c+0.055)/1.055)**2.4
return 0.2126*f(r)+0.7152*f(g)+0.0722*f(b)
def cr(a,b):
la,lb=sorted([lum(a),lum(b)],reverse=True); return (la+0.05)/(lb+0.05)
import sys
pairs=[l.split() for l in sys.stdin if l.strip()]
for p in pairs:
print(f"{p[0]:>9} on {p[1]:>9} {cr(p[0],p[1]):5.2f} {' '.join(p[2:])}")
+22
View File
@@ -0,0 +1,22 @@
import pathlib; exec(open(pathlib.Path(__file__).with_name('contrast.py')).read().split('import sys')[0])
def blend(fg,bg,a):
f=[int(fg[i:i+2],16) for i in (1,3,5)]; b=[int(bg[i:i+2],16) for i in (1,3,5)]
return '#'+''.join(f'{round(x*a+y*(1-a)):02x}' for x,y in zip(f,b))
D=dict(page='#2c2c30',inset='#232327',high='#36363a',text='#f4f4f6',muted='#b2b2ba',acc='#a99af0',accbg='#a99af0',onacc='#2a2431',ctl='#85858f',ok='#a9d88b',err='#f08b93',warn='#e8c170',info='#8fc3f0')
L=dict(page='#e4e2ea',inset='#d9d6e1',high='#f1f0f5',text='#2a2431',muted='#55505f',acc='#5b43b8',accbg='#5b43b8',onacc='#ffffff',ctl='#736e86',ok='#2c6619',err='#a8202f',warn='#7c5100',info='#1d5e94')
rows=[]
for name,T in (('Dark',D),('Light',L)):
chk=[('Body text','text','page',4.5),('Body text on inset (inputs)','text','inset',4.5),('Body text on high surface (dialog)','text','high',4.5),
('Secondary text','muted','page',4.5),('Secondary text on inset','muted','inset',4.5),('Secondary text on high','muted','high',4.5),
('Accent text / links','acc','page',4.5),('Accent text on high','acc','high',4.5),('Text on primary button','onacc','accbg',4.5),
('Success text','ok','page',4.5),('Error text','err','page',4.5),('Error text on high','err','high',4.5),('Warning text','warn','page',4.5),('Info text','info','page',4.5),
('Control border vs page (1.4.11)','ctl','page',3),('Control border vs inset','ctl','inset',3),('Control border vs high','ctl','high',3),('Focus ring vs page','acc','page',3),('Focus ring vs high','acc','high',3),
('Primary button fill vs page','accbg','page',3),('Error border vs inset','err','inset',3),('Warning border vs page','warn','page',3)]
for label,a,b,need in chk:
r=cr(T[a],T[b]); rows.append((name,label,T[a],T[b],r,need))
# tinted chips: 10% wash of status over page
for s in ('ok','err','warn','info','acc'):
bg=blend(T[s],T['page'],0.10); r=cr(T[s],bg); rows.append((name,f'{s} chip text on 10% wash',T[s],bg,r,4.5))
print('| Theme | Pair | Foreground | Background | Ratio | Needs | Result |\n|---|---|---|---|---|---|---|')
for n,l,a,b,r,need in rows:
print(f"| {n} | {l} | `{a}` | `{b}` | {r:.2f}:1 | {need}:1 | {'Pass' if r>=need else '**FAIL**'} |")
+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>