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
+247
View File
@@ -0,0 +1,247 @@
# Iteration 2: navigation, data display and interaction
2026-09-18. Builds on iteration 1 (`../screens/`, left unchanged for comparison).
Same Design System v3 tokens; the new components are in
`source/components.css`. Open **`screens/index.html`** first: it's the flow map,
with eight guided flows that deep-link into each state.
---
## 1. What was wrong with iteration 1
| # | Where | Problem | Why it matters | Iteration 2 fix |
|---|---|---|---|---|
| 1 | Nav, Dashboard | "Prints" and "All prints →" linked to `#`. Dead ends. | You lose trust in a nav item the first time it does nothing. | A real **Prints** page (history + attention queue, as on `dev`) |
| 2 | Filament → Edit | Edit navigated to a separate page | You lose your place, filter and scroll every time | **Detail panel** opens over the list; Esc/back returns you to where you were |
| 3 | Dashboard, Printers | A spool in "Running low" or an AMS slot wasn't clickable | The most natural next click went nowhere | Every spool, print and holder opens the same panel, on any page |
| 4 | Dashboard | Reported numbers but never said what to do | The kill criterion is **daily use**, so the home screen has to hand you today's jobs | **"Needs you"** queue, each item one click from its fix |
| 5 | Everywhere | Almost every value was an uppercase chip (status, provenance, warnings) | Nothing stands out when everything is loud, so a legend box was needed | Chip/label hierarchy (§5.1); provenance became quiet text with a tooltip |
| 6 | Filament table | Tare, source, gross, value, location and two actions in every row, 2–3 lines each | Too dense to scan 105 rows | **Stock / Weights** column sets; the full breakdown moves to the panel |
| 7 | All pages | No freshness, loading, empty or error state | The app polls every 10 s; stale data shown as live is the worst failure | "Live · updated 3s ago" in the header, an offline banner with the data's age, skeletons, empty states |
| 8 | Filament | "P1S · AMS 1 · **slot 2**" | **Invented.** The schema stores the holder, not the tray position | Holders show fill ("3 / 4") and contents; tray position listed under Future |
| 9 | Mobile | Nav squeezed into icon rows; no reachable primary action | Weighing happens at the shelf, on a phone | Bottom tab bar with badges, a floating **+** button, the panel as a full-screen sheet |
| 10 | Every page | A large proposal banner on top of every screen | Pushed content down; it read as part of the app | A small "Mockup" pill bottom-left, with the legend and demo controls inside |
| 11 | Printers | One card per section in three stacked sections | Two-thirds of the width empty; the lost printer was mid-page | Urgency order (lost → printing → idle) in one row, plus a "Jump to" strip |
| 12 | Printers ↔ Dashboard | "Open printer" landed at the top of the page | You had to find the right card yourself | `04-printers.html#printer=3` scrolls to that card and highlights it |
---
## 2. Navigation model
```
┌ Rail (desktop) / tab bar (phone) ─────────────────────────────┐
│ Dashboard · Filament · Prints [3] · Printers [1] │ ← badges = work waiting
│ Manage: Lists · Labels [4] · Access (admin) │ ← kept on dev, not redesigned
└───────────────────────────────────────────────────────────────┘
Top bar on every page: [ Search… ⌘K ] ● Live · updated 3s ago [◐]
```
- **Four destinations, ranked by how often they're used.** Manage items are
second-tier and grouped separately. Access is labeled "admin".
- **Badges only for work waiting**: Prints = prints missing a spool or weight
(amber); Printers = disconnected printers (red). No badge for totals, so a
badge always means "go here".
- **One page header pattern**: title, a one-line summary with the key numbers,
**one** primary button on the right, everything else under "More ▾".
- **Deep links for every state**, so browser Back, bookmarks and sharing all
work:
| URL | Opens |
|---|---|
| `02-filament.html?f=low&q=black` | Filament, Low view, filtered |
| `…#spool=47` · `#spool=47&tab=weigh` · `&tab=edit&state=conflict` | Spool panel, on a tab, optionally in a demo state |
| `03-prints.html?tab=needs` · `#print=210` | Prints tab / print panel |
| `04-printers.html#printer=3` · `#load=p3-lite` | Scroll to printer / load a holder |
| `?palette=weigh%20black` | Palette open with a query (demo hook) |
---
## 3. Core flows (most frequent first)
| Flow | Steps in iteration 2 | Iteration 1 |
|---|---|---|
| **Morning check** | Dashboard → "Needs you" → Review 3 → **Use** suggestion ×2, type 1 weight | Not possible; no queue |
| **Attribute a print** | Prints › Needs attention: suggestion shown inline → **Use**. Or batch-select → one spool | Not designed |
| **Weigh a spool** | Row **⚖ Weigh**, or palette `weigh copper` → type reading → see before → after → Save | Open the form, find the weigh box, save the whole form |
| **Weigh several** | Filament › Low → **Start weighing** → **Save & weigh next** (j/k also steps) | One at a time, reopening the form each time |
| **Load a spool** | Printers → holder **+ Load** → filter stored spools → pick → Load | Not designed |
| **Move / unload** | Panel › Move: storage or any holder; full holders disabled with the reason | Only by typing a location |
| **Add a spool** | **+ Add spool** (header, FAB, palette) → panel with 5-part name preview | Separate page |
| **Handle a 409** | Panel › Edit: side-by-side you-opened / now / your-edit → reapply or discard | Same content, but on a separate page |
| **Tare unknown** | Tare unknown view → Weigh tab explains, offers spool type or weigh-empty | Only a hint under the form field |
| **Find anything** | `/` or `⌘K` → spools, prints, printers and actions in one list | Filament search only |
---
## 4. Interaction patterns
**Panel (drawer) vs page.** Use a page for a *collection* and a panel for *one
thing*. The panel sits on the right (520 px) and becomes a full-screen sheet
under 860 px. Tabs: Overview · Weigh · Move · Edit for spools; a single view for
prints, with the fix at the top when something is missing.
- Opens with the heading focused (or the tab's main input), traps Tab, and
returns focus to the row that opened it.
- **Esc**, the ✕, clicking the backdrop or browser **Back** closes it.
- **↑ / ↓ buttons and j / k** step through the list it was opened from. The
footer's **Save & weigh next** does the same while weighing.
- **Unsaved changes**: closing Edit with changes shows an inline "Discard
unsaved changes?" bar, not a browser `confirm()`.
**Command palette.** `/` or `⌘K` from anywhere. Empty, it shows actions (Add
spool, Weigh a spool…, Prints needing attention, Switch theme) plus Go to.
Typing matches color, brand, `#id`, location, job name, gcode path or printer.
The `weigh ` prefix turns the list into "Weigh which spool?". ↑↓ ↵ Esc all work.
It uses the ARIA combobox + listbox pattern.
**Row actions.** A whole row is clickable and focusable (↵ opens it). The most
common action (**⚖ Weigh**) is visible on the row; the rest sit in a ⋯ menu,
which is always visible so touch and keyboard users can reach it. Destructive
items are last, after a separator, in red.
**Confirmation.** Only for destructive actions, and it says the consequence:
"Its print history stays… This can't be undone." Everything else saves
directly and confirms with a **toast** that names what changed ("Weighed Copper
· Sunlu: 97 g → 58 g"), with a **View** link. There's no fake Undo, because the
API has none.
**Batch.** Batch attribution follows `dev`'s AttributionQueue (select → attribute
to one spool). The weigh session is the batch pattern for weighing.
**Keyboard map.** `/` `⌘K` palette · `↵` open row · `Esc` close · `j` `k` next
and previous in the panel · `Tab` stays inside the panel while it's open.
---
## 5. Data display rules
### 5.1 Three visual tiers, used for nothing else
| Tier | Looks like | Used for | Examples |
|---|---|---|---|
| **State** | Colored dot/shape + bold word | What something *is doing* | ● Running · ● Finished · ◆ Unknown · ■ Failed · ○ Idle · ◆ Reconnecting |
| **Needs action** | Amber/red text with `!` or `⚠`, or an amber meter | Something *you* should do | `! no spool` · `⚠ 97 g` · `? tare unknown` |
| **Provenance** | Small gray icon + lowercase word, tooltip explains | *Where a number came from* | `✎ typed` · `▦ sliced` · `⎘ recalled` · `⚖ weighed bare` · `◇ community` · `≈ estimated` |
Each state has its own **shape** (dot, diamond, square, ring), so status never
relies on color alone. Chips (pills) remain only for view counts, filters and
the "Future, not built" marker.
### 5.2 Numbers
- Grams: `1,000 g` with thousands separators, always a space before the unit.
Totals over 1 kg: `95.72 kg`. Money: `$1,721.18`.
- Right-aligned tabular numbers in tables; the unit is never dropped.
- **Net is the default.** "Remaining" always means net filament. Gross appears
only with its tare beside it (`262 g tare + 812 g = 1,074 g`), so the three
can't be confused.
- **Unknown is written out** (`unknown`, `no duration`, `—` only for "not
applicable yet"). A missing tare is never 0.
- Durations: `3h 12m`. An upper bound is `≤ 3h 12m` in amber, with the gap
count beneath.
### 5.3 Time
Today and yesterday are relative words plus a clock time (`Today 08:37`,
`Yesterday 21:48`); older dates are `16 Sep 10:20`. Freshness is relative
(`updated 3s ago`, `last contact 6m ago`), because the question it answers is
"can I trust this?".
### 5.4 Meters
The remaining meter carries a **tick at the 200 g line**, so "how close to low"
reads without a number. The fill turns amber below it.
### 5.5 Progressive disclosure
| Where | Shows | Moves to the panel |
|---|---|---|
| Filament row | Spool, remaining meter, location *or* tare/gross (column set), value | Full tare/gross breakdown, source, spool type, label, version, prints from this spool |
| Print row | Job, state, duration, grams + source, spool, cost | Job key, why the source matters, per-color table, use type, archive |
| Printer card | State, now/last job, camera, holder contents | Connection details (collapsed) |
### 5.6 System states on every list
Loading → skeletons of the real layout (never a spinner over an empty page).
Empty → what's missing and one action ("Clear filters"). Stale → header dot
turns amber, and a banner gives the data's age and says saving is paused.
All-done → "✓ Nothing needs you right now."
---
## 6. Accessibility of the new pieces
- Panel: `role="dialog"`, `aria-modal`, labeled by its heading, focus trap, focus
returned on close.
- Palette: `role="combobox"` + `listbox` with `aria-activedescendant`.
- Tabs and segmented controls use `aria-selected`/`aria-pressed` **and** show a
raised fill plus bold text, not shadow alone.
- Sortable headers use `aria-sort` with a visible ▲/▼/↕.
- Meters use `role="meter"` with min, max and now.
- Toasts sit in an `aria-live="polite"` region. The offline banner is
`role="alert"`.
- Skip link, and focus rings on every interactive element (including rows).
- Motion (drawer slide, highlight flash, skeleton shimmer) turns off under
`prefers-reduced-motion`.
- All contrast pairs are unchanged from v3 (54/54 pass). No new colors were
introduced.
---
## 7. Things I fixed for honesty
- **Tray positions removed** (see §1 #8). The Move flow chooses a *holder*, and
full holders are disabled with the reason.
- **"Loaded filament: recorded by Extrudex, not read from the printer"** on
every printer card.
- **API gaps are labeled where they bite**: spool location history
(`spool_location_event` has no read endpoint) and weight source on
`/api/prints`.
- The weigh flow shows before → after and refuses, with the reason, a reading
below tare or above a full spool (the same two checks `dev` does).
## 8. Not redesigned in this iteration
Lists (taxonomy), Labels, Access administration, the Add/Edit **printer** form,
and the phone scan session (`/s/:id`, `/c/:id`). They keep their `dev` screens.
The nav links to them show a toast saying so.
## 9. New open questions
1. **Suggested spool acceptance.** On `dev` a suggestion is "not confirmed or
charged" until accepted. Iteration 2 puts **Use** one click away. Is one
click enough confirmation for a deduction, or should it ask?
1a. On click is fine as long as it's accurate. It can always be manually updated if it was an error.
2. **Weigh session order.** It follows the current list order (least remaining
first). Would shelf order be better? That needs location to be structured,
not free text.
2a. Weight would be best, but the location should also be visible, that way I can see the rolls that are lowest and use them first.
3. **Default landing page.** Dashboard, or straight to Prints › Needs attention
when it's non-empty?
3a. Dashboard
4. **Badge scope.** Should Filament get a badge for "tare unknown", or does that
become nagging, since most tares resolve from a spool type?
4a. Yes, this is good. It identifies an assumed weight and could be off.
5. **Palette actions.** Worth adding "Load ‹spool› into ‹holder›" and "Attribute
‹print› to ‹spool›" as typed commands?
5a. Makes sense to me.
## 10. Extra implementation tasks from iteration 2 (titles only, smallest first)
1. Add a freshness indicator driven by the last successful poll
2. Show an offline banner with the data's age when polling fails
3. Add a 200 g tick to the remaining meter
4. Replace provenance chips with icon + text labels and tooltips
5. Make spool rows and holder contents open a detail panel
6. Deep-link panel state in the URL (`#spool=`, `#print=`, `&tab=`)
7. Add Stock / Weights column sets to Filament
8. Add saved views with counts to Filament (All, Low, Loaded, In storage, Tare unknown)
9. Add a Weigh tab with a live before/after preview
10. Add a weigh session (Save & weigh next)
11. Add a Move tab that picks a holder, disabling full ones
12. Add "+ Load" on printer holders with a stored-spool picker
13. Add a Needs attention tab to Prints with inline suggestion acceptance
14. Add nav badges for prints needing attention and disconnected printers
15. Add a "Needs you" queue to the Dashboard
16. Add a ⌘K command palette over spools, prints, printers and actions
17. Add a mobile bottom tab bar and a floating Add button
18. Order printer cards by urgency and support `#printer=` scroll-to
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+460
View File
@@ -0,0 +1,460 @@
/* Iteration-2 shared behaviour: shell, navigation badges, command palette,
* drawers (spool / print / load), toasts, freshness and demo states.
* Plain browser JS, no dependencies, works offline. */
(function () {
'use strict';
/* ── helpers ─────────────────────────────────────────────── */
var $ = function (s, r) { return (r || document).querySelector(s); };
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
function g(n) { return n == null ? '—' : Math.round(n).toLocaleString('en-US') + ' g'; }
function kg(n) { return (n / 1000).toFixed(2) + ' kg'; }
function usd(n) { return n == null ? '—' : '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
function dur(s) { if (s == null) return '—'; if (s < 60) return '<1m'; var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + 'h ' + String(m).padStart(2, '0') + 'm' : m + 'm'; }
function when(iso) {
if (!iso) return '—';
var d = new Date(iso), t = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
var days = Math.round((new Date(NOW.toDateString()) - new Date(d.toDateString())) / 864e5);
if (days === 0) return 'Today ' + t;
if (days === 1) return 'Yesterday ' + t;
return d.getDate() + ' ' + d.toLocaleString('en-GB', { month: 'short' }) + ' ' + t;
}
function ago(sec) { if (sec < 60) return sec + 's ago'; var m = Math.floor(sec / 60); if (m < 60) return m + 'm ago'; var h = Math.floor(m / 60); return h < 24 ? h + 'h ago' : Math.floor(h / 24) + 'd ago'; }
function since(iso) { return Math.max(0, Math.round((NOW - new Date(iso)) / 1000)); }
var byId = function (arr, id) { for (var i = 0; i < arr.length; i++) if (arr[i].id === id) return arr[i]; return null; };
function spool(id) { return byId(SPOOLS, id); }
function printer(id) { return byId(PRINTERS, id); }
function print(id) { return byId(PRINTS, id); }
function container(cid) {
for (var i = 0; i < PRINTERS.length; i++) for (var j = 0; j < PRINTERS[i].containers.length; j++)
if (PRINTERS[i].containers[j].id === cid) return { printer: PRINTERS[i], c: PRINTERS[i].containers[j] };
return null;
}
function contents(cid) { return SPOOLS.filter(function (s) { return s.cont === cid; }); }
function fullName(s) { return [s.brand, s.speed, s.mat, s.type, s.fin].filter(Boolean).join(' '); }
function short(s) { return s.brand + ' ' + s.color; }
function where(s) {
if (!s.cont) return { loaded: false, text: s.loc || 'Storage', short: s.loc || 'Storage' };
var c = container(s.cont);
return { loaded: true, text: c.printer.name + ' · ' + c.c.label, short: c.printer.model + ' · ' + c.c.label, printer: c.printer };
}
function gross(s) { return s.tare == null ? null : s.tare + s.rem; }
function value(s) { return s.rem / 1000 * s.kg; }
function low(s) { return s.rem < LOW; }
function sw(colors, cls) { return '<span class="swatch ' + (cls || '') + '" aria-hidden="true">' + colors.map(function (h) { return '<i style="background:' + h + '"></i>'; }).join('') + '</span>'; }
function meter(s) {
var pct = Math.max(0, Math.min(100, s.rem / s.tot * 100)), tick = Math.min(100, LOW / s.tot * 100);
return '<div class="meter' + (low(s) ? ' is-low' : '') + '" role="meter" aria-valuemin="0" aria-valuemax="' + s.tot + '" aria-valuenow="' + s.rem + '" aria-label="' + esc(s.color) + ' remaining"><span style="width:' + pct + '%"></span><i class="tick" style="left:' + tick + '%" title="200 g low-stock line"></i></div>';
}
/* provenance: quiet icon + words */
var TSRC = { spool: ['⚖', 'weighed bare', ''], manufacturer: ['◆', 'manufacturer', ''], community: ['◇', 'community', ''], estimated: ['≈', 'estimated', 'warn'] };
function tareProv(s) {
if (s.tare == null) return '<span class="prov warn"><i>?</i>tare unknown</span>';
var t = TSRC[s.tsrc]; return '<span class="prov ' + t[2] + '" title="' + esc(s.tsrc === 'spool' ? 'This reel was weighed empty' : 'From spool type: ' + s.stype) + '"><i>' + t[0] + '</i>' + t[1] + '</span>';
}
function remProv(s) { return s.remSrc === 'weighed' ? '<span class="prov"><i>⚖</i>weighed' + (s.weighedAt ? ' ' + esc(s.weighedAt) : '') + '</span>' : '<span class="prov"><i>✎</i>typed</span>'; }
var WSRC = { typed: ['✎', 'typed', '', 'A measurement someone entered. Survives an identity correction.'], sliced: ['▦', 'sliced', 'info', "Read from this run's 3MF over FTPS."], recalled: ['⎘', 'recalled', 'info', 'Copied from the weight remembered for this gcode path. No longer written.'], unknown: ['?', 'source unknown', 'warn', 'Written before the source was recorded. Neither correction path will move it.'] };
function weightProv(p) { if (!p.wsrc) return ''; var w = WSRC[p.wsrc]; return '<span class="prov ' + w[2] + '" title="' + esc(w[3]) + '"><i>' + w[0] + '</i>' + w[1] + '</span>'; }
function durCell(p) {
if (p.status === 'running') return '<span class="num">' + dur(since(p.start)) + '</span><span class="sub">so far</span>';
if (p.status === 'unknown') return '<span class="muted" title="Nobody saw this print end. The only measurable figure is the outage, which is not how long anything printed.">no duration</span><span class="sub">end not seen</span>';
if (p.gaps) return '<span class="num strong" style="color:var(--status-warning)" title="The connection dropped ' + (p.gaps === 1 ? 'once' : p.gaps + ' times') + ' during this print. That time is inside the figure and nobody watched it, so it is an upper bound.">≤ ' + dur(p.dur) + '</span><span class="sub prov warn">upper bound · ' + p.gaps + ' gap' + (p.gaps > 1 ? 's' : '') + '</span>';
if (p.gaps == null) return '<span class="num">' + dur(p.dur) + '</span><span class="sub" title="Predates gap counting: nobody asked whether the connection dropped.">gaps not counted</span>';
return '<span class="num">' + dur(p.dur) + '</span>';
}
var PST = { running: ['run', 'Running'], finished: ['ok', 'Finished'], failed: ['err', 'Failed'], unknown: ['warn', 'Unknown'] };
function printSt(p) { var s = PST[p.status]; return '<span class="st ' + s[0] + '">' + s[1] + '</span>'; }
function printerSt(pr) {
if (!pr.connected) return '<span class="st warn">' + (pr.lifecycle === 'reconnecting' ? 'Reconnecting' : 'Offline') + '</span>';
if (pr.state === 'RUNNING') return '<span class="st run">Printing</span>';
if (pr.state === 'PAUSE') return '<span class="st warn">Paused</span>';
if (pr.state === 'FAILED') return '<span class="st err">Failed</span>';
return '<span class="st idle">Idle</span>';
}
/* what a print still needs; empty while running */
function needs(p) {
if (p.status === 'running') return [];
var n = [];
if (p.grams == null) n.push({ k: 'weight', t: 'filament weight' });
if (p.filaments) p.filaments.forEach(function (f) { if (f.spool == null) n.push({ k: 'spool', t: 'spool for color ' + (f.slot + 1), f: f }); });
else if (p.spool == null) n.push({ k: 'spool', t: 'spool' });
return n;
}
function printsNeeding() { return PRINTS.filter(function (p) { return needs(p).length && !p.archived; }); }
function spoolLinks(p) {
if (p.filaments) return p.filaments.map(function (f) { var s = f.spool && spool(f.spool); return s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '">' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '</a>' : '<span class="prov warn"><i>!</i>color ' + (f.slot + 1) + ' unattributed</span>'; }).join('<br>');
var s = p.spool && spool(p.spool);
return s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '" class="nowrap">' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '</a>' : (p.status === 'running' ? '<span class="muted">—</span>' : '<span class="prov warn"><i>!</i>no spool</span>');
}
/* ── shell ───────────────────────────────────────────────── */
var ICON = {
dashboard: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="2"/><rect x="14" y="3" width="7" height="5" rx="2"/><rect x="14" y="12" width="7" height="9" rx="2"/><rect x="3" y="16" width="7" height="5" rx="2"/></svg>',
filament: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v6M12 15v6"/></svg>',
prints: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 20h14M7 16l3-9h4l3 9M9 12h6"/></svg>',
printers: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 17h8M9 7h6v5H9z"/></svg>',
lists: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/></svg>',
labels: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="7.5" r="1.5"/></svg>',
access: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0M17 11l2 2 4-4"/></svg>',
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></svg>',
sun: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>'
};
var PAGES = [['dashboard', 'Dashboard', '01-dashboard.html'], ['filament', 'Filament', '02-filament.html'], ['prints', 'Prints', '03-prints.html'], ['printers', 'Printers', '04-printers.html']];
function badges() {
var need = printsNeeding().length, down = PRINTERS.filter(function (p) { return !p.connected; }).length;
return {
prints: need ? '<span class="count count-warn" title="' + need + ' prints need a spool or weight">' + need + '</span>' : '',
printers: down ? '<span class="count count-err" title="' + down + ' printer not connected">' + down + '</span>' : ''
};
}
function shell(page) {
var b = badges();
var nav = PAGES.map(function (p) { return '<a href="' + p[2] + '"' + (p[0] === page ? ' aria-current="page"' : '') + '>' + ICON[p[0]] + '<span>' + p[1] + '</span>' + (b[p[0]] || '') + '</a>'; }).join('');
var unl = SPOOLS.filter(function (s) { return !s.label; }).length;
var rail = '<nav class="rail nm-raised" aria-label="Main">' +
'<a class="wordmark" href="01-dashboard.html" style="text-decoration:none;color:inherit"><span class="wordmark-mark" aria-hidden="true">X</span>Extrudex</a>' +
'<div class="nav nav-group">' + nav + '</div>' +
'<div class="nav nav-group"><span class="nav-label">Manage</span>' +
'<a href="#" data-soon="Lists">' + ICON.lists + '<span>Lists</span></a>' +
'<a href="#" data-soon="Labels">' + ICON.labels + '<span>Labels</span><span class="count" style="color:var(--text-secondary)" title="' + unl + ' spools without a printed label (sample)">' + unl + '</span></a>' +
'<a href="#" data-soon="Access">' + ICON.access + '<span>Access</span><span class="sub" style="margin-left:auto">admin</span></a></div>' +
'<div class="rail-foot"><div class="seg" role="group" aria-label="Color theme"><button type="button" data-set-theme="light">Light</button><button type="button" data-set-theme="dark">Dark</button></div>' +
'<span class="small muted">Signed in · operator</span></div></nav>';
var tabbar = '<nav class="tabbar nm-raised" aria-label="Main">' + PAGES.map(function (p) { return '<a href="' + p[2] + '"' + (p[0] === page ? ' aria-current="page"' : '') + '>' + ICON[p[0]] + (b[p[0]] || '') + '<span>' + p[1] + '</span></a>'; }).join('') + '</nav>';
var top = '<div class="topbar"><button class="search-trigger" type="button" data-palette>' + ICON.search + '<span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"><span class="long">Search spools, prints, printers…</span><span class="short">Search…</span></span><span class="kbd long">⌘K</span></button>' +
'<span class="fresh" id="fresh" role="status"><span class="dot" aria-hidden="true"></span><span id="fresh-t">Live · updated 3s ago</span></span>' +
'<button class="btn btn-icon btn-sm" type="button" data-toggle-theme aria-label="Switch color theme">' + ICON.sun + '</button></div>';
var mock = '<div class="mock"><button type="button" data-menu="mockmenu" aria-expanded="false">Mockup<span class="long"> · v3 iteration 2</span></button><div class="menu" id="mockmenu" role="dialog" aria-label="About this mockup">' +
'<p><b>Sample data</b>, based on the real inventory. Nothing is wired to the API.</p>' +
'<p><span class="prov info"><i>◈</i>feature</span> fields exist only on the printer-connections branch. <span class="prov warn"><i>⚠</i>API gap</span> means stored but not served.</p><hr>' +
'<button type="button" data-demo="loading">Replay first load (skeletons)</button>' +
'<button type="button" data-demo="offline">Simulate polling failure</button>' +
'<button type="button" data-demo="online">Back to live</button><hr>' +
'<a href="index.html">Flow map of all screens →</a></div></div>';
var drawer = '<div class="drawer-scrim" data-close-drawer></div><aside class="drawer nm-dialog" id="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title" hidden></aside>';
var pal = '<div class="pal-scrim" id="pal" role="presentation"><div class="pal nm-dialog" role="dialog" aria-modal="true" aria-label="Search and commands"><div class="pal-input">' + ICON.search +
'<input id="pal-q" role="combobox" aria-expanded="true" aria-controls="pal-list" aria-autocomplete="list" placeholder="Type a spool, #id, job, printer or action…" autocomplete="off"><span class="kbd">esc</span></div>' +
'<div class="pal-list" id="pal-list" role="listbox"></div><div class="pal-foot"><span><span class="kbd">↑</span> <span class="kbd">↓</span> move</span><span><span class="kbd">↵</span> open</span><span><span class="kbd">/</span> or <span class="kbd">⌘K</span> anywhere</span></div></div></div>';
return { rail: rail, tabbar: tabbar, top: top, rest: mock + drawer + pal + '<div class="toasts" id="toasts" aria-live="polite"></div>' };
}
/* ── theme ───────────────────────────────────────────────── */
function setTheme(v) {
document.documentElement.setAttribute('data-theme', v);
$$('[data-set-theme]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.getAttribute('data-set-theme') === v)); });
try { localStorage.setItem('extrudex-v3-theme', v); } catch (e) {}
}
/* ── toasts ──────────────────────────────────────────────── */
function updateBadges() {
var b = badges();
$$('.nav a, .tabbar a').forEach(function (a) {
var key = { '03-prints.html': 'prints', '04-printers.html': 'printers' }[a.getAttribute('href')]; if (!key) return;
var old = a.querySelector('.count'); if (old) old.parentNode.removeChild(old);
if (b[key]) a.querySelector('span').insertAdjacentHTML(a.closest('.tabbar') ? 'beforebegin' : 'afterend', b[key]);
});
}
function toast(html, kind) {
setTimeout(updateBadges, 0);
var t = document.createElement('div'); t.className = 'toast ' + (kind || ''); t.setAttribute('role', 'status');
t.innerHTML = '<span>' + html + '</span><button class="btn btn-sm btn-icon" type="button" aria-label="Dismiss">✕</button>';
$('#toasts').appendChild(t);
var kill = function () { if (t.parentNode) t.parentNode.removeChild(t); };
t.querySelector('button').onclick = kill; setTimeout(kill, 6000);
}
/* ── drawer ──────────────────────────────────────────────── */
var D = { kind: null, id: null, tab: 'overview', state: 'normal', list: null, lastFocus: null, dirty: false };
function openDrawer(kind, id, tab, push) {
D.kind = kind; D.id = id; D.tab = tab || 'overview'; D.state = 'normal'; D.dirty = false;
if (!document.body.classList.contains('drawer-open')) D.lastFocus = document.activeElement;
renderDrawer();
var el = $('#drawer'); el.hidden = false;
requestAnimationFrame(function () { document.body.classList.add('drawer-open'); var f = el.querySelector('[autofocus]') || el.querySelector('h2'); if (f) { f.setAttribute('tabindex', f.tagName === 'H2' ? '-1' : f.getAttribute('tabindex')); f.focus(); } });
var h = '#' + kind + '=' + id + (D.tab !== 'overview' ? '&tab=' + D.tab : '');
if (push !== false && location.hash !== h) history.pushState({ d: 1 }, '', h);
$$('[data-row-spool],[data-row-print]').forEach(function (r) { r.classList.toggle('is-selected', (kind === 'spool' && +r.getAttribute('data-row-spool') === id) || (kind === 'print' && +r.getAttribute('data-row-print') === id)); });
}
function closeDrawer(push) {
if (D.dirty && !confirmDiscard()) return;
document.body.classList.remove('drawer-open'); D.kind = null;
setTimeout(function () { if (!D.kind) $('#drawer').hidden = true; }, 240);
if (push !== false && location.hash) history.pushState({}, '', location.pathname + location.search);
$$('.is-selected').forEach(function (r) { r.classList.remove('is-selected'); });
if (D.lastFocus && D.lastFocus.focus) D.lastFocus.focus();
}
function confirmDiscard() {
var bar = $('#discard-bar'); if (bar) { bar.hidden = false; bar.querySelector('button').focus(); return false; }
return true;
}
function renderDrawer() {
var el = $('#drawer');
if (D.kind === 'spool') el.innerHTML = spoolDrawer(spool(D.id));
else if (D.kind === 'new') el.innerHTML = editDrawer(null);
else if (D.kind === 'print') el.innerHTML = printDrawer(print(D.id));
else if (D.kind === 'load') el.innerHTML = loadDrawer(D.id);
if (window.PAGE && PAGE.onDrawer) PAGE.onDrawer();
}
function head(icon, title, sub, extra) {
var list = D.list || [], i = list.indexOf(D.id);
var nav = (D.kind === 'spool' && i >= 0 && list.length > 1) ? '<div class="drawer-nav"><button class="btn btn-sm btn-icon" type="button" data-step="-1" aria-label="Previous spool in list" ' + (i === 0 ? 'disabled' : '') + '>↑</button><button class="btn btn-sm btn-icon" type="button" data-step="1" aria-label="Next spool in list" ' + (i === list.length - 1 ? 'disabled' : '') + '>↓</button></div>' : '';
return '<div class="drawer-head">' + icon + '<div style="flex:1;min-width:0"><h2 id="drawer-title">' + title + '</h2><span class="sub">' + sub + '</span>' + (extra || '') + '</div>' + nav + '<button class="btn btn-sm btn-icon" type="button" data-close-drawer aria-label="Close panel (Esc)">✕</button></div>';
}
function tabs(list) {
return '<div class="drawer-tabs"><div class="tabs" role="tablist" aria-label="Spool panel">' + list.map(function (t) { return '<button type="button" role="tab" data-tab="' + t[0] + '" aria-selected="' + (D.tab === t[0]) + '">' + t[1] + '</button>'; }).join('') + '</div></div>';
}
function spoolDrawer(s) {
var w = where(s);
var h = head(sw(s.c, '" style="width:44px;height:44px'), esc(s.color) + ' <span class="muted" style="font-weight:400">#' + s.id + '</span>', esc(fullName(s)) + ' · ' + esc(w.text));
var t = tabs([['overview', 'Overview'], ['weigh', 'Weigh'], ['move', w.loaded ? 'Move' : 'Load / move'], ['edit', 'Edit']]);
var body = '', foot = '';
if (D.tab === 'overview') {
var used = PRINTS.filter(function (p) { return p.spool === s.id || (p.filaments || []).some(function (f) { return f.spool === s.id; }); });
var gr = gross(s);
body = '<div class="nm-inset panel"><div class="row" style="align-items:baseline"><span style="font-size:34px;font-weight:800;line-height:1" class="num' + (low(s) ? '" style="color:var(--status-warning)' : '') + '">' + Math.round(s.rem).toLocaleString('en-US') + '<span style="font-size:15px"> g</span></span><span class="muted">of ' + g(s.tot) + ' net</span><span class="spacer"></span>' + remProv(s) + '</div>' +
'<div style="margin-top:12px">' + meter(s) + '</div>' + (low(s) ? '<p class="small" style="margin:10px 0 0;color:var(--status-warning)"><b>⚠ Below the 200 g line.</b> Reorder or finish it on a small job.</p>' : '') + '</div>' +
'<div class="panel nm-raised-sm"><h3>On the scale</h3>' + (gr == null
? '<p class="small" style="margin:0">Gross is <b>unknown</b> because no empty-spool weight is known. It is never treated as 0.</p><div class="row" style="margin-top:12px"><button class="btn btn-sm" type="button" data-tab="weigh">Set a tare</button></div>'
: '<dl class="kv"><dt>Empty spool (tare)</dt><dd>' + g(s.tare) + ' ' + tareProv(s) + '</dd><dt>+ Filament (net)</dt><dd>' + g(s.rem) + '</dd><dt class="strong" style="color:var(--text-primary)">= Gross, on a scale</dt><dd class="strong">' + g(gr) + '</dd></dl>') + '</div>' +
'<dl class="kv"><dt>Location</dt><dd>' + (w.loaded ? '<a href="04-printers.html#printer=' + w.printer.id + '">' + esc(w.text) + '</a>' : esc(w.text)) + ' · <button class="btn btn-sm" type="button" data-tab="move" style="min-height:26px;padding:2px 8px">Move</button></dd>' +
'<dt>Value left</dt><dd>' + usd(value(s)) + ' <span class="sub" style="display:inline">at ' + usd(s.kg) + '/kg</span></dd>' +
'<dt>Spool type</dt><dd>' + (s.stype ? esc(s.stype) : '<span class="muted">none chosen</span>') + '</dd>' +
'<dt>Label</dt><dd>' + (s.label ? 'printed' : '<span class="prov warn"><i>!</i>not printed</span> <button class="btn btn-sm" type="button" data-fake="Label PDF generated for #' + s.id + '" style="min-height:26px;padding:2px 8px">Print label</button>') + '</dd>' +
'<dt>Record</dt><dd class="muted">version ' + s.v + '</dd></dl>' +
'<div><h3 class="section" style="margin-bottom:8px">Prints from this spool <span class="muted">· ' + used.length + '</span></h3>' + (used.length ? '<div class="stack" style="gap:6px">' + used.map(function (p) { var f = (p.filaments || []).filter(function (f) { return f.spool === s.id; })[0]; return '<a class="row nm-raised-sm" style="padding:8px 12px;text-decoration:none;color:inherit;gap:10px" href="#print=' + p.id + '" data-print="' + p.id + '">' + printSt(p) + '<span class="strong" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis">' + esc(p.job) + '</span><span class="num">' + g(f ? f.grams : p.grams) + '</span><span class="sub" style="display:inline">' + when(p.start) + '</span></a>'; }).join('') + '</div>' : '<p class="small muted" style="margin:0">No prints drew from this spool yet.</p>') + '</div>' +
'<p class="small muted" style="margin:0"><span class="prov warn"><i>⚠</i>API gap</span> Location history is stored in <code>spool_location_event</code> but there is no endpoint to read it.</p>';
foot = '<button class="btn btn-danger btn-sm" type="button" data-delete="' + s.id + '" style="margin-right:auto">Delete…</button><button class="btn" type="button" data-tab="edit">Edit</button><button class="btn btn-primary" type="button" data-tab="weigh" ' + (s.tare == null ? 'title="Needs a tare first"' : '') + '>Weigh</button>';
}
if (D.tab === 'weigh') {
if (s.tare == null) {
body = '<div class="banner"><b>?</b><span>This spool has <b>no empty-spool weight</b>, so a scale reading can\'t be turned into filament. Set one first. It is never assumed to be 0.</span></div>' +
'<label class="field stack" style="gap:6px"><span class="strong small">Pick a spool type</span><select class="select" autofocus><option>Choose…</option><option>Generic — 1 kg spool (assumed) · 180 g · estimated</option><option>Creality — Hyper cardboard · 176 g · community</option><option>Creality — legacy plastic · 138 g · community</option></select><span class="sub">Fastest. The figure\'s trust level is shown next to it.</span></label>' +
'<div class="row"><hr style="flex:1;border:0;border-top:1px solid var(--border-hairline)"><span class="sub">or, more accurate</span><hr style="flex:1;border:0;border-top:1px solid var(--border-hairline)"></div>' +
'<label class="field stack" style="gap:6px"><span class="strong small">Weigh the empty spool</span><input class="input num" inputmode="numeric" placeholder="e.g. 210 g"><span class="sub">Only possible once the spool is used up, or from an identical empty.</span></label>';
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" data-fake="Tare saved for #' + s.id + '. Weigh-in unlocked.">Save tare</button>';
} else {
body = '<div class="panel nm-inset"><label class="stack" style="gap:8px"><span class="strong">Whole spool on the scale</span><input class="input num" id="scale" inputmode="numeric" autofocus placeholder="full reads ' + g(s.tare + s.tot) + '" style="font-size:22px;min-height:56px"><span class="sub">Remaining = reading − ' + g(s.tare) + ' tare (' + tareProv(s) + ')</span></label></div>' +
'<div class="panel nm-raised-sm" id="weigh-out" aria-live="polite"><h3>Result</h3><div class="row"><span class="muted">Now</span><b class="num">' + g(s.rem) + '</b><span class="muted">→</span><b class="num" id="w-after">—</b><span class="spacer"></span><span id="w-diff" class="num"></span></div><p class="small muted" id="w-note" style="margin:8px 0 0">Type the reading and the result appears here before you save.</p></div>';
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" id="w-save" disabled>Save weigh-in</button>' + (D.list && D.list.indexOf(s.id) < D.list.length - 1 ? '<button class="btn" type="button" id="w-next" disabled>Save & weigh next ↓</button>' : '');
}
}
if (D.tab === 'move') {
var dests = PRINTERS.map(function (pr) {
return '<div class="stack" style="gap:8px"><div class="row"><b class="small">' + esc(pr.name) + '</b>' + (pr.connected ? '' : '<span class="prov warn"><i>⚡</i>disconnected (loading still recorded)</span>') + '</div><div class="row" style="gap:8px;align-items:stretch">' +
pr.containers.map(function (c) { var n = contents(c.id).length, full = n >= c.cap && s.cont !== c.id, here = s.cont === c.id; return '<button type="button" class="slot" style="min-height:64px;flex:1" data-dest="' + c.id + '" ' + (full ? 'disabled title="Full: unload a spool first"' : '') + ' aria-pressed="' + here + '"><span class="sn">' + esc(c.label) + '</span><span class="g">' + n + ' / ' + c.cap + (here ? ' · here now' : full ? ' · full' : '') + '</span></button>'; }).join('') + '</div></div>';
}).join('');
body = '<p class="small muted" style="margin:0">Where is the spool going? Full holders are disabled. Extrudex records which holder a spool is in, not its tray position.</p>' +
'<div class="stack" style="gap:8px"><b class="small">Storage</b><div class="row" style="gap:8px;align-items:stretch"><button type="button" class="slot" style="min-height:64px;flex:1" data-dest="storage" aria-pressed="' + !s.cont + '"><span class="sn">Shelf / storage</span><span class="g">' + (s.cont ? 'unload' : 'here now') + '</span></button></div>' +
'<label class="stack" style="gap:6px"><span class="sub">Where in storage (optional)</span><input class="input" value="' + esc(s.loc || '') + '" placeholder="Shelf A2, Dry box 1…"></label></div>' + dests;
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" id="m-save" disabled>Move spool</button>';
}
if (D.tab === 'edit') { return editDrawer(s); }
return h + t + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
}
function editDrawer(s) {
var isNew = !s; s = s || { id: null, c: ['#888888'], color: '', brand: 'Sunlu', speed: null, mat: 'PLA', type: null, fin: 'Basic', rem: 1000, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', kg: 0, loc: '', v: null };
var st = D.state;
var h = isNew ? head('<span class="swatch" style="width:44px;height:44px"><i style="background:#888"></i></span>', 'Add spool', 'New spools carry no version') : head(sw(s.c, '" style="width:44px;height:44px'), 'Edit ' + esc(s.color) + ' <span class="muted" style="font-weight:400">#' + s.id + '</span>', 'Opened 09:32 at version ' + (st === 'conflict' ? s.v - 1 : s.v));
var t = isNew ? '' : tabs([['overview', 'Overview'], ['weigh', 'Weigh'], ['move', where(s).loaded ? 'Move' : 'Load / move'], ['edit', 'Edit']]);
function sel(label, val, req, opts) { return '<label class="stack" style="gap:6px"><span class="small strong">' + label + ' ' + (req ? '<span class="sub" style="display:inline;color:var(--text-accent)">required</span>' : '<span class="sub" style="display:inline">optional</span>') + '</span><select class="select"><option>' + (val || 'None') + '</option>' + (opts || '') + '</select></label>'; }
var demo = '<div class="row small" style="gap:8px"><span class="muted">Demo state</span><div class="tabs" role="group" aria-label="Demo state">' + [['normal', 'Normal'], ['errors', '400 errors'], ['conflict', '409 conflict']].map(function (x) { return '<button type="button" data-state="' + x[0] + '" aria-selected="' + (st === x[0]) + '">' + x[1] + '</button>'; }).join('') + '</div></div>';
var conflict = st === 'conflict' ? '<div class="nm-inset" role="alert" style="padding:16px;border:2px solid var(--status-warning);background:var(--wash-warning);border-radius:var(--radius-lg)"><b style="color:var(--status-warning)">⚠ This spool changed since you opened it. Nothing was saved.</b><p class="small" style="margin:8px 0">Someone weighed it at 09:39 (version ' + (s.v - 1) + ' → ' + s.v + '). Saving would have put the old remaining weight back.</p>' +
'<table class="tbl" style="font-size:12px"><thead><tr><th>Field</th><th class="r">You opened</th><th class="r">Now</th><th class="r">Your edit</th></tr></thead><tbody><tr><td>Remaining</td><td class="r">860 g</td><td class="r strong" style="color:var(--status-warning)">' + g(s.rem) + ' ⚖</td><td class="r muted">—</td></tr><tr><td>Location</td><td class="r">Shelf A3</td><td class="r">Shelf A3</td><td class="r strong accent">' + esc(where(s).short) + '</td></tr></tbody></table>' +
'<div class="row" style="margin-top:12px"><button class="btn btn-primary btn-sm" type="button" data-fake="Reloaded version ' + s.v + ' and reapplied your location change. Review, then save.">Reload & reapply my change</button><button class="btn btn-sm" type="button" data-state="normal">Reload, discard mine</button></div></div>' : '';
var errsum = st === 'errors' ? '<div role="alert" style="padding:14px 16px;border:2px solid var(--status-error);background:var(--wash-error);border-radius:var(--radius-lg)"><b style="color:var(--status-error)">⚠ 2 fields need fixing. Nothing was saved.</b><ul class="small" style="margin:6px 0 0;padding-left:18px"><li><a href="#e-rem">Remaining is more than the spool holds</a></li><li><a href="#e-name">Color name is empty</a></li></ul></div>' : '';
var name = '<div class="nm-inset" style="padding:12px 14px;border-radius:var(--radius-md)"><span class="sub">Reads as</span><div class="strong" style="font-size:17px">' + esc(fullName(s)) + '</div><span class="sub">brand · <i>speed</i> · material · <i>type</i> · finish (italic = optional)</span></div>';
var body = demo + conflict + errsum + name +
'<div class="fr2">' + sel('Brand', s.brand, 1) + sel('Print speed', s.speed, 0, '<option>Rapid</option><option>High Speed</option>') + sel('Material', s.mat, 1) + sel('Filament type', s.type, 0, '<option>CF</option><option>+ 2.0</option>') + sel('Finish', s.fin, 1) + '</div>' +
'<div class="fr2"><div class="stack' + (st === 'errors' ? ' field has-error' : '') + '" id="e-name" style="gap:6px"><label class="small strong" for="f-name">Color name <span class="sub" style="display:inline;color:var(--text-accent)">required</span></label><input class="input" id="f-name" value="' + (st === 'errors' ? '' : esc(s.color)) + '" ' + (st === 'errors' ? 'aria-invalid="true" aria-describedby="e-name-m"' : '') + ' placeholder="e.g. Charcoal">' + (st === 'errors' ? '<p class="field-error" id="e-name-m" style="margin:0">Give the color a name, even "Unknown". <span class="sub" style="display:inline">server: <code>color_name is required</code></span></p>' : '') + '</div>' +
'<label class="stack" style="gap:6px"><span class="small strong">Colors <span class="sub" style="display:inline">1–3</span></span><span class="row" style="gap:8px">' + sw(s.c) + '<input class="input" value="' + s.c[0] + '" style="max-width:110px"><button class="btn btn-sm" type="button">+ color</button></span></label></div>' +
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Total <span class="sub" style="display:inline">net, as sold</span></span><input class="input num" value="' + g(s.tot) + '"></label>' +
'<div class="stack' + (st === 'errors' ? ' field has-error' : '') + '" id="e-rem" style="gap:6px"><label class="small strong" for="f-rem">Remaining <span class="sub" style="display:inline">net</span></label><input class="input num" id="f-rem" value="' + (st === 'errors' ? '1,040 g' : g(s.rem)) + '" ' + (st === 'errors' ? 'aria-invalid="true" aria-describedby="e-rem-m"' : '') + '>' + (st === 'errors' ? '<p class="field-error" id="e-rem-m" style="margin:0">1,040 g is more than this spool holds (' + g(s.tot) + '). <span class="sub" style="display:inline">server: <code>remaining_grams cannot exceed total_grams</code></span></p>' : '<span class="sub">To measure it, use the <b>Weigh</b> tab instead.</span>') + '</div></div>' +
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Spool type <span class="sub" style="display:inline">supplies the tare</span></span><select class="select"><option>' + esc(s.stype || 'None chosen') + '</option></select></label>' +
'<label class="stack" style="gap:6px"><span class="small strong">Empty spool (tare)</span><input class="input num" value="' + (s.tare == null ? '' : g(s.tare)) + '" placeholder="unknown"><span class="sub">' + (s.tare == null ? '<span class="prov warn"><i>?</i>unknown, never 0</span>' : tareProv(s)) + '</span></label></div>' +
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Cost per kg</span><input class="input num" value="' + usd(s.kg) + '"></label><label class="stack" style="gap:6px"><span class="small strong">Storage location <span class="sub" style="display:inline">optional</span></span><input class="input" value="' + esc(s.loc || '') + '"></label></div>' +
'<div class="banner" id="discard-bar" hidden><span style="flex:1">Discard unsaved changes?</span><button class="btn btn-sm" type="button" data-discard>Discard</button><button class="btn btn-sm" type="button" data-keep>Keep editing</button></div>';
var foot = (st === 'conflict' ? '<span class="small muted" style="margin-right:auto">Save is off until you choose a version.</span>' : '') + '<button class="btn" type="button" data-' + (isNew ? 'close-drawer' : 'tab="overview"') + '>Cancel</button><button class="btn btn-primary" type="button" ' + (st === 'conflict' ? 'disabled' : 'data-save') + '>' + (isNew ? 'Add spool' : 'Save changes') + '</button>';
return h + t + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
}
function printDrawer(p) {
var pr = printer(p.printer), n = needs(p);
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.prints + '</span>', esc(p.job), printSt(p) + ' · ' + esc(pr.name) + ' · print #' + p.id);
var fix = '';
if (n.length) {
fix = '<div class="nm-inset panel" style="border:1px solid var(--status-warning)"><h3 style="color:var(--status-warning)">Needs ' + n.map(function (x) { return x.t; }).join(' + ') + '</h3>' + fixers(p) + '</div>';
}
var body = fix + '<dl class="kv"><dt>Printer</dt><dd><a href="04-printers.html#printer=' + pr.id + '">' + esc(pr.name) + '</a></dd><dt>Started</dt><dd>' + when(p.start) + '</dd><dt>Ended</dt><dd>' + (p.end ? when(p.end) : p.status === 'running' ? '<span class="muted">still running</span>' : '—') + '</dd>' +
'<dt>Duration</dt><dd>' + durCell(p) + '</dd>' +
'<dt>Job key</dt><dd><code>' + esc(p.key) + '</code><span class="sub">The identity. Two jobs can share a name, never a path.</span></dd>' +
'<dt>Filament</dt><dd>' + (p.grams == null ? '<span class="muted">not known yet</span>' : '<b class="num">' + g(p.grams) + '</b> ' + weightProv(p) + (p.status === 'failed' ? '<span class="sub">Failed prints record the full job weight. Correct by hand if needed.</span>' : '')) + '</dd>' +
'<dt>Spool</dt><dd>' + spoolLinks(p) + '</dd><dt>Cost</dt><dd>' + (p.cost == null ? '<span class="muted">pending: needs weight and spool</span>' : usd(p.cost) + (p.filaments && p.filaments.some(function (f) { return f.spool == null; }) ? ' <span class="sub" style="display:inline">so far</span>' : '')) + '</dd>' +
'<dt>Use</dt><dd><div class="tabs" role="group" aria-label="Use type"><button type="button" aria-selected="' + (p.use === 'business') + '">Business</button><button type="button" aria-selected="' + (p.use === 'personal') + '">Personal</button></div>' + (p.use == null ? '<span class="sub">Not recorded</span>' : '') + '</dd></dl>' +
(p.filaments ? '<div><h3 class="section">Colors</h3><table class="tbl"><thead><tr><th>#</th><th>Color</th><th>Spool</th><th class="r">Grams</th><th class="r">Cost</th></tr></thead><tbody>' + p.filaments.map(function (f) { var s = f.spool && spool(f.spool); return '<tr><td>' + (f.slot + 1) + '</td><td><span class="swatch swatch-sm"><i style="background:' + f.hex + '"></i></span> <code>' + f.hex + '</code></td><td>' + (s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '">' + esc(short(s)) + '</a>' : '<span class="prov warn"><i>!</i>unattributed</span>') + '</td><td class="r">' + g(f.grams) + '</td><td class="r">' + usd(f.cost) + '</td></tr>'; }).join('') + '</tbody></table></div>' : '');
var foot = p.status === 'running' ? '<span class="small muted">Name and weight are captured when the print ends.</span>' : '<button class="btn btn-sm" type="button" data-fake="Archived ' + esc(p.job) + '. Accounting unchanged." style="margin-right:auto">Archive</button><button class="btn" type="button" data-close-drawer>Done</button>';
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
}
function fixers(p) {
var out = '';
if (p.grams == null) out += '<label class="stack" style="gap:6px;margin-bottom:12px"><span class="small strong">Filament used</span><span class="row" style="gap:8px"><input class="input num" style="max-width:140px" placeholder="grams" inputmode="numeric" autofocus><button class="btn btn-sm" type="button" data-fake="Saved ' + esc(p.job) + ' weight · remembered for ' + esc(p.key) + '">Save weight</button></span><span class="sub">From the slicer. Remembered for <code>' + esc(p.key) + '</code> so later runs fill in by themselves.</span></label>';
var slots = p.filaments ? p.filaments.filter(function (f) { return f.spool == null; }) : (p.spool == null ? [{ suggested: p.suggested, slot: null }] : []);
slots.forEach(function (f) {
var sug = f.suggested && spool(f.suggested);
out += '<div class="stack" style="gap:6px;margin-bottom:8px"><span class="small strong">Spool' + (f.slot != null ? ' for color ' + (f.slot + 1) + ' <span class="swatch swatch-sm"><i style="background:' + f.hex + '"></i></span>' : '') + '</span>' +
(sug ? '<div class="row nm-raised-sm" style="padding:8px 12px;gap:10px">' + sw(sug.c, 'swatch-sm') + '<span style="flex:1"><b>' + esc(short(sug)) + '</b><span class="sub">Suggested: same color, loaded in ' + esc(where(sug).short) + '. Not charged until you confirm.</span></span><button class="btn btn-sm btn-primary" type="button" data-fake="Attributed to ' + esc(short(sug)) + ' · ' + g(f.grams || p.grams) + ' deducted">Use this</button></div>' : '') +
'<select class="select"><option>' + (sug ? 'Or pick another spool…' : 'Pick a spool…') + '</option>' + SPOOLS.slice(0, 8).map(function (s) { return '<option>' + g(s.rem) + ' · ' + esc(short(s)) + ' · ' + esc(s.mat) + '</option>'; }).join('') + '</select></div>';
});
return out;
}
function loadDrawer(cid) {
var c = container(cid), inIt = contents(cid);
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.printers + '</span>', 'Load into ' + esc(c.c.label), esc(c.printer.name) + ' · ' + inIt.length + ' of ' + c.c.cap + ' in use');
var cands = SPOOLS.filter(function (s) { return !s.cont; });
var body = '<label class="stack" style="gap:6px"><span class="small strong">Find a spool in storage</span><input class="input" id="load-q" placeholder="Brand, color, material…" autofocus></label><div class="stack" style="gap:6px" id="load-list">' +
cands.map(function (s) { return '<button type="button" class="slot" style="min-height:0;flex-direction:row;align-items:center;gap:12px;width:100%" data-load="' + s.id + '" aria-pressed="false" data-hay="' + esc((fullName(s) + ' ' + s.color + ' ' + (s.loc || '')).toLowerCase()) + '">' + sw(s.c) + '<span style="flex:1;min-width:0"><span class="nm" style="display:block">' + esc(s.color) + ' · ' + esc(s.brand) + '</span><span class="sub">' + esc(fullName(s)) + ' · ' + esc(s.loc || 'Storage') + '</span></span><span class="g num' + (low(s) ? '" style="color:var(--status-warning)' : '') + '">' + g(s.rem) + '</span></button>'; }).join('') + '</div>' +
'<p class="small muted" style="margin:0">Scanning the holder\'s QR label does the same thing from a phone (existing <code>/c/:id</code> scan session).</p>';
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer>Cancel</button><button class="btn btn-primary" type="button" id="l-save" disabled>Load spool</button></div>';
}
/* ── palette ─────────────────────────────────────────────── */
var P = { items: [], sel: 0 };
function palItems(q) {
q = q.trim().toLowerCase();
var acts = [
{ sec: 'Actions', ico: '+', t: 'Add spool', m: 'new', run: function () { openDrawer('new', 0, 'edit'); } },
{ sec: 'Actions', ico: '⚖', t: 'Weigh a spool…', m: 'type its name', run: function () { $('#pal-q').value = 'weigh '; palRender(); }, keep: 1 },
{ sec: 'Actions', ico: '!', t: 'Prints needing attention', m: printsNeeding().length + ' open', href: '03-prints.html?tab=needs' },
{ sec: 'Actions', ico: '◐', t: 'Switch color theme', m: 'light / dark', run: function () { setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'); } }
].concat(PAGES.map(function (p) { return { sec: 'Go to', ico: '→', t: p[1], m: 'page', href: p[2] }; }));
var weigh = /^weigh\s*/.test(q); if (weigh) q = q.replace(/^weigh\s*/, '');
var sp = SPOOLS.map(function (s) { return { sec: weigh ? 'Weigh which spool?' : 'Spools', html: sw(s.c, 'swatch-sm'), t: s.color + ' · ' + fullName(s), m: '#' + s.id + ' · ' + g(s.rem) + ' · ' + where(s).short, hay: ('#' + s.id + ' ' + s.id + ' ' + fullName(s) + ' ' + s.color + ' ' + where(s).text).toLowerCase(), run: function () { go('spool', s.id, weigh ? 'weigh' : 'overview'); } }; });
var pr = PRINTS.map(function (p) { return { sec: 'Prints', ico: '▤', t: p.job, m: PST[p.status][1] + ' · ' + when(p.start), hay: (p.job + ' ' + p.key + ' ' + printer(p.printer).name).toLowerCase(), run: function () { go('print', p.id); } }; });
var pt = PRINTERS.map(function (p) { return { sec: 'Printers', ico: '▣', t: p.name, m: p.connected ? (p.state === 'RUNNING' ? 'printing' : 'idle') : 'reconnecting', hay: p.name.toLowerCase(), href: '04-printers.html#printer=' + p.id }; });
if (weigh) return sp.filter(function (i) { return !q || i.hay.indexOf(q) >= 0; }).slice(0, 12);
if (!q) return acts.concat(sp.filter(function (i, k) { return k < 3; }).map(function (i) { i.sec = 'Recent spools'; return i; }));
var match = function (i) { return (i.hay || i.t.toLowerCase()).indexOf(q) >= 0; };
return acts.filter(match).concat(sp.filter(match).slice(0, 8), pr.filter(match).slice(0, 5), pt.filter(match));
}
function go(kind, id, tab) {
var pageFor = { spool: null, print: null };
if (window.PAGE && PAGE.name) openDrawer(kind, id, tab);
}
function palRender() {
P.items = palItems($('#pal-q').value); if (P.sel >= P.items.length) P.sel = 0;
var last = '', html = '';
P.items.forEach(function (it, i) {
if (it.sec !== last) { html += '<div class="pal-sec">' + esc(it.sec) + '</div>'; last = it.sec; }
html += '<div class="pal-item" role="option" id="pi' + i + '" data-i="' + i + '" aria-selected="' + (i === P.sel) + '">' + (it.html || '<span style="width:18px;text-align:center" aria-hidden="true">' + it.ico + '</span>') + '<span>' + esc(it.t) + '</span><span class="meta">' + esc(it.m || '') + '</span></div>';
});
$('#pal-list').innerHTML = html || '<div class="empty"><h3>No matches</h3><p>Try a color, brand, <code>#47</code>, a job name or a printer.</p></div>';
$('#pal-q').setAttribute('aria-activedescendant', P.items.length ? 'pi' + P.sel : '');
var cur = $('#pi' + P.sel); if (cur) cur.scrollIntoView({ block: 'nearest' });
}
function palOpen(prefill) { $('#pal').classList.add('is-open'); $('#pal-q').value = prefill || ''; P.sel = 0; palRender(); $('#pal-q').focus(); }
function palClose() { $('#pal').classList.remove('is-open'); }
function palRun(i) { var it = P.items[i]; if (!it) return; if (!it.keep) palClose(); if (it.href) location.href = it.href; else it.run(); }
/* ── freshness + demos ───────────────────────────────────── */
var fresh = { n: 3, stale: false };
function tick() {
fresh.n = fresh.stale ? fresh.n + 1 : (fresh.n >= 10 ? 0 : fresh.n + 1);
var f = $('#fresh'); if (!f) return;
f.classList.toggle('is-stale', fresh.stale);
$('#fresh-t').innerHTML = fresh.stale ? 'Not updating · data from 09:41:07 <span class="long">· retrying</span>' : 'Live <span class="long">· updated ' + fresh.n + 's ago</span>';
}
function demo(kind) {
var main = $('#page');
if (kind === 'loading') {
var keep = main.innerHTML;
main.innerHTML = '<div class="stack" style="gap:24px" aria-busy="true" aria-label="Loading"><div class="skel" style="width:40%;height:28px"></div><div class="stats">' + [1, 2, 3, 4].map(function () { return '<div class="stat nm-raised"><div class="skel" style="width:50%"></div><div class="skel" style="width:70%;height:24px;margin-top:10px"></div></div>'; }).join('') + '</div><div class="nm-raised card stack">' + [1, 2, 3, 4, 5].map(function () { return '<div class="skel" style="height:18px"></div>'; }).join('') + '</div></div>';
setTimeout(function () { main.innerHTML = keep; if (PAGE.after) PAGE.after(); }, 1600);
}
if (kind === 'offline') { fresh.stale = true; fresh.n = 0; tick(); var b = $('#offline-banner'); if (!b) { b = document.createElement('div'); b.id = 'offline-banner'; b.className = 'banner'; b.setAttribute('role', 'alert'); b.innerHTML = '<b>⚠</b><span style="flex:1">Can\'t reach Extrudex. You are looking at data from <b>09:41:07</b>, and it is not updating. Retrying every 10 s. Saving is paused.</span><button class="btn btn-sm" type="button" data-demo="online">Retry now</button>'; main.parentNode.insertBefore(b, main); } }
if (kind === 'online') { fresh.stale = false; fresh.n = 0; tick(); var ob = $('#offline-banner'); if (ob) ob.parentNode.removeChild(ob); }
}
/* ── wiring ──────────────────────────────────────────────── */
function parseHash() {
var m = /^#(spool|print|load)=([\w-]+)(?:&tab=(\w+))?(?:&state=(\w+))?/.exec(location.hash);
if (m) { var id = m[1] === 'load' ? m[2] : +m[2]; openDrawer(m[1], id, m[3], false); if (m[4]) { D.state = m[4]; renderDrawer(); } }
else if (D.kind) closeDrawer(false);
}
function init(page, render) {
var s = shell(page.name);
document.body.insertAdjacentHTML('afterbegin', '<a class="btn btn-sm" href="#page" style="position:absolute;left:-9999px;top:8px" onfocus="this.style.left=\'8px\'" onblur="this.style.left=\'-9999px\'">Skip to content</a><div class="shell">' + s.rail + '<main id="main">' + s.top + '<div id="page"></div></main></div>' + s.tabbar + s.rest);
window.PAGE = page;
$('#page').innerHTML = render();
if (page.after) page.after();
setTheme(document.documentElement.getAttribute('data-theme'));
setInterval(tick, 1000);
parseHash();
var pq = /[?&]palette=([^&]*)/.exec(location.search); if (pq) palOpen(decodeURIComponent(pq[1]).replace(/\+/g, ' '));
}
document.addEventListener('click', function (e) {
var t = e.target, a;
if ((a = t.closest('[data-set-theme]'))) return setTheme(a.getAttribute('data-set-theme'));
if (t.closest('[data-toggle-theme]')) return setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
if (t.closest('[data-palette]')) return palOpen();
if (t.id === 'pal') return palClose();
if ((a = t.closest('.pal-item'))) return palRun(+a.getAttribute('data-i'));
if ((a = t.closest('[data-menu]'))) { var m = $('#' + a.getAttribute('data-menu')), open = !m.classList.contains('is-open'); $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); }); m.classList.toggle('is-open', open); a.setAttribute('aria-expanded', String(open)); if (open) { var f = m.querySelector('button,a'); if (f) f.focus(); } e.stopPropagation(); return; }
if (!t.closest('.menu')) $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); });
if ((a = t.closest('[data-demo]'))) { $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); }); return demo(a.getAttribute('data-demo')); }
if ((a = t.closest('[data-soon]'))) { e.preventDefault(); return toast('<b>' + a.getAttribute('data-soon') + '</b> keeps its current screen on dev. It isn\'t redesigned in this iteration.'); }
if ((a = t.closest('[data-spool]'))) { e.preventDefault(); D.list = null; return openDrawer('spool', +a.getAttribute('data-spool'), a.getAttribute('data-tab-open') || 'overview'); }
if ((a = t.closest('[data-print]'))) { e.preventDefault(); return openDrawer('print', +a.getAttribute('data-print')); }
if ((a = t.closest('[data-load-into]'))) { e.preventDefault(); return openDrawer('load', a.getAttribute('data-load-into')); }
if ((a = t.closest('[data-add-spool]'))) { e.preventDefault(); return openDrawer('new', 0, 'edit'); }
if (t.closest('[data-close-drawer]')) return closeDrawer();
if ((a = t.closest('#drawer [data-tab]'))) { D.tab = a.getAttribute('data-tab'); D.state = 'normal'; renderDrawer(); history.replaceState({ d: 1 }, '', '#' + D.kind + '=' + D.id + (D.tab !== 'overview' ? '&tab=' + D.tab : '')); var f = $('#drawer [autofocus]'); if (f) f.focus(); return; }
if ((a = t.closest('[data-state]'))) { D.state = a.getAttribute('data-state'); return renderDrawer(); }
if ((a = t.closest('[data-step]'))) { var i = D.list.indexOf(D.id) + +a.getAttribute('data-step'); if (D.list[i] != null) openDrawer('spool', D.list[i], D.tab, false); history.replaceState({ d: 1 }, '', '#spool=' + D.id + (D.tab !== 'overview' ? '&tab=' + D.tab : '')); return; }
if ((a = t.closest('[data-dest]'))) { $$('[data-dest]').forEach(function (b) { b.setAttribute('aria-pressed', String(b === a)); }); $('#m-save').disabled = false; var dc = a.getAttribute('data-dest'), cc = dc !== 'storage' && container(dc); $('#m-save').textContent = cc ? 'Load into ' + cc.printer.model + ' · ' + cc.c.label : 'Move to storage'; return; }
if ((a = t.closest('[data-load]'))) { $$('[data-load]').forEach(function (b) { b.setAttribute('aria-pressed', String(b === a)); }); var ls = spool(+a.getAttribute('data-load')); $('#l-save').disabled = false; $('#l-save').textContent = 'Load ' + ls.color + ' · ' + ls.brand; $('#l-save').setAttribute('data-sid', ls.id); return; }
if (t.id === 'l-save') { var sid = +t.getAttribute('data-sid'), ss = spool(sid), cc2 = container(D.id); ss.cont = D.id; closeDrawer(); toast('Loaded <b>' + esc(ss.color + ' · ' + ss.brand) + '</b> into ' + esc(cc2.printer.model + ' · ' + cc2.c.label) + ' <a href="#spool=' + sid + '" data-spool="' + sid + '">View</a>'); if (PAGE.refresh) PAGE.refresh(); return; }
if (t.id === 'm-save') { var dest = $('[data-dest][aria-pressed="true"]').getAttribute('data-dest'), sp = spool(D.id); sp.cont = dest === 'storage' ? null : dest; sp.v++; D.tab = 'overview'; renderDrawer(); toast('Moved <b>' + esc(sp.color) + '</b> to ' + esc(where(sp).text)); if (PAGE.refresh) PAGE.refresh(); return; }
if (t.id === 'w-save' || t.id === 'w-next') { var ws = spool(D.id), before = ws.rem, nv = Math.round(+$('#scale').value.replace(/[^\d.]/g, '') - ws.tare); ws.rem = nv; ws.remSrc = 'weighed'; ws.weighedAt = '18 Sep 09:41'; ws.v++; toast('Weighed <b>' + esc(ws.color + ' · ' + ws.brand) + '</b>: ' + g(before) + ' → ' + g(nv)); if (PAGE.refresh) PAGE.refresh(); if (t.id === 'w-next') { var nx = D.list[D.list.indexOf(D.id) + 1]; openDrawer('spool', nx, 'weigh', false); history.replaceState({ d: 1 }, '', '#spool=' + nx + '&tab=weigh'); } else { D.tab = 'overview'; renderDrawer(); } return; }
if (t.closest('[data-save]')) { var sv = D.kind === 'new'; closeDrawer(); D.dirty = false; return toast(sv ? 'Added spool <b>#57</b>. <a href="#">Print its label</a>' : 'Saved changes'); }
if (t.closest('[data-discard]')) { D.dirty = false; return closeDrawer(); }
if (t.closest('[data-keep]')) { $('#discard-bar').hidden = true; return; }
if ((a = t.closest('[data-fake]'))) { toast(a.getAttribute('data-fake')); return; }
if ((a = t.closest('[data-delete]'))) { var ds = spool(+a.getAttribute('data-delete')); $('#drawer .drawer-foot').innerHTML = '<div class="stack" style="gap:8px;width:100%"><b>Delete ' + esc(ds.color + ' · ' + fullName(ds)) + '?</b><span class="small">Its print history stays. Prints keep their grams and cost and only lose the link to this spool. This can\'t be undone.</span><div class="row" style="justify-content:flex-end"><button class="btn" type="button" data-tab="overview" autofocus>Keep spool</button><button class="btn btn-danger" type="button" data-fake="Spool #' + ds.id + ' deleted. 0 prints lost their cost.">Delete spool</button></div></div>'; $('#drawer .drawer-foot [autofocus]').focus(); return; }
});
document.addEventListener('input', function (e) {
if (e.target.id === 'pal-q') { P.sel = 0; return palRender(); }
if (e.target.closest('#drawer .drawer-body') && D.tab === 'edit') D.dirty = true;
if (e.target.id === 'scale') {
var s = spool(D.id), v = +e.target.value.replace(/[^\d.]/g, ''), r = Math.round(v - s.tare);
var ok = v > 0 && v >= s.tare && r <= s.tot + 1;
$('#w-after').textContent = v ? g(r) : '—';
$('#w-diff').innerHTML = v && ok ? '<span class="prov">' + (r - s.rem > 0 ? '+' : '') + (r - s.rem).toLocaleString('en-US') + ' g</span>' : '';
$('#w-note').innerHTML = !v ? 'Type the reading and the result appears here before you save.' : v < s.tare ? '<span style="color:var(--status-error)">⚠ ' + g(v) + ' is less than the ' + g(s.tare) + ' empty spool. One of them is wrong. Nothing will be saved.</span>' : r > s.tot + 1 ? '<span style="color:var(--status-error)">⚠ That leaves ' + g(r) + ', more than the spool holds full (' + g(s.tot) + '). Check the total or the tare.</span>' : 'Saved with the tare it used, so a later tare correction can re-base it.';
$('#w-save').disabled = !ok; if ($('#w-next')) $('#w-next').disabled = !ok;
}
if (e.target.id === 'load-q') { var q = e.target.value.toLowerCase(); $$('[data-load]').forEach(function (b) { b.hidden = q && b.getAttribute('data-hay').indexOf(q) < 0; }); }
});
document.addEventListener('keydown', function (e) {
var typing = /INPUT|SELECT|TEXTAREA/.test(document.activeElement.tagName);
if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || (e.key === '/' && !typing)) { e.preventDefault(); return palOpen(); }
if ($('#pal').classList.contains('is-open')) {
if (e.key === 'Escape') { e.preventDefault(); return palClose(); }
if (e.key === 'ArrowDown') { e.preventDefault(); P.sel = Math.min(P.items.length - 1, P.sel + 1); return palRender(); }
if (e.key === 'ArrowUp') { e.preventDefault(); P.sel = Math.max(0, P.sel - 1); return palRender(); }
if (e.key === 'Enter') { e.preventDefault(); return palRun(P.sel); }
return;
}
if (e.key === 'Escape') { if ($('.menu.is-open')) { $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); }); return; } if (D.kind) return closeDrawer(); }
if (D.kind === 'spool' && !typing && D.list && (e.key === 'j' || e.key === 'k')) { var b = $('[data-step="' + (e.key === 'j' ? 1 : -1) + '"]'); if (b && !b.disabled) b.click(); }
if (e.key === 'Enter' && document.activeElement.matches('tr[data-row-spool],tr[data-row-print]')) document.activeElement.click();
if (D.kind && e.key === 'Tab') { var dr = $('#drawer'), f = $$('button:not([disabled]),a[href],input,select,[tabindex="0"]', dr).filter(function (x) { return x.offsetParent; }); if (!f.length) return; if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); } else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); } }
});
window.addEventListener('popstate', parseHash);
window.X = { init: init, esc: esc, g: g, kg: kg, usd: usd, dur: dur, when: when, ago: ago, since: since, spool: spool, printer: printer, print: print, container: container, contents: contents, fullName: fullName, short: short, where: where, gross: gross, value: value, low: low, sw: sw, meter: meter, tareProv: tareProv, remProv: remProv, weightProv: weightProv, durCell: durCell, printSt: printSt, printerSt: printerSt, needs: needs, printsNeeding: printsNeeding, spoolLinks: spoolLinks, openDrawer: openDrawer, D: D, toast: toast, ICON: ICON };
})();
+72
View File
@@ -0,0 +1,72 @@
"""Iteration 2: assemble self-contained pages.
Each screen = v3 tokens (fonts inlined as base64) + iteration-1 app.css
utilities + components.css + page CSS, then data.js + app.js + page script.
Run: python3 build.py (writes ../screens/*.html)
"""
import base64, pathlib, re
HERE = pathlib.Path(__file__).resolve().parent
ROOT = HERE.parent.parent # ~/Design/extrudex-v3
OUT = HERE.parent / 'screens'
OUT.mkdir(exist_ok=True)
tokens = (ROOT / 'design-system-v3.css').read_text()
tokens = re.sub(r"url\('\./fonts/([^']+)'\)",
lambda m: "url('data:font/woff2;base64," + base64.b64encode((ROOT / 'fonts' / m.group(1)).read_bytes()).decode() + "')",
tokens)
base_css = (ROOT / 'source' / 'app.css').read_text()
comp_css = (HERE / 'components.css').read_text()
data_js = (HERE / 'data.js').read_text()
app_js = (HERE / 'app.js').read_text()
THEME = """<script>
(function () { var t = null; try { t = localStorage.getItem('extrudex-v3-theme'); } catch (e) {}
var q = /[?&]theme=(light|dark)/.exec(location.search); if (q) t = q[1];
if (t !== 'light' && t !== 'dark') t = window.matchMedia && matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', t); })();
</script>"""
def build(slug, title, desc):
src = (HERE / 'pages' / f'{slug}.html').read_text()
css = ''.join(re.findall(r'<style>(.*?)</style>', src, re.S))
js = ''.join(re.findall(r'<script>(.*?)</script>', src, re.S))
html = f"""<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<meta name="description" content="{desc}">
{THEME}
<style>
{tokens}
{base_css}
{comp_css}
{css}
</style>
</head>
<body>
<script>
{data_js}
{app_js}
</script>
<script>
{js}
</script>
</body>
</html>
"""
(OUT / f'{slug}.html').write_text(html)
print(slug, len(html) // 1024, 'KB')
PAGES = [
('index', 'Extrudex Flow Map', 'How the iteration-2 screens connect, with guided flows.'),
('01-dashboard', 'Extrudex Dashboard', 'Attention-first dashboard, iteration 2.'),
('02-filament', 'Extrudex Filament', 'Filament inventory with drawer, filters and weigh session, iteration 2.'),
('03-prints', 'Extrudex Prints', 'Print history and attribution queue, iteration 2.'),
('04-printers', 'Extrudex Printers', 'Printers with holders, load flow and connection state, iteration 2.'),
]
for p in PAGES:
if (HERE / 'pages' / f'{p[0]}.html').exists():
build(*p)
+239
View File
@@ -0,0 +1,239 @@
/* ── Design System v3.1 components (iteration 2) ─────────────────────────
* Built only from design-system-v3.css tokens. No new colors. */
/* Shell */
.shell { display: grid; grid-template-columns: 224px minmax(0, 1fr); gap: var(--space-6); max-width: 1480px; margin: 0 auto; padding: var(--space-5) var(--space-6); min-height: 100vh; }
.rail { position: sticky; top: var(--space-5); align-self: start; padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-4); max-height: calc(100vh - 40px); }
.wordmark { font-weight: var(--fw-display); font-size: var(--fs-h3); display: flex; align-items: center; gap: var(--space-2); padding: 4px 6px; }
.wordmark-mark { width: 30px; height: 30px; border-radius: 10px; background: var(--accent-fill); color: var(--text-on-accent); display: grid; place-items: center; font-size: 15px; border: 1px solid var(--border-control); box-shadow: var(--nm-raised-1); }
.nav-group { display: flex; flex-direction: column; gap: 4px; }
.nav-label { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); font-weight: var(--fw-bold); padding: 8px 12px 2px; }
.nav a { display: flex; align-items: center; gap: var(--space-3); padding: 9px 12px; border-radius: var(--radius-md); color: var(--text-primary); text-decoration: none; border: 1px solid transparent; font-weight: var(--fw-medium); position: relative; }
.nav a:hover { border-color: var(--border-control); }
.nav a:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
.nav a[aria-current="page"] { background: var(--surface-inset); box-shadow: var(--nm-inset-1); border-color: var(--text-accent); color: var(--text-accent); font-weight: var(--fw-bold); }
.nav svg { width: 18px; height: 18px; flex: none; }
.nav .count { margin-left: auto; min-width: 22px; height: 20px; padding: 0 6px; border-radius: var(--radius-pill); font-size: var(--fs-caption); font-weight: var(--fw-bold); display: inline-grid; place-items: center; border: 1px solid currentColor; }
.count-warn { color: var(--status-warning); background: var(--wash-warning); }
.count-err { color: var(--status-error); background: var(--wash-error); }
.rail-foot { margin-top: auto; display: flex; flex-direction: column; gap: var(--space-3); border-top: 1px solid var(--border-hairline); padding-top: var(--space-3); }
/* Top bar: search, freshness, context */
.topbar { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
.search-trigger { flex: 1; min-width: 220px; max-width: 560px; display: flex; align-items: center; gap: var(--space-3); padding: 9px 14px; border-radius: var(--radius-pill); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-control); color: var(--text-secondary); font: inherit; cursor: pointer; text-align: left; }
.search-trigger:hover { border-color: var(--text-accent); }
.search-trigger:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: var(--focus-offset); }
.search-trigger svg { width: 16px; height: 16px; }
.kbd { font: var(--fw-bold) 10px/1 var(--font-mono); padding: 3px 6px; border-radius: 5px; border: 1px solid var(--border-control); color: var(--text-secondary); background: var(--surface-raised); box-shadow: 0 1px 0 var(--border-strong); }
.fresh { display: inline-flex; align-items: center; gap: 8px; font-size: var(--fs-label); color: var(--text-secondary); }
.fresh .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--status-success); box-shadow: 0 0 0 3px var(--wash-success); }
.fresh.is-stale .dot { background: var(--status-warning); box-shadow: 0 0 0 3px var(--wash-warning); }
.fresh.is-stale { color: var(--status-warning); }
/* Breadcrumb */
.crumbs { display: flex; gap: 6px; align-items: center; font-size: var(--fs-label); color: var(--text-secondary); }
.crumbs a { color: var(--text-secondary); text-decoration: none; }
.crumbs a:hover { color: var(--text-accent); text-decoration: underline; }
/* Page header: title, one-line summary, ONE primary action, overflow for the rest */
.page-head { display: flex; flex-wrap: wrap; gap: var(--space-4); align-items: flex-end; justify-content: space-between; }
.page-head h1 { margin: 0; font-size: var(--fs-h1); line-height: var(--lh-tight); font-weight: var(--fw-display); }
.page-head .sum { margin: 6px 0 0; color: var(--text-secondary); font-size: 13px; }
.page-head .sum b { color: var(--text-primary); }
/* Overflow / row menu */
.menu-wrap { position: relative; display: inline-block; }
.menu { position: absolute; right: 0; top: calc(100% + 6px); z-index: 30; min-width: 220px; padding: 6px; display: none; flex-direction: column; gap: 2px; background: var(--surface-high); border: 1px solid var(--border-strong); border-radius: var(--radius-md); box-shadow: var(--nm-raised-3); }
.menu.is-open { display: flex; }
.menu button, .menu a { all: unset; box-sizing: border-box; display: flex; gap: 10px; align-items: center; padding: 8px 10px; border-radius: var(--radius-sm); font-size: 13px; cursor: pointer; color: var(--text-primary); }
.menu button:hover, .menu a:hover, .menu button:focus-visible, .menu a:focus-visible { background: var(--accent-wash); outline: 1px solid var(--text-accent); }
.menu .danger { color: var(--status-error); }
.menu hr { border: 0; border-top: 1px solid var(--border-hairline); margin: 4px 0; width: 100%; }
.menu .hint { margin-left: auto; font-size: var(--fs-caption); color: var(--text-secondary); }
/* Tabs (segmented, with counts) */
.tabs { display: flex; gap: 4px; padding: 4px; border-radius: var(--radius-pill); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-control); width: max-content; max-width: 100%; overflow-x: auto; }
.tabs button { font: var(--fw-medium) 13px/1 var(--font-body); color: var(--text-secondary); background: transparent; border: 1px solid transparent; border-radius: var(--radius-pill); padding: 8px 14px; cursor: pointer; display: inline-flex; gap: 8px; align-items: center; white-space: nowrap; }
.tabs button[aria-selected="true"] { background: var(--surface-raised); color: var(--text-primary); font-weight: var(--fw-bold); box-shadow: var(--nm-raised-1); border-color: var(--border-control); }
.tabs button:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
.tabs .n { font-size: var(--fs-caption); padding: 1px 7px; border-radius: var(--radius-pill); border: 1px solid currentColor; }
.tabs .n.warn { color: var(--status-warning); }
/* Filter chips (removable) */
.fchips { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }
.fchip { display: inline-flex; gap: 6px; align-items: center; padding: 4px 6px 4px 10px; border-radius: var(--radius-pill); border: 1px solid var(--text-accent); background: var(--accent-wash); color: var(--text-accent); font-size: var(--fs-label); font-weight: var(--fw-bold); }
.fchip button { all: unset; cursor: pointer; width: 18px; height: 18px; display: grid; place-items: center; border-radius: 50%; }
.fchip button:hover, .fchip button:focus-visible { background: var(--text-accent); color: var(--text-on-accent); }
.fadd { font-size: var(--fs-label); }
/* Quiet provenance: icon + lowercase text, never a chip */
.prov { display: inline-flex; gap: 4px; align-items: center; font-size: var(--fs-caption); color: var(--text-secondary); white-space: nowrap; }
.prov i { font-style: normal; width: 14px; text-align: center; }
.prov.warn { color: var(--status-warning); font-weight: var(--fw-bold); }
.prov.info { color: var(--status-info); }
/* Status: small filled label + glyph. Reserved for state, not provenance */
.st { display: inline-flex; gap: 6px; align-items: center; font-size: var(--fs-label); font-weight: var(--fw-bold); white-space: nowrap; }
.st::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex: none; }
.st.run { color: var(--text-accent); } .st.ok { color: var(--status-success); } .st.err { color: var(--status-error); } .st.warn { color: var(--status-warning); } .st.idle { color: var(--text-secondary); }
.st.warn::before { border-radius: 2px; transform: rotate(45deg); }
.st.err::before { border-radius: 1px; }
.st.idle::before { background: transparent; border: 2px solid currentColor; width: 6px; height: 6px; }
/* Data tables (v3.1): quieter, row = one line where possible */
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.tbl th { text-align: left; font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps); color: var(--text-secondary); font-weight: var(--fw-bold); padding: 10px 12px; border-bottom: 1px solid var(--border-strong); white-space: nowrap; position: sticky; top: 0; background: var(--surface-raised); z-index: 1; }
.tbl th button.sort { all: unset; cursor: pointer; display: inline-flex; gap: 4px; align-items: center; }
.tbl th button.sort:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; border-radius: 3px; }
.tbl th[aria-sort="ascending"] button.sort::after { content: "▲"; font-size: 9px; color: var(--text-accent); }
.tbl th[aria-sort="descending"] button.sort::after { content: "▼"; font-size: 9px; color: var(--text-accent); }
.tbl th[aria-sort="none"] button.sort::after { content: "↕"; font-size: 10px; opacity: .6; }
.tbl td { padding: 10px 12px; border-bottom: 1px solid var(--border-hairline); vertical-align: middle; }
.tbl tr:last-child td { border-bottom: 0; }
.tbl .r { text-align: right; font-variant-numeric: tabular-nums; }
.tbl tbody tr.clickable { cursor: pointer; }
.tbl tbody tr.clickable:hover td { background: var(--accent-wash); }
.tbl tbody tr.is-selected td { background: var(--accent-wash); box-shadow: inset 0 1px 0 var(--text-accent), inset 0 -1px 0 var(--text-accent); }
.tbl tbody tr.is-selected td:first-child { box-shadow: inset 3px 0 0 var(--text-accent), inset 0 1px 0 var(--text-accent), inset 0 -1px 0 var(--text-accent); }
.tbl tbody tr:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
.sub { display: block; font-size: var(--fs-caption); color: var(--text-secondary); font-weight: var(--fw-regular); line-height: var(--lh-snug); }
.strong { font-weight: var(--fw-bold); }
.num { font-variant-numeric: tabular-nums; }
.table-card { padding: 0; overflow: hidden; }
.table-scroll { overflow: auto; max-height: 70vh; }
.table-foot { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border-top: 1px solid var(--border-hairline); flex-wrap: wrap; font-size: var(--fs-label); }
/* Meter: remaining bar with the 200 g threshold tick */
.meter { position: relative; height: 8px; border-radius: var(--radius-pill); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); overflow: visible; min-width: 90px; }
.meter > span { display: block; height: 100%; border-radius: var(--radius-pill); background: var(--text-accent); }
.meter.is-low > span { background: var(--status-warning); }
.meter .tick { position: absolute; top: -3px; bottom: -3px; width: 2px; background: var(--text-secondary); border-radius: 1px; }
/* Drawer (right sheet; full-screen on phones) */
.drawer-scrim { position: fixed; inset: 0; background: rgba(10, 10, 14, .5); opacity: 0; pointer-events: none; transition: opacity var(--dur-base) var(--ease-out); z-index: 40; }
:root[data-theme="light"] .drawer-scrim { background: rgba(60, 55, 80, .28); }
.drawer { position: fixed; top: 12px; right: 12px; bottom: 12px; width: min(520px, calc(100vw - 24px)); z-index: 41; transform: translateX(calc(100% + 24px)); transition: transform 220ms var(--ease-out); display: flex; flex-direction: column; overflow: hidden; }
body.drawer-open .drawer { transform: none; }
body.drawer-open .drawer-scrim { opacity: 1; pointer-events: auto; }
.drawer-head { padding: var(--space-5) var(--space-5) var(--space-3); display: flex; gap: var(--space-3); align-items: flex-start; border-bottom: 1px solid var(--border-hairline); }
.drawer-head h2 { margin: 0; font-size: var(--fs-h3); line-height: var(--lh-snug); }
.drawer-tabs { padding: var(--space-3) var(--space-5) 0; }
.drawer-body { flex: 1; overflow: auto; padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-5); }
.drawer-foot { padding: var(--space-4) var(--space-5); border-top: 1px solid var(--border-hairline); display: flex; gap: var(--space-3); justify-content: flex-end; align-items: center; flex-wrap: wrap; }
.drawer-nav { display: flex; gap: 4px; }
@media (prefers-reduced-motion: reduce) { .drawer, .drawer-scrim { transition: none; } }
/* Key-value list */
.kv { display: grid; grid-template-columns: minmax(110px, auto) 1fr; gap: 8px var(--space-4); margin: 0; font-size: 13px; }
.kv dt { color: var(--text-secondary); }
.kv dd { margin: 0; font-variant-numeric: tabular-nums; }
.panel { padding: var(--space-4); }
.panel h3 { margin: 0 0 var(--space-3); font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); }
/* Command palette */
.pal-scrim { position: fixed; inset: 0; background: rgba(10, 10, 14, .55); z-index: 60; display: none; align-items: flex-start; justify-content: center; padding-top: 12vh; }
:root[data-theme="light"] .pal-scrim { background: rgba(60, 55, 80, .3); }
.pal-scrim.is-open { display: flex; }
.pal { width: min(640px, calc(100vw - 32px)); max-height: 70vh; display: flex; flex-direction: column; overflow: hidden; }
.pal-input { display: flex; gap: var(--space-3); align-items: center; padding: var(--space-4); border-bottom: 1px solid var(--border-hairline); }
.pal-input input { all: unset; flex: 1; font: var(--fw-medium) 16px/1.4 var(--font-body); color: var(--text-primary); }
.pal-input input::placeholder { color: var(--text-secondary); }
.pal-list { overflow: auto; padding: 6px; }
.pal-sec { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); font-weight: var(--fw-bold); padding: 10px 10px 4px; }
.pal-item { display: flex; gap: var(--space-3); align-items: center; padding: 9px 10px; border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; border: 1px solid transparent; }
.pal-item[aria-selected="true"] { background: var(--accent-wash); border-color: var(--text-accent); }
.pal-item .meta { margin-left: auto; font-size: var(--fs-caption); color: var(--text-secondary); white-space: nowrap; }
.pal-foot { padding: 8px var(--space-4); border-top: 1px solid var(--border-hairline); display: flex; gap: var(--space-4); font-size: var(--fs-caption); color: var(--text-secondary); flex-wrap: wrap; }
/* Toasts */
.toasts { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 70; display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; }
.toast { pointer-events: auto; display: flex; gap: var(--space-3); align-items: center; padding: 10px 12px 10px 16px; background: var(--surface-high); border: 1px solid var(--border-strong); border-left: 4px solid var(--status-success); border-radius: var(--radius-md); box-shadow: var(--nm-raised-3); font-size: 13px; max-width: min(560px, calc(100vw - 32px)); }
.toast.warn { border-left-color: var(--status-warning); }
/* Attention list (dashboard) */
.attn-list { display: flex; flex-direction: column; gap: var(--space-3); }
.attn { display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; gap: var(--space-4); align-items: center; padding: var(--space-4); border-radius: var(--radius-md); background: var(--surface-raised); border: 1px solid var(--border-hairline); box-shadow: var(--nm-raised-2); }
.attn .ico { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; font-size: 20px; font-weight: var(--fw-bold); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); }
.attn.warn .ico { color: var(--status-warning); border-color: var(--status-warning); }
.attn.err .ico { color: var(--status-error); border-color: var(--status-error); }
.attn.info .ico { color: var(--status-info); }
.attn h3 { margin: 0; font-size: 14px; }
.attn p { margin: 2px 0 0; font-size: var(--fs-label); color: var(--text-secondary); }
.attn-done { padding: var(--space-5); text-align: center; color: var(--text-secondary); }
/* Skeleton + empty + error states */
.skel { border-radius: 6px; background: linear-gradient(90deg, var(--surface-inset) 0%, var(--border-hairline) 50%, var(--surface-inset) 100%); background-size: 200% 100%; animation: skel 1.4s linear infinite; height: 12px; }
@keyframes skel { to { background-position: -200% 0; } }
@media (prefers-reduced-motion: reduce) { .skel { animation: none; } }
.empty { padding: var(--space-8) var(--space-5); text-align: center; display: flex; flex-direction: column; gap: var(--space-3); align-items: center; }
.empty h3 { margin: 0; font-size: 15px; }
.empty p { margin: 0; color: var(--text-secondary); font-size: 13px; max-width: 420px; }
.banner { display: flex; gap: var(--space-3); align-items: center; padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); font-size: 13px; border: 1px solid var(--status-warning); background: var(--wash-warning); }
.banner.err { border-color: var(--status-error); background: var(--wash-error); }
/* Slot grid (printers + move flow) */
.slotgrid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.slotgrid.one { grid-template-columns: minmax(0, 1fr); max-width: 25%; }
.slot { all: unset; box-sizing: border-box; cursor: pointer; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; padding: 10px; min-height: 92px; border-radius: var(--radius-md); border: 1px solid var(--border-control); background: var(--surface-raised); box-shadow: var(--nm-raised-1); font-size: var(--fs-caption); position: relative; min-width: 0; }
.slot:hover { border-color: var(--text-accent); }
.slot:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
.slot .sn { color: var(--text-secondary); font-weight: var(--fw-bold); }
.slot .nm { font-weight: var(--fw-bold); font-size: 12px; line-height: var(--lh-snug); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.slot .g { margin-top: auto; font-variant-numeric: tabular-nums; font-size: 12px; }
.slot.empty { border-style: dashed; box-shadow: none; background: transparent; color: var(--text-secondary); justify-content: center; align-items: center; text-align: center; }
.slot.is-low { border-color: var(--status-warning); }
.slot[aria-pressed="true"] { background: var(--accent-wash); border: 2px solid var(--text-accent); box-shadow: var(--nm-inset-1); }
.slot[aria-pressed="true"]::after { content: "✓"; position: absolute; top: 6px; right: 8px; color: var(--text-accent); font-weight: var(--fw-bold); font-size: 13px; }
.slot[disabled] { cursor: not-allowed; opacity: .75; }
/* Mockup pill (replaces the iteration-1 banner) */
.mock { position: fixed; left: 16px; bottom: 16px; z-index: 35; }
.mock > button { font: var(--fw-bold) var(--fs-caption)/1 var(--font-body); text-transform: uppercase; letter-spacing: var(--tracking-caps); padding: 8px 12px; border-radius: var(--radius-pill); border: 1px solid var(--text-accent); background: var(--surface-high); color: var(--text-accent); box-shadow: var(--nm-raised-2); cursor: pointer; }
.mock .menu { left: 0; right: auto; top: auto; bottom: calc(100% + 8px); width: 330px; padding: var(--space-4); gap: var(--space-2); font-size: var(--fs-label); }
.mock .menu p { margin: 0; line-height: var(--lh-snug); }
/* Mobile bottom tab bar + FAB */
.tabbar { display: none; }
.fab { display: none; }
@media (max-width: 860px) {
.shell { grid-template-columns: minmax(0, 1fr); padding: var(--space-3) var(--space-4) 96px; gap: var(--space-4); }
.rail { display: none; }
.tabbar { display: grid; grid-template-columns: repeat(4, 1fr); position: fixed; left: 8px; right: 8px; bottom: 8px; z-index: 36; padding: 6px; border-radius: var(--radius-xl); }
.tabbar a { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 6px 0; border-radius: var(--radius-lg); color: var(--text-secondary); text-decoration: none; font-size: 11px; font-weight: var(--fw-medium); position: relative; border: 1px solid transparent; }
.tabbar a[aria-current="page"] { color: var(--text-accent); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border-color: var(--text-accent); font-weight: var(--fw-bold); }
.tabbar svg { width: 20px; height: 20px; }
.tabbar .count { position: absolute; top: 2px; right: calc(50% - 22px); min-width: 16px; height: 16px; font-size: 9px; padding: 0 4px; border-radius: 99px; display: grid; place-items: center; border: 1px solid currentColor; }
.fab { display: grid; place-items: center; position: fixed; right: 16px; bottom: 88px; z-index: 36; width: 56px; height: 56px; border-radius: 18px; font-size: 26px; padding: 0; }
.mock { bottom: 88px; }
.topbar .fresh span.long { display: none; }
.page-head .primary-desktop { display: none; }
.drawer { top: 0; right: 0; bottom: 0; width: 100vw; border-radius: 0; }
.slotgrid.one { max-width: 50%; }
}
@media (max-width: 520px) { .slotgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* Form grid + small utilities */
.fr2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px 16px; }
.nowrap { white-space: nowrap; }
.drawer-body a { color: var(--text-accent); }
.linkish { all: unset; cursor: pointer; color: var(--text-accent); text-decoration: underline; }
.linkish:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
.tile-link { text-decoration: none; color: inherit; }
.tile-link:hover { border-color: var(--text-accent); }
.tile-link:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: var(--focus-offset); }
.is-flash { animation: flash 1.6s var(--ease-out) 1; }
@keyframes flash { 0%, 40% { outline: 3px solid var(--focus-ring); outline-offset: 4px; } 100% { outline: 3px solid transparent; outline-offset: 4px; } }
@media (prefers-reduced-motion: reduce) { .is-flash { animation: none; outline: 3px solid var(--focus-ring); } }
#page { display: flex; flex-direction: column; gap: var(--space-6); min-width: 0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
#drawer-title:focus { outline: none; }
#drawer-title:focus-visible { outline: none; text-decoration: underline; text-decoration-color: var(--focus-ring); text-underline-offset: 4px; }
.short { display: none; }
@media (max-width: 860px) {
.search-trigger .long, .search-trigger .kbd.long, .mock .long { display: none; }
.search-trigger .short { display: inline; }
.search-trigger { min-width: 0; }
.remcell .top { flex-wrap: nowrap; white-space: nowrap; }
.remcell .top .sub { display: none !important; }
}
.pal-input svg { width: 18px; height: 18px; flex: none; color: var(--text-secondary); }
+58
View File
@@ -0,0 +1,58 @@
/* Sample data for the iteration-2 mockups. One model shared by every page, so a
* spool, print or printer is the same object wherever it appears. Values are
* based on the real inventory (import/filament_review.tsv, seed.sql spool
* types) and the 2026-08-12 production canary. Nothing here is live. */
var NOW = new Date('2026-09-18T09:41:07');
var CANARY = { spools: 105, brands: 18, kg: 95.72, value: 1721.18 };
/* tsrc: where this spool's tare came from.
* 'spool' weighed for this reel (tare_origin=spool)
* 'manufacturer' | 'community' | 'estimated' via the chosen spool type
* null no tare known */
var SPOOLS = [
{ id: 40, c: ['#1f3fbf'], color: 'Klein Blue', brand: 'Sunlu', speed: null, mat: 'PLA', type: '+ 2.0', fin: 'Basic', rem: 64, tot: 250, tare: 60, tsrc: 'community', stype: 'Sunlu — 250 g mini', remSrc: 'typed', kg: 25.20, cont: null, loc: 'Shelf B2', label: true, v: 3 },
{ id: 41, c: ['#b87333'], color: 'Copper', brand: 'Sunlu', speed: null, mat: 'PLA', type: '+', fin: 'Silk', rem: 97, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', remSrc: 'typed', kg: 19.99, cont: 'p1-ams1', label: true, v: 7 },
{ id: 42, c: ['#141414'], color: 'Black', brand: 'Elegoo', speed: 'Rapid', mat: 'PETG', type: null, fin: 'Basic', rem: 142, tot: 1000, tare: 154, tsrc: 'manufacturer', stype: 'Elegoo — 1 kg cardboard', remSrc: 'typed', kg: 11.74, cont: 'p2-ams1', label: true, v: 9 },
{ id: 43, c: ['#ece8df'], color: 'Rock White', brand: 'Overture', speed: null, mat: 'PLA', type: null, fin: 'Marble', rem: 171, tot: 1000, tare: 155, tsrc: 'community', stype: 'Overture — cardboard, PLA lines', remSrc: 'typed', kg: 17.99, cont: null, loc: 'Shelf C', label: true, v: 4 },
{ id: 44, c: ['#161616', '#c0232f'], color: 'Black Red', brand: 'TECSONAR', speed: null, mat: 'PLA', type: null, fin: 'Matte', rem: 188, tot: 1000, tare: null, tsrc: null, stype: null, remSrc: 'typed', kg: 15.99, cont: null, loc: 'Shelf C', label: false, v: 2 },
{ id: 45, c: ['#101010'], color: 'Black', brand: 'JAYO', speed: 'Rapid', mat: 'PETG', type: null, fin: 'Matte', rem: 655, tot: 1000, tare: null, tsrc: null, stype: null, remSrc: 'typed', kg: 12.72, cont: 'p1-ams1', label: true, v: 5 },
{ id: 46, c: ['#8a8f7a', '#e9e4d8'], color: 'Marble', brand: 'iSANMATE', speed: null, mat: 'ASA', type: null, fin: 'Marble', rem: 430, tot: 1000, tare: 180, tsrc: 'estimated', stype: 'Generic — 1 kg spool (assumed)', remSrc: 'weighed', kg: 25.99, cont: null, loc: 'Dry box 1', label: true, v: 6 },
{ id: 47, c: ['#1b1b1b'], color: 'Black', brand: 'Sunlu', speed: null, mat: 'PETG', type: 'CF', fin: 'Matte', rem: 812, tot: 1000, tare: 262, tsrc: 'spool', stype: 'Sunlu — 1 kg plastic', remSrc: 'weighed', weighedAt: '18 Sep 09:39', kg: 22.79, cont: 'p3-lite', label: true, v: 5 },
{ id: 48, c: ['#b8864b'], color: 'Natural Wood', brand: 'Sunlu', speed: null, mat: 'PLA', type: null, fin: 'Wood', rem: 1000, tot: 1000, tare: 35, tsrc: 'estimated', stype: 'Refill — bare cardboard core', remSrc: 'typed', kg: 16.50, cont: null, loc: 'Shelf A3', label: false, v: 1 },
{ id: 49, c: ['#a3402c'], color: 'Brick Red', brand: 'Sunlu', speed: 'High Speed', mat: 'PLA', type: null, fin: 'Marble', rem: 1000, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', remSrc: 'typed', kg: 17.50, cont: null, loc: 'Shelf A2', label: true, v: 1 },
{ id: 50, c: ['#e8453c', '#f2c230', '#2a8fd6'], color: 'Gradient Rainbow', brand: 'AMOLEN', speed: null, mat: 'PETG', type: null, fin: 'Gradient', rem: 1000, tot: 1000, tare: 180, tsrc: 'estimated', stype: 'Generic — 1 kg spool (assumed)', remSrc: 'typed', kg: 23.99, cont: null, loc: 'Shelf D', label: false, v: 1 },
{ id: 51, c: ['#1d4fa8'], color: 'Blue', brand: 'Elegoo', speed: 'Rapid', mat: 'PETG', type: null, fin: 'Basic', rem: 1000, tot: 1000, tare: 154, tsrc: 'manufacturer', stype: 'Elegoo — 1 kg cardboard', remSrc: 'typed', kg: 17.99, cont: 'p2-ams1', label: true, v: 2 },
{ id: 52, c: ['#141414'], color: 'Black', brand: 'Sunlu', speed: null, mat: 'PLA', type: '+ 2.0', fin: 'Basic', rem: 873, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', remSrc: 'typed', kg: 11.47, cont: 'p1-ams1', label: true, v: 11 },
{ id: 53, c: ['#b9bcc0'], color: 'Mist Gray', brand: 'Overture', speed: null, mat: 'PLA', type: null, fin: 'Marble', rem: 920, tot: 1000, tare: 155, tsrc: 'community', stype: 'Overture — cardboard, PLA lines', remSrc: 'typed', kg: 16.99, cont: 'p1-ams1', label: true, v: 3 },
{ id: 54, c: ['#f4f4f0'], color: 'White', brand: 'Elegoo', speed: null, mat: 'PLA', type: null, fin: 'Matte', rem: 569, tot: 1000, tare: 154, tsrc: 'manufacturer', stype: 'Elegoo — 1 kg cardboard', remSrc: 'typed', kg: 14.99, cont: 'p2-ams1', label: true, v: 6 },
{ id: 55, c: ['#e8453c', '#f2c230', '#2a8fd6'], color: 'Red Yellow Blue', brand: 'AMOLEN', speed: null, mat: 'PLA', type: null, fin: 'Matte', rem: 532, tot: 1000, tare: 180, tsrc: 'estimated', stype: 'Generic — 1 kg spool (assumed)', remSrc: 'typed', kg: 19.99, cont: 'p3-lite', label: true, v: 4 },
{ id: 56, c: ['#6e1a2a'], color: 'Transparent Wine Red', brand: 'Creality', speed: null, mat: 'PETG', type: null, fin: 'Translucent', rem: 1000, tot: 1000, tare: null, tsrc: null, stype: null, remSrc: 'typed', kg: 17.99, cont: null, loc: 'Shelf D', label: false, v: 1 }
];
var PRINTERS = [
{ id: 1, name: 'P1S - Prints Charming', model: 'P1S', ip: '10.60.1.242', serial: '01P09A••••••4821', state: 'RUNNING', connected: true, lifecycle: 'ready', stale: false, age: 2, session: 3, camera: 'snapshot', job: 'Gridfinity_bins_3x2', openPrint: 212,
containers: [{ id: 'p1-ams1', kind: 'ams', label: 'AMS 1', cap: 4 }, { id: 'p1-ext', kind: 'external', label: 'External', cap: 1 }] },
{ id: 2, name: 'H2C - Constipation Orion', model: 'H2C', ip: '10.60.1.202', serial: '31B••••••••0093', state: 'FINISH', connected: true, lifecycle: 'ready', stale: false, age: 4, session: 2, camera: 'snapshot', job: '', openPrint: null,
containers: [{ id: 'p2-ams1', kind: 'ams', label: 'AMS 1', cap: 4 }, { id: 'p2-ext', kind: 'external', label: 'External', cap: 1 }] },
{ id: 3, name: 'A1 - Jordyns Layer Slayer', model: 'A1', ip: '10.60.1.x (sample)', serial: '039••••••••7710', state: 'RUNNING', connected: false, lifecycle: 'reconnecting', stale: true, age: 366, session: 14, camera: 'snapshot', job: 'Coaster_set', openPrint: 213,
containers: [{ id: 'p3-lite', kind: 'ams_lite', label: 'AMS Lite', cap: 4 }, { id: 'p3-ext', kind: 'external', label: 'External', cap: 1 }] }
];
/* weight source: typed | sliced | recalled | unknown | null (not known yet)
* gaps: null = before counting, 0 = asked and none */
var PRINTS = [
{ id: 213, job: 'Coaster_set', key: '/coasters/plate_1.gcode', printer: 3, status: 'running', start: '2026-09-18T08:55', end: null, dur: null, gaps: 0, grams: null, wsrc: null, cost: null, spool: null, use: 'personal', open: true },
{ id: 212, job: 'Gridfinity_bins_3x2', key: '/gridfinity/bins_3x2.gcode', printer: 1, status: 'running', start: '2026-09-18T08:37', end: null, dur: null, gaps: 0, grams: null, wsrc: null, cost: null, spool: null, use: 'business' },
{ id: 211, job: 'Benchy_x4', key: '/benchy-batch/plate_1.gcode', printer: 2, status: 'finished', start: '2026-09-18T02:15', end: '2026-09-18T05:27', dur: 11520, gaps: 1, grams: 46, wsrc: 'sliced', cost: 0.54, spool: 42, use: 'business' },
{ id: 210, job: 'Sign_CubeCraft', key: '/signs/cubecraft.gcode', printer: 2, status: 'finished', start: '2026-09-17T21:48', end: '2026-09-17T23:53', dur: 7500, gaps: 0, grams: 64, wsrc: 'sliced', cost: 0.61, spool: null, use: 'business',
filaments: [{ slot: 0, spool: 54, grams: 41, hex: '#f4f4f0', cost: 0.61 }, { slot: 1, spool: null, suggested: 51, grams: 23, hex: '#1d4fa8', cost: null }] },
{ id: 209, job: 'Cable_clips', key: '/misc/cable_clips.gcode', printer: 3, status: 'unknown', start: '2026-09-17T19:30', end: '2026-09-17T21:02', dur: null, gaps: 1, grams: 12, wsrc: 'typed', cost: 0.27, spool: 47, use: 'personal' },
{ id: 208, job: 'Lid_v3', key: '/enclosure/lid_v3.gcode', printer: 1, status: 'failed', start: '2026-09-17T16:02', end: '2026-09-17T16:49', dur: 2820, gaps: 0, grams: 118, wsrc: 'typed', cost: 1.35, spool: 52, use: 'business' },
{ id: 207, job: 'Planter_small', key: '/garden/planter_small.gcode', printer: 3, status: 'finished', start: '2026-09-16T10:20', end: '2026-09-16T16:00', dur: 20400, gaps: 0, grams: 212, wsrc: 'recalled', cost: 4.24, spool: 55, use: 'personal' },
{ id: 206, job: 'Bracket_M3', key: '/brackets/m3.gcode', printer: 1, status: 'finished', start: '2026-09-16T08:05', end: '2026-09-16T09:17', dur: 4320, gaps: 0, grams: null, wsrc: null, cost: null, spool: null, use: null },
{ id: 205, job: 'Filament_clip', key: '/misc/filament_clip.gcode', printer: 1, status: 'finished', start: '2026-09-15T14:40', end: '2026-09-15T15:18', dur: 2280, gaps: 0, grams: 7, wsrc: 'sliced', cost: null, spool: null, suggested: 41, use: 'personal' },
{ id: 201, job: 'Test_cube', key: '/calibration/test_cube.gcode', printer: 1, status: 'finished', start: '2026-08-07T14:11', end: '2026-08-07T14:42', dur: 1860, gaps: null, grams: 9, wsrc: 'unknown', cost: 0.10, spool: 52, use: null }
];
var LOW = 200;
@@ -0,0 +1,76 @@
<style>
.dash { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(300px, 1fr); gap: var(--space-6); align-items: start; }
.stats .stat { gap: 4px; }
.stats a.stat { border: 1px solid var(--border-hairline); }
.day { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); font-weight: var(--fw-bold); padding: 14px 16px 6px; }
.feed { display: flex; flex-direction: column; }
.feed-row { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: 110px minmax(0, 1fr) auto auto; gap: 4px var(--space-4); align-items: center; padding: 10px 16px; border-top: 1px solid var(--border-hairline); font-size: 13px; }
.feed-row:hover { background: var(--accent-wash); }
.feed-row:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
.feed-row .job { font-weight: var(--fw-bold); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.feed-row .r { text-align: right; font-variant-numeric: tabular-nums; }
.side-row { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; padding: 10px 16px; border-top: 1px solid var(--border-hairline); font-size: 13px; width: 100%; }
.side-row:hover { background: var(--accent-wash); }
.side-row:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
.card-h { display: flex; align-items: center; gap: var(--space-3); padding: 14px 16px 10px; }
.card-h h2 { margin: 0; font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); }
.card-h a { margin-left: auto; font-size: var(--fs-label); }
.swrow { display: inline-flex; margin-left: 6px; vertical-align: middle; }
.swrow .swatch { margin-left: -6px; width: 18px; height: 18px; }
@media (max-width: 1180px) { .dash { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 620px) { .feed-row { grid-template-columns: minmax(0, 1fr) auto; } .feed-row .when { grid-column: 1 / -1; order: -1; } .feed-row .hide-s { display: none; } .attn { grid-template-columns: 36px minmax(0, 1fr); } .attn .ico { width: 36px; height: 36px; } .attn .btn { grid-column: 1 / -1; justify-content: center; } }
</style>
<script>
(function () {
var x = X;
function attention() {
var lowS = SPOOLS.filter(x.low), tareS = SPOOLS.filter(function (s) { return s.tare == null; }), need = x.printsNeeding(), down = PRINTERS.filter(function (p) { return !p.connected; });
var withSug = need.filter(function (p) { return p.suggested || (p.filaments || []).some(function (f) { return f.suggested; }); }).length;
var items = [];
down.forEach(function (p) { items.push('<div class="attn err"><span class="ico" aria-hidden="true">⚡</span><div><h3>' + x.esc(p.name) + ' lost its connection</h3><p>Last contact ' + x.ago(p.age) + '. Print capture is paused; <b>' + x.esc(p.job) + '</b> is still open and will be reconciled on reconnect.</p></div><a class="btn btn-sm" href="04-printers.html#printer=' + p.id + '">Open printer</a></div>'); });
if (need.length) items.push('<div class="attn warn"><span class="ico" aria-hidden="true">!</span><div><h3>' + need.length + ' prints need a spool or weight</h3><p>Their filament hasn\'t been deducted and their cost is incomplete.' + (withSug ? ' ' + withSug + ' have a same-color suggestion ready.' : '') + '</p></div><a class="btn btn-sm btn-primary" href="03-prints.html?tab=needs">Review ' + need.length + '</a></div>');
if (lowS.length) items.push('<div class="attn warn"><span class="ico" aria-hidden="true">▁</span><div><h3>' + lowS.length + ' spools under 200 g <span class="swrow">' + lowS.map(function (s) { return x.sw(s.c); }).join('') + '</span></h3><p>' + lowS.filter(function (s) { return s.cont; }).length + ' of them are loaded in a printer right now.</p></div><a class="btn btn-sm" href="02-filament.html?f=low">See low stock</a></div>');
if (tareS.length) items.push('<div class="attn info"><span class="ico" aria-hidden="true">?</span><div><h3>' + tareS.length + ' spools can\'t be weighed yet</h3><p>No empty-spool weight is known, so a scale reading can\'t be turned into filament. Picking a spool type fixes most.</p></div><a class="btn btn-sm" href="02-filament.html?f=tare">Set tares</a></div>');
return items.length ? items.join('') : '<div class="attn-done nm-inset">✓ Nothing needs you right now.</div>';
}
function feed() {
var groups = [];
PRINTS.slice(0, 7).forEach(function (p) { var w = x.when(p.start), d = /^(Today|Yesterday)/.test(w) ? w.split(' ')[0] : w.split(' ').slice(0, 2).join(' '); var last = groups[groups.length - 1]; if (!last || last.d !== d) groups.push(last = { d: d, list: [] }); last.list.push(p); });
return groups.map(function (grp) {
return '<div class="day">' + grp.d + '</div>' + grp.list.map(function (p) {
var n = x.needs(p), pr = x.printer(p.printer);
return '<button class="feed-row" type="button" data-print="' + p.id + '"><span class="when">' + x.printSt(p) + '<span class="sub">' + x.when(p.start).split(' ').pop() + '</span></span>' +
'<span style="min-width:0"><span class="job" style="display:block">' + x.esc(p.job) + '</span><span class="sub">' + x.esc(pr.model) + (p.open && !pr.connected ? ' · <span class="prov warn"><i>⚡</i>open across outage</span>' : '') + (n.length ? ' · <span class="prov warn"><i>!</i>needs ' + n.map(function (i) { return i.t; }).join(', ') + '</span>' : '') + '</span></span>' +
'<span class="r hide-s">' + x.durCell(p) + '</span><span class="r">' + (p.grams == null ? '<span class="muted">—</span>' : '<b>' + x.g(p.grams) + '</b><span class="sub">' + x.weightProv(p) + '</span>') + '</span></button>';
}).join('');
}).join('');
}
function printers() {
return PRINTERS.map(function (p) {
return '<a class="side-row" href="04-printers.html#printer=' + p.id + '"><span>' + x.printerSt(p) + '</span><span style="min-width:0"><b style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + x.esc(p.name) + '</b><span class="sub">' + (p.connected ? (p.job ? x.esc(p.job) : 'last: Benchy_x4') : 'last known: printing ' + x.esc(p.job)) + '</span></span><span class="sub">' + (p.connected ? x.ago(p.age) : '<span class="prov warn">' + x.ago(p.age) + '</span>') + '</span></a>';
}).join('');
}
function lows() {
return SPOOLS.filter(x.low).sort(function (a, b) { return a.rem - b.rem; }).map(function (s) {
return '<button class="side-row" type="button" data-spool="' + s.id + '">' + x.sw(s.c) + '<span style="min-width:0"><b>' + x.esc(s.color) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">' + x.esc(x.fullName(s)) + ' · ' + x.esc(x.where(s).short) + '</span></span><span class="num strong" style="color:var(--status-warning)">' + x.g(s.rem) + '</span></button>';
}).join('');
}
function render() {
var today = PRINTS.filter(function (p) { return /^Today/.test(x.when(p.start)) && p.grams != null; });
var usedG = today.reduce(function (a, p) { return a + p.grams; }, 0), usedC = today.reduce(function (a, p) { return a + (p.cost || 0); }, 0);
return '<header class="page-head"><div><h1>Dashboard</h1><p class="sum">Friday 18 September · <b>' + PRINTERS.length + '</b> printers · <b>' + CANARY.spools + '</b> spools</p></div><button class="btn btn-primary primary-desktop" type="button" data-add-spool>+ Add spool</button></header>' +
'<section aria-labelledby="needs-h" class="stack" style="gap:12px"><h2 class="section" id="needs-h" style="margin:0">Needs you</h2><div class="attn-list">' + attention() + '</div></section>' +
'<section class="stats" aria-label="Stock">' +
'<a class="stat nm-raised tile-link" href="02-filament.html"><span class="stat-label">Spools</span><span class="stat-value">' + CANARY.spools + '</span><span class="stat-foot">' + CANARY.brands + ' brands · 6 materials →</span></a>' +
'<div class="stat nm-raised"><span class="stat-label">Filament left</span><span class="stat-value">' + CANARY.kg.toFixed(2) + ' kg</span><span class="stat-foot">net: filament only, no spools</span></div>' +
'<div class="stat nm-raised"><span class="stat-label">Stock value</span><span class="stat-value">' + x.usd(CANARY.value) + '</span><span class="stat-foot">what\'s left × each $/kg</span></div>' +
'<a class="stat nm-raised tile-link" href="03-prints.html"><span class="stat-label">Used today</span><span class="stat-value">' + x.g(usedG) + '</span><span class="stat-foot">' + x.usd(usedC) + ' · ' + today.length + ' done · 2 running →</span></a>' +
'</section>' +
'<div class="dash"><section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="act-h"><div class="card-h"><h2 id="act-h">Recent prints</h2><a href="03-prints.html">All prints →</a></div><div class="feed">' + feed() + '</div></section>' +
'<div class="stack" style="gap:24px"><section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="pr-h"><div class="card-h"><h2 id="pr-h">Printers</h2><a href="04-printers.html">Open →</a></div>' + printers() + '</section>' +
'<section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="low-h"><div class="card-h"><h2 id="low-h">Running low</h2><a href="02-filament.html?f=low">All →</a></div>' + lows() + '</section></div></div>' +
'<button class="btn btn-primary fab" type="button" data-add-spool aria-label="Add spool">+</button>';
}
X.init({ name: 'dashboard', refresh: function () { document.getElementById('page').innerHTML = render(); } }, render);
})();
</script>
+114
View File
@@ -0,0 +1,114 @@
<style>
.toolbar { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
.toolbar .row1 { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.searchbox { position: relative; flex: 1; min-width: 220px; }
.searchbox svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-secondary); }
.searchbox .input { padding-left: 36px; }
.ctl { display: inline-flex; gap: 8px; align-items: center; font-size: var(--fs-label); color: var(--text-secondary); }
.ctl .select { min-height: 36px; padding: 6px 10px; width: auto; font-size: 13px; }
.grp td { background: var(--surface-inset); font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); font-weight: var(--fw-bold); color: var(--text-secondary); padding: 8px 12px; }
.spcell { display: flex; gap: var(--space-3); align-items: center; min-width: 220px; }
.remcell { min-width: 160px; }
.remcell .top { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 5px; }
.rowact { display: flex; gap: 6px; justify-content: flex-end; }
.rowact .btn { min-height: 30px; padding: 4px 10px; }
.weigh-hint { display: flex; gap: var(--space-3); align-items: center; padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); background: var(--accent-wash); border: 1px solid var(--text-accent); font-size: 13px; }
@media (max-width: 760px) { .hide-m { display: none; } .spcell { min-width: 0; } .remcell { min-width: 110px; } .rowact .menu-wrap .btn { padding: 4px 8px; } }
</style>
<script>
(function () {
var x = X;
var q = new URLSearchParams(location.search);
var S = { view: q.get('f') || 'all', q: q.get('q') || '', mat: '', sort: 'rem', dir: 1, cols: 'stock', group: 'none' };
var VIEWS = [
['all', 'All', function () { return true; }],
['low', 'Low', x.low],
['loaded', 'Loaded', function (s) { return !!s.cont; }],
['storage', 'In storage', function (s) { return !s.cont; }],
['tare', 'Tare unknown', function (s) { return s.tare == null; }]
];
function viewFn(k) { for (var i = 0; i < VIEWS.length; i++) if (VIEWS[i][0] === k) return VIEWS[i]; return VIEWS[0]; }
function list() {
var f = viewFn(S.view)[2], qq = S.q.toLowerCase();
var l = SPOOLS.filter(function (s) { return f(s) && (!S.mat || s.mat === S.mat) && (!qq || ('#' + s.id + ' ' + x.fullName(s) + ' ' + s.color + ' ' + x.where(s).text).toLowerCase().indexOf(qq) >= 0); });
var key = { rem: function (s) { return s.rem; }, name: function (s) { return (s.color + s.brand).toLowerCase(); }, value: function (s) { return x.value(s); }, gross: function (s) { return x.gross(s) == null ? -1 : x.gross(s); } }[S.sort];
l.sort(function (a, b) { var A = key(a), B = key(b); return (A < B ? -1 : A > B ? 1 : 0) * S.dir; });
if (S.group !== 'none') { var gk = S.group === 'mat' ? function (s) { return s.mat; } : function (s) { return x.where(s).loaded ? x.where(s).printer.name : 'Storage'; }; l.sort(function (a, b) { return gk(a) < gk(b) ? -1 : gk(a) > gk(b) ? 1 : 0; }); l.gk = gk; }
return l;
}
function th(label, key, cls) { var s = S.sort === key ? (S.dir === 1 ? 'ascending' : 'descending') : 'none'; return '<th class="' + (cls || '') + '" aria-sort="' + s + '"><button class="sort" type="button" data-sort="' + key + '">' + label + '</button></th>'; }
function rowMenu(s) {
return '<div class="menu-wrap"><button class="btn btn-sm btn-icon" type="button" data-menu="rm' + s.id + '" aria-haspopup="menu" aria-expanded="false" aria-label="Actions for ' + x.esc(s.color + ' ' + s.brand) + '">⋯</button><div class="menu" id="rm' + s.id + '" role="menu">' +
'<button role="menuitem" type="button" data-spool="' + s.id + '">Open<span class="hint">↵</span></button><button role="menuitem" type="button" data-spool="' + s.id + '" data-tab-open="weigh">Weigh</button><button role="menuitem" type="button" data-spool="' + s.id + '" data-tab-open="move">' + (s.cont ? 'Move / unload' : 'Load into printer') + '</button><button role="menuitem" type="button" data-spool="' + s.id + '" data-tab-open="edit">Edit</button>' +
(s.label ? '' : '<button role="menuitem" type="button" data-fake="Label PDF generated for #' + s.id + '">Print label</button>') + '<hr><button role="menuitem" type="button" class="danger" data-spool="' + s.id + '" data-then-delete>Delete…</button></div></div>';
}
function rows(l) {
var out = '', last = null, cols = S.cols === 'stock' ? 5 : 5;
l.forEach(function (s) {
if (l.gk) { var gv = l.gk(s); if (gv !== last) { var n = l.filter(function (y) { return l.gk(y) === gv; }), tot = n.reduce(function (a, y) { return a + y.rem; }, 0); out += '<tr class="grp"><td colspan="' + cols + '">' + x.esc(gv) + ' · ' + n.length + ' spools · ' + x.kg(tot) + '</td></tr>'; last = gv; } }
var w = x.where(s), gr = x.gross(s);
out += '<tr class="clickable" tabindex="0" data-row-spool="' + s.id + '" aria-label="' + x.esc(s.color + ', ' + x.fullName(s) + ', ' + s.rem + ' grams') + '">' +
'<td><div class="spcell">' + x.sw(s.c) + '<span style="min-width:0"><span class="strong">' + x.esc(s.color) + '</span> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">' + x.esc(x.fullName(s)) + '</span></span></div></td>' +
'<td><div class="remcell"><div class="top"><b class="num"' + (x.low(s) ? ' style="color:var(--status-warning)"' : '') + '>' + x.g(s.rem) + '</b><span class="sub" style="display:inline">of ' + x.g(s.tot) + '</span></div>' + x.meter(s) + '</div></td>' +
(S.cols === 'stock'
? '<td class="hide-m">' + (w.loaded ? '<span class="st run" style="font-weight:500">' + x.esc(w.short) + '</span>' : '<span class="muted">' + x.esc(w.text) + '</span>') + '</td><td class="r hide-m"><span class="num">' + x.usd(x.value(s)) + '</span><span class="sub">' + x.usd(s.kg) + '/kg</span></td>'
: '<td class="r hide-m">' + (s.tare == null ? '<span class="prov warn"><i>?</i>unknown</span>' : '<span class="num">' + x.g(s.tare) + '</span><span class="sub">' + x.tareProv(s) + '</span>') + '</td><td class="r hide-m">' + (gr == null ? '<span class="muted">unknown</span>' : '<b class="num">' + x.g(gr) + '</b><span class="sub">' + x.remProv(s) + '</span>') + '</td>') +
'<td><div class="rowact">' + (s.tare != null ? '<button class="btn btn-sm hide-m" type="button" data-spool="' + s.id + '" data-tab-open="weigh">⚖ Weigh</button>' : '') + rowMenu(s) + '</div></td></tr>';
});
return out;
}
function render() {
var l = list(), all = SPOOLS.length;
x.D.list = l.map(function (s) { return s.id; });
var chips = [];
if (S.view !== 'all') chips.push(['view', viewFn(S.view)[1]]);
if (S.mat) chips.push(['mat', S.mat]);
if (S.q) chips.push(['q', '“' + S.q + '”']);
var weighable = l.filter(function (s) { return s.tare != null; });
return '<header class="page-head"><div><h1>Filament</h1><p class="sum"><b>' + CANARY.spools + '</b> spools · <b>' + CANARY.kg.toFixed(2) + ' kg</b> net · <b>' + x.usd(CANARY.value) + '</b> · sample page of ' + all + '</p></div>' +
'<div class="actions"><div class="menu-wrap"><button class="btn" type="button" data-menu="pgmenu" aria-haspopup="menu" aria-expanded="false">More ▾</button><div class="menu" id="pgmenu" role="menu"><button role="menuitem" type="button" data-fake="Labels PDF for 4 spools generated">Print ' + SPOOLS.filter(function (s) { return !s.label; }).length + ' missing labels</button><button role="menuitem" type="button" data-soon="Lists">Manage lists (brands, materials…)</button></div></div>' +
'<button class="btn btn-primary primary-desktop" type="button" data-add-spool>+ Add spool</button></div></header>' +
'<section class="toolbar nm-raised" aria-label="Find spools">' +
'<div class="tabs" role="tablist" aria-label="Saved views">' + VIEWS.map(function (v) { var n = SPOOLS.filter(v[2]).length; return '<button role="tab" type="button" data-view="' + v[0] + '" aria-selected="' + (S.view === v[0]) + '">' + v[1] + ' <span class="n' + ((v[0] === 'low' || v[0] === 'tare') && n ? ' warn' : '') + '">' + n + '</span></button>'; }).join('') + '</div>' +
'<div class="row1"><label class="searchbox">' + X.ICON.search + '<input class="input" id="fq" type="search" value="' + x.esc(S.q) + '" placeholder="Filter by color, brand, #id, location…" aria-label="Filter spools"></label>' +
'<label class="ctl">Material <select class="select" id="fmat"><option value="">Any</option>' + ['PLA', 'PETG', 'ASA', 'TPU', 'PA6', 'ABS'].map(function (m) { return '<option' + (S.mat === m ? ' selected' : '') + '>' + m + '</option>'; }).join('') + '</select></label>' +
'<label class="ctl">Group <select class="select" id="fgroup"><option value="none">None</option><option value="mat"' + (S.group === 'mat' ? ' selected' : '') + '>Material</option><option value="loc"' + (S.group === 'loc' ? ' selected' : '') + '>Location</option></select></label>' +
'<div class="tabs" role="group" aria-label="Columns"><button type="button" data-cols="stock" aria-selected="' + (S.cols === 'stock') + '">Stock</button><button type="button" data-cols="weights" aria-selected="' + (S.cols === 'weights') + '">Weights</button></div></div>' +
(chips.length ? '<div class="fchips" aria-label="Active filters"><span class="sub" style="display:inline">' + l.length + ' of ' + all + ' shown</span>' + chips.map(function (c) { return '<span class="fchip">' + x.esc(c[1]) + '<button type="button" data-unchip="' + c[0] + '" aria-label="Remove filter ' + x.esc(c[1]) + '">✕</button></span>'; }).join('') + '<button class="linkish small" type="button" data-unchip="all">Clear all</button></div>' : '') +
'</section>' +
(weighable.length > 1 && S.view !== 'all' ? '<div class="weigh-hint"><b>⚖</b><span style="flex:1">Weighing these? Step through all ' + weighable.length + ' with a scale: each result shows before you save, then the next one opens.</span><button class="btn btn-sm btn-primary" type="button" data-weigh-session>Start weighing</button></div>' : '') +
'<section class="nm-raised table-card" aria-label="Spools">' + (l.length ? '<div class="table-scroll"><table class="tbl"><thead><tr>' + th('Spool', 'name') + th('Remaining <abbr title="net filament">net</abbr>', 'rem') +
(S.cols === 'stock' ? '<th class="hide-m">Location</th>' + th('Value', 'value', 'r hide-m') : '<th class="r hide-m">Tare</th>' + th('Gross (scale)', 'gross', 'r hide-m')) + '<th class="r"><span class="sr-only">Actions</span></th></tr></thead><tbody>' + rows(l) + '</tbody></table></div>' +
'<div class="table-foot"><span class="muted">Click a row or press ↵ to open · <span class="kbd">j</span>/<span class="kbd">k</span> step through in the panel</span><span class="muted">1–' + l.length + ' of ' + (S.view === 'all' && !S.q && !S.mat ? CANARY.spools : l.length) + '</span></div>'
: '<div class="empty"><h3>No spools match</h3><p>' + (S.q ? 'Nothing matches “' + x.esc(S.q) + '”' : 'Nothing in this view') + (S.mat ? ' in ' + S.mat : '') + '.</p><button class="btn btn-sm" type="button" data-unchip="all">Clear filters</button></div>') + '</section>' +
'<button class="btn btn-primary fab" type="button" data-add-spool aria-label="Add spool">+</button>';
}
function redraw(keepFocus) {
var a = document.activeElement, id = a && a.id, pos = a && a.selectionStart;
document.getElementById('page').innerHTML = render();
if (keepFocus && id && document.getElementById(id)) { var el = document.getElementById(id); el.focus(); if (pos != null && el.setSelectionRange) el.setSelectionRange(pos, pos); }
var p = new URLSearchParams(); if (S.view !== 'all') p.set('f', S.view); if (S.q) p.set('q', S.q);
history.replaceState(history.state, '', location.pathname + (p.toString() ? '?' + p : '') + location.hash);
if (x.D.kind === 'spool') document.querySelectorAll('[data-row-spool="' + x.D.id + '"]').forEach(function (r) { r.classList.add('is-selected'); });
}
document.addEventListener('click', function (e) {
var t = e.target, a;
if ((a = t.closest('[data-view]'))) { S.view = a.getAttribute('data-view'); return redraw(); }
if ((a = t.closest('[data-cols]'))) { S.cols = a.getAttribute('data-cols'); return redraw(); }
if ((a = t.closest('[data-sort]'))) { var k = a.getAttribute('data-sort'); S.dir = S.sort === k ? -S.dir : 1; S.sort = k; return redraw(); }
if ((a = t.closest('[data-unchip]'))) { var c = a.getAttribute('data-unchip'); if (c === 'view' || c === 'all') S.view = 'all'; if (c === 'mat' || c === 'all') S.mat = ''; if (c === 'q' || c === 'all') S.q = ''; return redraw(); }
if (t.closest('[data-weigh-session]')) { var first = list().filter(function (s) { return s.tare != null; }); x.D.list = first.map(function (s) { return s.id; }); x.openDrawer('spool', first[0].id, 'weigh'); return; }
if ((a = t.closest('[data-then-delete]'))) { setTimeout(function () { var b = document.querySelector('#drawer [data-delete]'); if (b) b.click(); }, 50); return; }
if (t.closest('button, a, .menu')) return;
if ((a = t.closest('[data-row-spool]'))) { x.D.list = list().map(function (s) { return s.id; }); x.openDrawer('spool', +a.getAttribute('data-row-spool')); }
});
document.addEventListener('input', function (e) {
if (e.target.id === 'fq') { S.q = e.target.value; redraw(true); }
});
document.addEventListener('change', function (e) {
if (e.target.id === 'fmat') { S.mat = e.target.value; redraw(); }
if (e.target.id === 'fgroup') { S.group = e.target.value; redraw(); }
});
X.init({ name: 'filament', refresh: function () { redraw(); } }, render);
})();
</script>
+84
View File
@@ -0,0 +1,84 @@
<style>
.qcard { padding: var(--space-4) var(--space-5); display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(260px, 1.1fr); gap: var(--space-4); align-items: start; }
.qcard.is-done { opacity: .55; }
.qcard .what { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.qcard h3 { margin: 0; font-size: 15px; }
.fixes { display: flex; flex-direction: column; gap: var(--space-3); }
.fix { display: flex; flex-direction: column; gap: 6px; }
.fix .lab { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps); color: var(--status-warning); font-weight: var(--fw-bold); }
.sug { display: flex; gap: 10px; align-items: center; padding: 8px 10px; border-radius: var(--radius-md); border: 1px dashed var(--text-accent); background: var(--accent-wash); font-size: 13px; }
.batch { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; padding: var(--space-3) var(--space-4); }
.chk { width: 20px; height: 20px; accent-color: var(--text-accent); }
.filters { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: center; }
.filters .select, .filters .input { width: auto; min-height: 36px; padding: 6px 10px; font-size: 13px; }
.jobcell { min-width: 200px; }
@media (max-width: 900px) { .qcard { grid-template-columns: auto minmax(0, 1fr); } .qcard .fixes { grid-column: 1 / -1; } }
@media (max-width: 760px) { .hide-m { display: none; } .jobcell { min-width: 0; } }
</style>
<script>
(function () {
var x = X;
var q = new URLSearchParams(location.search);
var S = { tab: q.get('tab') || (x.printsNeeding().length ? 'needs' : 'all'), printer: '', status: '', q: '', sel: {}, done: {} };
function fixUI(p) {
var out = '';
if (p.grams == null) out += '<div class="fix"><span class="lab">! Filament weight</span><span class="row" style="gap:8px"><input class="input num" style="max-width:130px;min-height:36px" placeholder="grams" inputmode="numeric" aria-label="Grams used by ' + x.esc(p.job) + '"><button class="btn btn-sm" type="button" data-done="' + p.id + '" data-msg="Saved weight for ' + x.esc(p.job) + ' · remembered for ' + x.esc(p.key) + '">Save</button></span><span class="sub">From the slicer. Remembered for this gcode path.</span></div>';
var slots = p.filaments ? p.filaments.filter(function (f) { return f.spool == null; }) : (p.spool == null ? [{ suggested: p.suggested, slot: null, grams: p.grams }] : []);
slots.forEach(function (f) {
var s = f.suggested && x.spool(f.suggested);
out += '<div class="fix"><span class="lab">! Spool' + (f.slot != null ? ' for color ' + (f.slot + 1) + ' <span class="swatch swatch-sm" style="vertical-align:-3px"><i style="background:' + f.hex + '"></i></span>' : '') + '</span>' +
(s ? '<div class="sug">' + x.sw(s.c, 'swatch-sm') + '<span style="flex:1;min-width:0"><b>' + x.esc(x.short(s)) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">Same color, loaded in ' + x.esc(x.where(s).short) + '. Not charged until you confirm.</span></span><button class="btn btn-sm btn-primary" type="button" data-done="' + p.id + '" data-msg="Attributed ' + x.esc(p.job) + ' to ' + x.esc(x.short(s)) + (f.grams ? ' · ' + x.g(f.grams) + ' deducted' : '') + '">Use</button></div>' : '') +
'<select class="select" style="min-height:36px" aria-label="Pick spool"><option>' + (s ? 'Or pick another…' : 'Pick a spool…') + '</option>' + SPOOLS.slice(0, 10).map(function (sp) { return '<option>' + x.g(sp.rem) + ' · ' + x.esc(x.short(sp)) + ' · ' + sp.mat + '</option>'; }).join('') + '</select></div>';
});
return out;
}
function needsTab() {
var l = x.printsNeeding();
if (!l.length) return '<div class="nm-raised empty"><h3>✓ Every print is attributed</h3><p>New prints appear here when they end without a spool or weight.</p></div>';
var nsel = Object.keys(S.sel).filter(function (k) { return S.sel[k]; }).length;
return '<div class="batch nm-inset"><label class="row small" style="gap:8px"><input type="checkbox" class="chk" data-selall ' + (nsel === l.length ? 'checked' : '') + '> Select all ' + l.length + '</label><span class="spacer"></span><span class="small muted">' + nsel + ' selected</span><button class="btn btn-sm" type="button" ' + (nsel ? 'data-fake="Attributed ' + nsel + ' prints to one spool"' : 'disabled') + '>Attribute selected to one spool…</button></div>' +
'<p class="small muted" style="margin:0">Oldest first. Until these have a spool and a weight, nothing is deducted and cost stays incomplete.</p>' +
'<div class="stack" style="gap:12px">' + l.slice().reverse().map(function (p) {
var pr = x.printer(p.printer), done = S.done[p.id];
return '<article class="qcard nm-raised' + (done ? ' is-done' : '') + '"><input type="checkbox" class="chk" data-sel="' + p.id + '" aria-label="Select ' + x.esc(p.job) + '" ' + (S.sel[p.id] ? 'checked' : '') + '>' +
'<div class="what"><h3><button class="linkish" type="button" data-print="' + p.id + '" style="text-decoration:none;color:var(--text-primary)">' + x.esc(p.job) + '</button></h3><span class="sub">' + x.esc(pr.name) + ' · ' + x.when(p.end || p.start) + '</span><span class="row small" style="gap:12px;margin-top:4px">' + x.printSt(p) + '<span>' + x.durCell(p).replace(/<span class="sub[^"]*">/g, ' <span class="sub" style="display:inline">') + '</span>' + (p.grams != null ? '<span><b>' + x.g(p.grams) + '</b> ' + x.weightProv(p) + '</span>' : '') + '</span>' +
(p.filaments ? '<span class="sub">' + p.filaments.length + ' colors · ' + p.filaments.filter(function (f) { return f.spool; }).length + ' attributed</span>' : '') + '</div>' +
'<div class="fixes">' + (done ? '<span class="st ok">Done</span>' : fixUI(p)) + '</div></article>';
}).join('') + '</div>';
}
function allTab(filter) {
var l = PRINTS.filter(function (p) { return filter(p) && (!S.printer || p.printer === +S.printer) && (!S.status || p.status === S.status) && (!S.q || (p.job + ' ' + p.key).toLowerCase().indexOf(S.q.toLowerCase()) >= 0); });
var head = '<div class="filters"><input class="input" id="pq" type="search" placeholder="Job name or path…" value="' + x.esc(S.q) + '" aria-label="Filter prints"><select class="select" id="pp" aria-label="Printer"><option value="">All printers</option>' + PRINTERS.map(function (p) { return '<option value="' + p.id + '"' + (+S.printer === p.id ? ' selected' : '') + '>' + x.esc(p.name) + '</option>'; }).join('') + '</select>' +
'<select class="select" id="ps" aria-label="Status"><option value="">Any status</option>' + ['running', 'finished', 'failed', 'unknown'].map(function (s) { return '<option' + (S.status === s ? ' selected' : '') + '>' + s + '</option>'; }).join('') + '</select><span class="small muted">' + l.length + ' prints</span></div>';
if (!l.length) return head + '<div class="nm-raised empty"><h3>No prints match</h3><p>Change the printer, status or search.</p></div>';
return head + '<section class="nm-raised table-card"><div class="table-scroll"><table class="tbl"><thead><tr><th>Job</th><th>Status</th><th class="r hide-m">Duration</th><th class="r">Filament</th><th class="hide-m">Spool</th><th class="r hide-m">Cost</th><th class="hide-m">Use</th></tr></thead><tbody>' +
l.map(function (p) { var pr = x.printer(p.printer), n = x.needs(p); return '<tr class="clickable" tabindex="0" data-row-print="' + p.id + '"><td><div class="jobcell"><span class="strong">' + x.esc(p.job) + '</span><span class="sub">' + x.esc(pr.model) + ' · ' + x.when(p.start) + '</span></div></td><td>' + x.printSt(p) + (n.length ? '<span class="sub prov warn"><i>!</i>needs ' + n.map(function (i) { return i.k; }).join(' + ') + '</span>' : '') + '</td><td class="r hide-m">' + x.durCell(p) + '</td><td class="r">' + (p.grams == null ? '<span class="muted">—</span>' : '<b class="num">' + x.g(p.grams) + '</b><span class="sub">' + x.weightProv(p) + '</span>') + '</td><td class="hide-m">' + x.spoolLinks(p) + '</td><td class="r hide-m">' + (p.cost == null ? '<span class="muted">pending</span>' : x.usd(p.cost)) + '</td><td class="hide-m">' + (p.use ? '<span class="small">' + p.use + '</span>' : '<span class="muted small">not recorded</span>') + '</td></tr>'; }).join('') +
'</tbody></table></div><div class="table-foot"><span class="muted">Durations marked ≤ span a connection gap and are upper bounds. Hover for why.</span><span class="muted">' + l.length + ' of ' + PRINTS.length + '</span></div></section>';
}
function render() {
var n = x.printsNeeding().length, run = PRINTS.filter(function (p) { return p.status === 'running'; }).length;
var body = S.tab === 'needs' ? needsTab() : S.tab === 'all' ? allTab(function () { return true; }) : S.tab === 'running' ? allTab(function (p) { return p.status === 'running'; }) : '<div class="nm-raised empty"><h3>No archived prints</h3><p>Archiving hides a print from history. Its grams and cost still count.</p></div>';
return '<header class="page-head"><div><h1>Prints</h1><p class="sum"><b>' + PRINTS.length + '</b> captured (sample) · <b>' + run + '</b> running · <b>' + n + '</b> need attention</p></div></header>' +
'<div class="tabs" role="tablist" aria-label="Print lists">' + [['needs', 'Needs attention', n, 1], ['all', 'All', PRINTS.length], ['running', 'Running', run], ['archived', 'Archived', 0]].map(function (t) { return '<button role="tab" type="button" data-ptab="' + t[0] + '" aria-selected="' + (S.tab === t[0]) + '">' + t[1] + ' <span class="n' + (t[3] && t[2] ? ' warn' : '') + '">' + t[2] + '</span></button>'; }).join('') + '</div>' +
'<div class="stack" style="gap:16px">' + body + '</div>';
}
function redraw(focusId) {
var pos = focusId && document.getElementById(focusId) && document.getElementById(focusId).selectionStart;
document.getElementById('page').innerHTML = render();
if (focusId && document.getElementById(focusId)) { var el = document.getElementById(focusId); el.focus(); if (pos != null) el.setSelectionRange(pos, pos); }
history.replaceState(history.state, '', location.pathname + '?tab=' + S.tab + location.hash);
}
document.addEventListener('click', function (e) {
var t = e.target, a;
if ((a = t.closest('[data-ptab]'))) { S.tab = a.getAttribute('data-ptab'); return redraw(); }
if ((a = t.closest('[data-done]'))) { var id = +a.getAttribute('data-done'), pp = x.print(id); if (/weight/.test(a.getAttribute('data-msg'))) { pp.grams = 18; pp.wsrc = 'typed'; } else if (pp.filaments) { pp.filaments.forEach(function (f) { if (f.spool == null) f.spool = f.suggested || 52; }); } else { pp.spool = pp.suggested || 52; } X.toast(a.getAttribute('data-msg') + ' <a href="#print=' + id + '" data-print="' + id + '">View</a>'); return redraw(); }
if (t.matches('[data-selall]')) { x.printsNeeding().forEach(function (p) { S.sel[p.id] = t.checked; }); return redraw(); }
if (t.matches('[data-sel]')) { S.sel[t.getAttribute('data-sel')] = t.checked; return redraw(); }
if (t.closest('button, a, input, select')) return;
if ((a = t.closest('[data-row-print]'))) x.openDrawer('print', +a.getAttribute('data-row-print'));
});
document.addEventListener('input', function (e) { if (e.target.id === 'pq') { S.q = e.target.value; redraw('pq'); } });
document.addEventListener('change', function (e) { if (e.target.id === 'pp') { S.printer = e.target.value; redraw(); } if (e.target.id === 'ps') { S.status = e.target.value; redraw(); } });
X.init({ name: 'prints', refresh: function () { redraw(); } }, render);
})();
</script>
+84
View File
@@ -0,0 +1,84 @@
<style>
.jump { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }
.jump a { display: inline-flex; gap: 8px; align-items: center; padding: 6px 12px; border-radius: var(--radius-pill); border: 1px solid var(--border-control); text-decoration: none; color: var(--text-primary); font-size: 13px; background: var(--surface-raised); box-shadow: var(--nm-raised-1); }
.jump a:hover { border-color: var(--text-accent); }
.pgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: var(--space-6); align-items: start; }
.pcard { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); scroll-margin-top: 16px; }
.pcard.attn-card { border: 2px solid var(--status-warning); }
.phead { display: flex; gap: var(--space-3); align-items: flex-start; }
.phead h2 { margin: 0; font-size: var(--fs-h3); line-height: var(--lh-snug); }
.now { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px var(--space-3); padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); width: 100%; font-size: 13px; }
.now:hover { border-color: var(--text-accent); }
.now:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
.now.stale { border: 1px dashed var(--status-warning); }
.cam { aspect-ratio: 16 / 7; border-radius: var(--radius-md); background: radial-gradient(ellipse at 50% 95%, #5a4b3a 0 30%, transparent 31%), linear-gradient(180deg, #1c1d22 0%, #2b2c33 60%, #3a3326 100%); border: 1px solid var(--border-control); position: relative; overflow: hidden; }
.cam::after { content: ""; position: absolute; left: 34%; right: 34%; bottom: 18%; height: 26%; background: repeating-linear-gradient(0deg, #6b8fd6 0 3px, #5a7cc2 3px 6px); border-radius: 4px 4px 0 0; }
.cam .stamp { position: absolute; left: 8px; top: 6px; font-size: var(--fs-caption); color: #f4f4f6; background: rgba(0,0,0,.55); padding: 2px 8px; border-radius: 99px; z-index: 1; }
.cam-off { padding: var(--space-3); border-radius: var(--radius-md); border: 1px dashed var(--border-control); text-align: center; color: var(--text-secondary); font-size: var(--fs-label); }
.holder { padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); display: flex; flex-direction: column; gap: 6px; }
.holder-h { display: flex; gap: var(--space-3); align-items: center; font-size: var(--fs-label); }
.fill { display: inline-flex; gap: 3px; }
.fill i { width: 14px; height: 8px; border-radius: 2px; border: 1px solid var(--border-control); }
.fill i.on { background: var(--text-accent); border-color: var(--text-accent); }
.hrow { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; padding: 6px 8px; margin: 0 -8px; border-radius: var(--radius-sm); font-size: 13px; }
.hrow:hover { background: var(--accent-wash); }
.hrow:focus-visible { outline: var(--focus-width) solid var(--focus-ring); }
.lost { padding: var(--space-4); border-radius: var(--radius-md); border: 1px solid var(--status-warning); background: var(--wash-warning); font-size: 13px; display: flex; flex-direction: column; gap: 6px; }
.lost h3 { margin: 0; font-size: 14px; color: var(--status-warning); }
.lost ul { margin: 0; padding-left: 18px; }
details.cfg summary { cursor: pointer; font-size: var(--fs-label); font-weight: var(--fw-bold); }
.future { padding: var(--space-6); border: 2px dashed var(--border-control); border-radius: var(--radius-lg); }
.fgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: var(--space-4); margin-top: var(--space-4); }
.fitem { padding: var(--space-4); border: 1px dashed var(--border-control); border-radius: var(--radius-md); font-size: var(--fs-label); display: flex; flex-direction: column; gap: 6px; }
.fitem b { font-size: 13px; }
body.dev-only [data-feature] { display: none !important; }
body:not(.dev-only) [data-devonly] { display: none !important; }
</style>
<script>
(function () {
var x = X, FEAT = '<span class="prov info" title="Only on the printer-connections feature branch"><i>◈</i>feature</span>';
function lastPrint(pr) { return PRINTS.filter(function (p) { return p.printer === pr.id && p.status !== 'running'; })[0]; }
function holder(c) {
var sp = x.contents(c.id), free = c.cap - sp.length;
return '<div class="holder"><div class="holder-h"><b>' + x.esc(c.label) + '</b><span class="fill" aria-label="' + sp.length + ' of ' + c.cap + ' in use">' + Array.apply(null, Array(c.cap)).map(function (_, i) { return '<i class="' + (i < sp.length ? 'on' : '') + '"></i>'; }).join('') + '</span><span class="muted">' + sp.length + ' / ' + c.cap + '</span><span class="spacer"></span>' +
(free > 0 ? '<button class="btn btn-sm" type="button" data-load-into="' + c.id + '" style="min-height:28px;padding:3px 10px">+ Load</button>' : '<span class="sub">full</span>') + '</div>' +
(sp.length ? sp.map(function (s) { return '<button class="hrow" type="button" data-spool="' + s.id + '">' + x.sw(s.c, 'swatch-sm') + '<span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><b>' + x.esc(s.color) + '</b> <span class="muted">' + x.esc(s.brand + ' ' + s.mat + (s.type ? ' ' + s.type : '')) + '</span></span><span class="num' + (x.low(s) ? '" style="color:var(--status-warning);font-weight:700">⚠ ' : '">') + x.g(s.rem) + '</span></button>'; }).join('') : '<span class="sub">Empty</span>') + '</div>';
}
function card(pr) {
var open = pr.openPrint && x.print(pr.openPrint), last = lastPrint(pr), lostC = !pr.connected;
var conn = lostC
? '<div class="lost" role="status"><h3>⚡ Connection lost</h3><span data-feature>Reconnecting. Last contact <b>' + x.ago(pr.age) + '</b>. ' + FEAT + '</span><span data-devonly>Extrudex\'s MQTT session dropped. When it dropped isn\'t recorded on dev.</span>' +
'<ul class="small"><li>The printer may still be printing. This is Extrudex\'s connection, not the printer\'s power.</li><li><b>Capture is paused.</b> A job that starts and ends during the outage won\'t be recorded.</li><li>On reconnect: if <i>' + x.esc(pr.job) + '</i> is still running, it stays open with <b>+1 gap</b>; otherwise it closes as <b>unknown</b>.</li></ul></div>'
: '<div class="row small" style="gap:8px"><span class="fresh"><span class="dot"></span></span><span data-feature>Ready · last report ' + x.ago(pr.age) + ' ' + FEAT + '</span><span data-devonly>Connected</span></div>';
var now = open
? '<button class="now' + (pr.stale ? ' stale' : '') + '" type="button" data-print="' + open.id + '"><b>' + x.esc(open.job) + '</b><span class="num">' + x.dur(x.since(open.start)) + '</span>' + (pr.stale ? '<span class="sub" style="grid-column:1/-1">open record · started ' + x.when(open.start).split(' ').pop() + ' · watched until the drop</span><span class="sub" data-feature style="grid-column:1/-1"><span class="prov warn" style="white-space:normal">Last known: printing · ' + x.ago(pr.age) + '. Cached from the lost session, not live.</span></span>' : '<span class="sub">started ' + x.when(open.start).split(' ').pop() + ' · weight read from the 3MF when it ends</span><span class="sub">so far</span>') + '</button>'
: (last ? '<button class="now" type="button" data-print="' + last.id + '"><span><span class="sub" style="display:inline">Last · </span><b>' + x.esc(last.job) + '</b></span>' + x.printSt(last) + '<span class="sub">ended ' + x.when(last.end) + '</span><span class="sub">' + x.durCell(last).replace(/<span class="sub[^"]*">[^<]*<\/span>/, '') + '</span></button>' : '');
var cam = pr.connected ? '<div class="cam" role="img" aria-label="Camera snapshot from ' + x.esc(pr.name) + '"><span class="stamp">snapshot 09:41:0' + pr.id + '</span></div>' : '<div class="cam-off">Camera unavailable while disconnected</div>';
return '<article class="pcard nm-raised' + (lostC ? ' attn-card' : '') + '" id="printer-' + pr.id + '" aria-labelledby="ph' + pr.id + '"><div class="phead"><div style="flex:1;min-width:0"><h2 id="ph' + pr.id + '">' + x.esc(pr.name) + '</h2><span class="sub">Bambu Lab · ' + pr.model + '</span></div>' + x.printerSt(pr) +
'<div class="menu-wrap"><button class="btn btn-sm btn-icon" type="button" data-menu="pm' + pr.id + '" aria-haspopup="menu" aria-expanded="false" aria-label="Actions for ' + x.esc(pr.name) + '">⋯</button><div class="menu" id="pm' + pr.id + '" role="menu"><button role="menuitem" type="button" data-fake="Edit connection opens here (admin)">Edit connection</button><button role="menuitem" type="button" data-fake="Manage holders opens here (admin)">Manage holders</button><hr><button role="menuitem" type="button" class="danger" data-fake="Remove printer asks for confirmation first">Remove printer…</button></div></div></div>' +
conn + now + cam + '<div class="stack" style="gap:8px"><div class="row small"><b>Loaded filament</b><span class="sub" style="display:inline">recorded by Extrudex, not read from the printer</span></div>' + pr.containers.map(holder).join('') + '</div>' +
'<details class="cfg"><summary>Connection details</summary><dl class="kv" style="margin-top:10px"><dt>IP</dt><dd>' + x.esc(pr.ip) + '</dd><dt>Serial</dt><dd>' + x.esc(pr.serial) + '</dd><dt>Access code</dt><dd>✓ stored, never shown</dd>' +
'<dt data-feature>Lifecycle</dt><dd data-feature>' + pr.lifecycle + (pr.stale ? ' · <code>stale</code>' : '') + ' ' + FEAT + '</dd><dt data-feature>Session</dt><dd data-feature>#' + pr.session + ' (increments on reconnect)</dd></dl></details></article>';
}
function render() {
var order = PRINTERS.slice().sort(function (a, b) { var r = function (p) { return !p.connected ? 0 : p.state === 'RUNNING' ? 1 : 2; }; return r(a) - r(b); });
var nRun = PRINTERS.filter(function (p) { return p.connected && p.state === 'RUNNING'; }).length, nDown = PRINTERS.filter(function (p) { return !p.connected; }).length;
return '<header class="page-head"><div><h1>Printers</h1><p class="sum"><b>' + PRINTERS.length + '</b> printers · <b>' + nRun + '</b> printing · <b style="color:var(--status-warning)">' + nDown + '</b> disconnected · polled every 10 s, read-only MQTT</p></div>' +
'<div class="actions"><div class="tabs" role="group" aria-label="Data shown"><button type="button" data-ds="dev" aria-selected="' + document.body.classList.contains('dev-only') + '">dev today</button><button type="button" data-ds="feat" aria-selected="' + !document.body.classList.contains('dev-only') + '">+ feature branch</button></div><button class="btn btn-primary primary-desktop" type="button" data-fake="Add printer form opens here (admin)">+ Add printer</button></div></header>' +
'<nav class="jump" aria-label="Jump to printer"><span class="small muted">Jump to</span>' + order.map(function (p) { return '<a href="#printer=' + p.id + '">' + x.printerSt(p) + ' ' + x.esc(p.model) + '</a>'; }).join('') + '</nav>' +
'<div class="pgrid">' + order.map(card).join('') + '</div>' +
'<section class="future" aria-labelledby="fut-h"><h2 id="fut-h" style="margin:0;font-size:var(--fs-h3)"><span class="chip chip-neutral chip-dashed">Future, not built</span> Needs data Extrudex doesn\'t store</h2><div class="fgrid">' +
[['Progress % and time left', '<code>mc_percent</code> is parsed but not stored or served. ETA isn\'t read.'], ['Tray position in the AMS', 'Holders store which spools are in them, not which tray. The AMS tray index isn\'t recorded.'], ['Which spool is feeding now', '"Loaded" is recorded; "in use" isn\'t. Hence "not confirmed in use".'], ['Nozzle / bed temperature, layer', 'Not parsed from the report.'], ['AMS remaining % and humidity', 'Not read. <code>remain</code> only means something for RFID spools.'], ['Alert when MQTT drops', 'Only in container logs today. Dev doesn\'t store when it dropped.']].map(function (f) { return '<div class="fitem"><b>' + f[0] + '</b><span class="muted">' + f[1] + '</span></div>'; }).join('') + '</div></section>';
}
function focusPrinter() {
var m = /^#printer=(\d+)/.exec(location.hash); if (!m) return;
var el = document.getElementById('printer-' + m[1]); if (!el) return;
el.scrollIntoView({ block: 'start', behavior: 'smooth' }); el.classList.remove('is-flash'); void el.offsetWidth; el.classList.add('is-flash');
}
document.addEventListener('click', function (e) {
var a = e.target.closest('[data-ds]'); if (a) { document.body.classList.toggle('dev-only', a.getAttribute('data-ds') === 'dev'); document.getElementById('page').innerHTML = render(); }
});
window.addEventListener('hashchange', focusPrinter);
X.init({ name: 'printers', after: function () { setTimeout(focusPrinter, 60); }, refresh: function () { document.getElementById('page').innerHTML = render(); } }, render);
})();
</script>
+71
View File
@@ -0,0 +1,71 @@
<style>
.map { padding: var(--space-5); }
.map svg { width: 100%; height: auto; display: block; }
.map .node rect { fill: var(--surface-raised); stroke: var(--border-control); stroke-width: 1.5; }
.map .node.hub rect { fill: var(--accent-wash); stroke: var(--text-accent); stroke-width: 2; }
.map .node.page rect { stroke: var(--text-primary); }
.map text { fill: var(--text-primary); font-family: var(--font-mono); font-size: 13px; }
.map text.s { fill: var(--text-secondary); font-size: 11px; }
.map .e { stroke: var(--text-secondary); stroke-width: 1.5; fill: none; }
.map .e.hot { stroke: var(--text-accent); stroke-width: 2; }
.map .lbl { fill: var(--text-secondary); font-size: 10.5px; }
.map a:focus-visible rect { stroke: var(--focus-ring); stroke-width: 3; }
.flows { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 330px), 1fr)); gap: var(--space-4); }
.flow { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-3); }
.flow h3 { margin: 0; font-size: 15px; }
.flow ol { margin: 0; padding-left: 20px; font-size: 13px; color: var(--text-secondary); display: flex; flex-direction: column; gap: 4px; }
.flow ol b { color: var(--text-primary); }
.flow .btn { align-self: flex-start; }
.compare { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: var(--space-4); }
.compare > div { padding: var(--space-4); font-size: 13px; }
.compare h3 { margin: 0 0 8px; font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); }
.compare ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
</style>
<script>
(function () {
function node(x, y, w, h, t, s, cls, href) {
var inner = '<g class="node ' + cls + '"><rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" rx="14"/><text x="' + (x + 16) + '" y="' + (y + 26) + '" font-weight="700">' + t + '</text>' + s.map(function (l, i) { return '<text class="s" x="' + (x + 16) + '" y="' + (y + 46 + i * 16) + '">' + l + '</text>'; }).join('') + '</g>';
return href ? '<a href="' + href + '">' + inner + '</a>' : inner;
}
function edge(d, hot, lx, ly, label) { return '<path class="e' + (hot ? ' hot' : '') + '" d="' + d + '" marker-end="url(#ar' + (hot ? 'h' : '') + ')"/>' + (label ? '<text class="lbl" x="' + lx + '" y="' + ly + '">' + label + '</text>' : ''); }
function svg() {
return '<svg viewBox="0 -50 1100 600" role="img" aria-labelledby="map-t map-d"><title id="map-t">Extrudex iteration 2 flow map</title><desc id="map-d">Four pages share one navigation, a command palette and a detail panel. The Dashboard links into each page\'s filtered views; spools, prints and holders open in the panel from any page.</desc>' +
'<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 10 5 0 10z" fill="var(--text-secondary)"/></marker><marker id="arh" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 10 5 0 10z" fill="var(--text-accent)"/></marker></defs>' +
node(30, 30, 230, 110, 'Dashboard', ['“Needs you” queue', 'stock · recent prints', 'printers · running low'], 'page', '01-dashboard.html') +
node(310, 30, 230, 110, 'Filament', ['saved views + counts', 'Stock / Weights columns', 'weigh session'], 'page', '02-filament.html') +
node(590, 30, 230, 110, 'Prints', ['Needs attention queue', 'All · Running · Archived', 'batch attribute'], 'page', '03-prints.html') +
node(870, 30, 200, 110, 'Printers', ['urgency order', 'holders + Load', 'connection state'], 'page', '04-printers.html') +
node(250, 300, 600, 130, 'Detail panel (#spool= · #print= · #load=)', ['Spool: Overview · Weigh · Move · Edit (+ 409 / 400 states)', 'Print: what it needs, fixed in place · provenance explained', 'Load: pick a stored spool for a holder with space', 'Opens over any page · Esc / back closes · ↑↓ / j k step through the list'], 'hub', '02-filament.html#spool=47') +
node(30, 450, 300, 90, '⌘K / “/” palette', ['spools · prints · printers', '“weigh copper” jumps straight in'], 'hub', null) +
node(770, 450, 300, 90, 'Toasts + badges', ['every save confirms what changed', 'nav counts update immediately'], '', null) +
edge('M262 85 L306 85', 1, 262, 76, 'views') +
edge('M180 30 C180 -18 705 -18 705 26', 1, 395, -8, 'Review N prints') +
edge('M110 30 C110 -38 970 -38 970 26', 1, 520, -30, 'Open printer') +
edge('M145 140 C145 230 330 250 400 298', 1, 150, 225, 'spool / print rows') +
edge('M425 140 L470 298', 1, 452, 230, 'row / ↵') +
edge('M705 140 L640 298', 1, 680, 230, 'row / fix') +
edge('M970 140 C970 230 860 270 800 298', 1, 900, 235, 'spool · + Load') +
edge('M180 450 C180 400 240 380 248 380', 0, 120, 420, 'open anything') +
edge('M850 380 C900 400 920 420 920 448', 0, 930, 415, 'after save') +
'</svg>';
}
var FLOWS = [
['Morning check (≈1 min)', ['<b>Dashboard</b> opens on “Needs you”', 'Review 3 → <b>Use</b> the same-color suggestion', 'Badge on <b>Prints</b> drops as each one clears'], '01-dashboard.html', 'Start on the Dashboard'],
['Weigh the low spools', ['<b>Filament → Low</b> view', '<b>Start weighing</b>: type the scale reading', 'See before → after, then <b>Save & weigh next</b>'], '02-filament.html?f=low', 'Open low stock'],
['Load a spool into a printer', ['<b>Printers</b> → A1 AMS Lite → <b>+ Load</b>', 'Filter stored spools, pick one', 'Toast confirms; the holder fill updates'], '04-printers.html#printer=3', 'Open A1'],
['Find anything', ['Press <b>/</b> or <b>⌘K</b> on any page', 'Type <i>copper</i>, <i>#47</i> or <i>weigh black</i>', '↵ opens the panel right there'], '02-filament.html', 'Try it on Filament'],
['Someone changed it first (409)', ['Spool #47 → <b>Edit</b>', 'Demo state: <b>409 conflict</b>', 'Side-by-side; reapply your change, or discard it'], '02-filament.html#spool=47&tab=edit', 'Open the editor'],
['A spool with no tare', ['<b>Tare unknown</b> view (3)', 'Weigh tab explains why it can\'t weigh yet', 'Pick a spool type, or weigh the empty spool'], '02-filament.html?f=tare#spool=44&tab=weigh', 'Open #44'],
['A printer drops off', ['<b>Printers</b>: A1 sits first with a warning border', 'Toggle <b>dev today / + feature branch</b>', 'What happens on reconnect is spelled out'], '04-printers.html#printer=3', 'Open Printers'],
['When the data is not live', ['Mockup pill (bottom left) → <b>Simulate polling failure</b>', 'Header turns amber, banner says the data\'s age', '<b>Replay first load</b> shows skeletons'], '01-dashboard.html', 'Open Dashboard']
];
function render() {
return '<header class="page-head"><div><h1>Flow map</h1><p class="sum">Iteration 2 · how the screens connect, and <b>8 flows</b> to click through</p></div><a class="btn" href="../../screens/01-dashboard.html">Compare with iteration 1 →</a></header>' +
'<section class="map nm-raised" aria-label="Flow diagram">' + svg() + '</section>' +
'<section aria-labelledby="fl-h" class="stack" style="gap:12px"><h2 class="section" id="fl-h" style="margin:0">Try these flows</h2><div class="flows">' + FLOWS.map(function (f) { return '<article class="flow nm-raised"><h3>' + f[0] + '</h3><ol>' + f[1].map(function (s) { return '<li>' + s + '</li>'; }).join('') + '</ol><a class="btn btn-sm" href="' + f[2] + '">' + f[3] + ' →</a></article>'; }).join('') + '</div></section>' +
'<section class="compare" aria-label="What changed"><div class="nm-inset"><h3>Iteration 1</h3><ul><li>“Prints” and “All prints” were dead ends</li><li>Edit left the list for a separate page</li><li>A spool in an AMS slot or low-stock list went nowhere</li><li>Every data point was a loud chip, so a legend was needed</li><li>No freshness, loading or error state</li><li>Invented “slot 2” positions the database doesn\'t store</li></ul></div>' +
'<div class="nm-raised"><h3>Iteration 2</h3><ul><li>Every spool, print and holder opens one shared panel, deep-linkable</li><li>Dashboard leads with what needs doing, each item one click from the fix</li><li>Chips only for state; provenance is quiet text with a tooltip</li><li>Palette, badges, toasts, j/k stepping, Esc/back to close</li><li>“Live · updated 3s ago”, with an honest offline state</li><li>Holders show fill (“3 / 4”), not tray positions</li></ul></div></section>';
}
X.init({ name: 'map' }, render);
})();
</script>