From d9ed1898b955185823ee5af669dd5badaa724449 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Sat, 19 Sep 2026 11:31:50 -0400 Subject: [PATCH] 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) --- .gitignore | 2 + 00-understanding.md | 193 +++ README.md | 59 +- SUMMARY.md | 157 ++ design-system-v3.css | 394 +++++ design-system-v3.md | 263 +++ fonts/OFL-LICENSE.txt | 93 ++ fonts/jetbrains-mono-latin-400-normal.woff2 | Bin 0 -> 21168 bytes fonts/jetbrains-mono-latin-500-normal.woff2 | Bin 0 -> 21832 bytes fonts/jetbrains-mono-latin-700-normal.woff2 | Bin 0 -> 21908 bytes fonts/jetbrains-mono-latin-800-normal.woff2 | Bin 0 -> 21236 bytes iteration-2/00-ux-review.md | 247 +++ iteration-2/screens/01-dashboard.html | 1342 +++++++++++++++ iteration-2/screens/02-filament.html | 1380 ++++++++++++++++ iteration-2/screens/03-prints.html | 1350 +++++++++++++++ iteration-2/screens/04-printers.html | 1350 +++++++++++++++ iteration-2/screens/index.html | 1337 +++++++++++++++ iteration-2/source/app.js | 460 ++++++ iteration-2/source/build.py | 72 + iteration-2/source/components.css | 239 +++ iteration-2/source/data.js | 58 + iteration-2/source/pages/01-dashboard.html | 76 + iteration-2/source/pages/02-filament.html | 114 ++ iteration-2/source/pages/03-prints.html | 84 + iteration-2/source/pages/04-printers.html | 84 + iteration-2/source/pages/index.html | 71 + iteration-3/00-changes.md | 142 ++ iteration-3/screens/01-dashboard.html | 1509 +++++++++++++++++ iteration-3/screens/02-filament.html | 1548 ++++++++++++++++++ iteration-3/screens/03-prints.html | 1517 +++++++++++++++++ iteration-3/screens/04-printers.html | 1517 +++++++++++++++++ iteration-3/screens/05-lists.html | 1509 +++++++++++++++++ iteration-3/screens/06-labels.html | 1509 +++++++++++++++++ iteration-3/screens/07-access.html | 1491 +++++++++++++++++ iteration-3/screens/08-system.html | 1486 +++++++++++++++++ iteration-3/screens/09-scan-holder.html | 1512 +++++++++++++++++ iteration-3/screens/10-scan-spool.html | 1480 +++++++++++++++++ iteration-3/screens/devices.html | 1482 +++++++++++++++++ iteration-3/screens/index.html | 1512 +++++++++++++++++ iteration-3/source/app.js | 553 +++++++ iteration-3/source/build.py | 79 + iteration-3/source/components.css | 275 ++++ iteration-3/source/data.js | 96 ++ iteration-3/source/pages/01-dashboard.html | 76 + iteration-3/source/pages/02-filament.html | 115 ++ iteration-3/source/pages/03-prints.html | 84 + iteration-3/source/pages/04-printers.html | 84 + iteration-3/source/pages/05-lists.html | 76 + iteration-3/source/pages/06-labels.html | 76 + iteration-3/source/pages/07-access.html | 58 + iteration-3/source/pages/08-system.html | 53 + iteration-3/source/pages/09-scan-holder.html | 79 + iteration-3/source/pages/10-scan-spool.html | 47 + iteration-3/source/pages/devices.html | 49 + iteration-3/source/pages/index.html | 79 + screens/01-dashboard.html | 718 ++++++++ screens/02-filament.html | 724 ++++++++ screens/03-spool-form.html | 828 ++++++++++ screens/04-printers.html | 751 +++++++++ source/app.css | 87 + source/build.py | 122 ++ source/checks/contrast.py | 10 + source/checks/table.py | 22 + source/pages/01-dashboard.html | 176 ++ source/pages/02-filament.html | 182 ++ source/pages/03-spool-form.html | 286 ++++ source/pages/04-printers.html | 209 +++ 67 files changed, 33632 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 00-understanding.md create mode 100644 SUMMARY.md create mode 100644 design-system-v3.css create mode 100644 design-system-v3.md create mode 100644 fonts/OFL-LICENSE.txt create mode 100644 fonts/jetbrains-mono-latin-400-normal.woff2 create mode 100644 fonts/jetbrains-mono-latin-500-normal.woff2 create mode 100644 fonts/jetbrains-mono-latin-700-normal.woff2 create mode 100644 fonts/jetbrains-mono-latin-800-normal.woff2 create mode 100644 iteration-2/00-ux-review.md create mode 100644 iteration-2/screens/01-dashboard.html create mode 100644 iteration-2/screens/02-filament.html create mode 100644 iteration-2/screens/03-prints.html create mode 100644 iteration-2/screens/04-printers.html create mode 100644 iteration-2/screens/index.html create mode 100644 iteration-2/source/app.js create mode 100644 iteration-2/source/build.py create mode 100644 iteration-2/source/components.css create mode 100644 iteration-2/source/data.js create mode 100644 iteration-2/source/pages/01-dashboard.html create mode 100644 iteration-2/source/pages/02-filament.html create mode 100644 iteration-2/source/pages/03-prints.html create mode 100644 iteration-2/source/pages/04-printers.html create mode 100644 iteration-2/source/pages/index.html create mode 100644 iteration-3/00-changes.md create mode 100644 iteration-3/screens/01-dashboard.html create mode 100644 iteration-3/screens/02-filament.html create mode 100644 iteration-3/screens/03-prints.html create mode 100644 iteration-3/screens/04-printers.html create mode 100644 iteration-3/screens/05-lists.html create mode 100644 iteration-3/screens/06-labels.html create mode 100644 iteration-3/screens/07-access.html create mode 100644 iteration-3/screens/08-system.html create mode 100644 iteration-3/screens/09-scan-holder.html create mode 100644 iteration-3/screens/10-scan-spool.html create mode 100644 iteration-3/screens/devices.html create mode 100644 iteration-3/screens/index.html create mode 100644 iteration-3/source/app.js create mode 100644 iteration-3/source/build.py create mode 100644 iteration-3/source/components.css create mode 100644 iteration-3/source/data.js create mode 100644 iteration-3/source/pages/01-dashboard.html create mode 100644 iteration-3/source/pages/02-filament.html create mode 100644 iteration-3/source/pages/03-prints.html create mode 100644 iteration-3/source/pages/04-printers.html create mode 100644 iteration-3/source/pages/05-lists.html create mode 100644 iteration-3/source/pages/06-labels.html create mode 100644 iteration-3/source/pages/07-access.html create mode 100644 iteration-3/source/pages/08-system.html create mode 100644 iteration-3/source/pages/09-scan-holder.html create mode 100644 iteration-3/source/pages/10-scan-spool.html create mode 100644 iteration-3/source/pages/devices.html create mode 100644 iteration-3/source/pages/index.html create mode 100644 screens/01-dashboard.html create mode 100644 screens/02-filament.html create mode 100644 screens/03-spool-form.html create mode 100644 screens/04-printers.html create mode 100644 source/app.css create mode 100644 source/build.py create mode 100644 source/checks/contrast.py create mode 100644 source/checks/table.py create mode 100644 source/pages/01-dashboard.html create mode 100644 source/pages/02-filament.html create mode 100644 source/pages/03-spool-form.html create mode 100644 source/pages/04-printers.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f2e848 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +__pycache__/ diff --git a/00-understanding.md b/00-understanding.md new file mode 100644 index 0000000..c60525c --- /dev/null +++ b/00-understanding.md @@ -0,0 +1,193 @@ +# Extrudex UI redesign: what exists today + +Written 2026-09-18, before any design work. Every source here was read, not +changed. + +| Source | Version read | +|---|---| +| CTXD `extrudex`: CONTEXT.MD v3, DECISIONS.MD v4, GLOSSARY.MD v3, HANDOFFS.MD v32 (skimmed) | 2026-09-07 / 2026-09-17 | +| Repository `dev` at `321ce0d` (2026-09-17) | read only | +| Printer-connections feature branch, local ref `origin/feature/7efc2030-…-printer-connections` at `4e17f9e` (2026-09-17) | read with `git diff`, no checkout, no fetch | +| Design System v2.0: `http://pages.ccl/design-v2.0/` | HTTP 200, tokens extracted | + +## Scope agreed on 2026-09-18 + +- Designs are based on the **current `dev` code**, not on the 2026-08-12 + picture in CTXD. CTXD is out of date (see "Differences between CTXD and the + code" at the end). +- **Screen 1 is a new standalone Dashboard.** Today, the home route `/` is + `Floor.tsx`. +- **The Printers screen** uses the fields stored on `dev` plus the lifecycle and + staleness fields from the unmerged printer-connections feature branch. The + branch fields are labeled "on feature branch, not in production". + +--- + +## 1. Fields shown on each existing screen + +### Floor (`pages/Floor.tsx`, route `/`, the de facto dashboard) + +Polls every 10 s. It has two columns. + +**Primary column** +- Printers view (`pages/Printers.tsx` embedded; see below) +- "Today's prints", from midnight America/New_York, with an "All prints" link +- Print list (`components/PrintList.tsx`), one row per print: + - job name (click to edit; locked while running with "name is captured from the printer") + - printer name · started at + - status badge: `running` / `finished` / `failed` / `unknown` + - duration (`—` when null), plus a **`+gap` marker** when `connection_gaps > 0`. The tooltip says the figure is "an upper bound rather than a measurement". + - grams used (click to edit) or a grams input; multi-color prints show "N g total" + - spool picker ("274 g · Sunlu Mint Green · PLA · Matte"), with a suggested spool + - cost, or "cost pending" on multi-color prints + - use type select: Personal / Business / "Use not recorded" + - archive control + - per-color rows for multi-color prints: slot, spool, grams, color hex, suggested spool +- Empty states: "Nothing printed today." / "No prints captured yet." + +**Secondary column** +- "Running low": spools under `LOW_STOCK_GRAMS` = **200 g**, each with a color swatch, color name, brand and grams +- "Inventory" stats: **Spools**, **Filament left** (g), **Stock value** ($), **Running low** (count, warning style) +- Empty state: "No filament tracked yet." + +> **Not shown today:** `weight_source` (see §2 gaps), tare, gross weight. + +### Filament (`pages/Filament.tsx` + `components/SpoolTable.tsx`, route `/filament`) + +- Header: "Labels outstanding: N", **Download unlabeled labels**, **Manage lists**, **Add spool** +- Filters: search ("Search brand, colour, material, location…"), **Low stock only**, **In storage only**, brand select with counts, material select with counts, sort (Least remaining / Most remaining / Brand A–Z / Colour A–Z / Highest value), **Group identical**, view toggle **Table / Cards**, pagination +- **Table columns:** swatch · color name (click to edit) + brand · Filament (material · type · finish · speed; "Standard"/"Basic" hidden) · Remaining ("N of M g" + bar, red below 200 g) · $/kg ("Not set" when 0) · Value · Location ("In storage · detail" or container name) · Delete +- Grouped rows: combined total, "N of M g each", "$X/kg each", expand/collapse +- **Card view:** swatch, color name, brand, taxonomy line, bar, "N g left", value, "In storage" / "Loaded · container", storage detail, Edit, Delete +- Delete opens `ConfirmDelete` +- Error and status lines, plus empty states + +> **Not shown today:** tare, tare source, gross weight, remaining source (`typed`/`weighed`), label printed date. + +### Add/Edit spool (`components/SpoolForm.tsx`, a modal ``) + +Title: "Add spool" / "Edit spool #ID". It has four sections. + +1. **Identity:** Brand* · Material* · Type (optional) · Finish* · Speed (optional) · Spool type (optional, "sets the empty-spool weight") · Location (optional, "Shelf A") +2. **Colour:** Colour name (with a suggestion from the pinned FilamentColors.xyz catalog: unique, ambiguous or no match) · 1–3 colors, each with a swatch picker and hex text, plus Add colour / Remove / "Colour not known yet" (stores #888888) +3. **Quantity and cost:** Total grams ("filament, not incl. spool") · Remaining grams · Empty spool (tare, optional, "e.g. 250") with a **tare origin note**: + - `spool`: "This spool's own weight. Saved with it." + - `spool_type`: "From the X spool type", plus "(estimated — weigh your own to be sure)" + - `brand`: "Guessed from the X brand…" (warning style) + - `unknown`: "Recorded on this spool, but nothing says whether it was weighed…" + - `none`: no note + + Then Cost per kg. +4. **Weigh it** (only when tare > 0): scale reading, placeholder "full reads N", hint "Remaining is filled in for you: reading minus N g of spool". Otherwise: "Set an empty-spool weight… and you can weigh the whole spool". + +- Footer: client-side errors (scale below tare, capacity contradiction), **a single server `error` line**, Cancel, Save changes / Add spool +- **409 today:** the server message ("this spool changed after you loaded it — reload it and reapply your edit, so a newer weigh-in is not overwritten (you loaded version 4, it is now version 5)") appears as plain text in the footer. There is no reload action and no side-by-side comparison. +- **400 today:** also only in the footer, never next to the field that caused it. + +Real 400 messages that can be mapped to a field: `color_name is required` · `cost_per_kg cannot be negative` · `remaining_grams cannot be negative` · `remaining_grams cannot exceed total_grams` · `gross_grams must be greater than zero — it is the whole spool on the scale` · `a tare of 0 is not a measurement — leave it unset instead` · `scale_grams is required and must be greater than zero`. + +### Printers (`pages/Printers.tsx`, embedded in Floor; `/printers` redirects to `/`) + +**This screen already exists on `dev`.** It is not new. +- Header: Add printer / Manage AMS units (admin only; others see "Admin role required…") +- Grouped into **In progress**, **Needs attention** (paused, failed), **Idle**, **Offline**, with counts +- **Full card** (busy/paused/failed): name · state badge (Printing, Paused, Failed, Idle, Preparing, Slicing, Waiting…, Offline) · actions menu (Edit, Remove) · job name or "Job name unavailable" · **camera snapshot** (or "disabled" / "unavailable while offline" / "unavailable") · **Loaded filament, "not confirmed in use"**: each container's label, used/capacity, and each spool's swatch, brand, color and grams (or "Empty") · last print line +- **Compact row** (idle/offline): state marker, name, badge, last job + "Ended …", or "Last contact unavailable" when offline +- Printer form (`PrinterForm.tsx`): name, brand (bambu/elegoo), model, IP, serial, access code (write-only) + +--- + +## 2. Printer data Extrudex stores today + +### Stored in SQLite on `dev` (`schema.sql`) + +| Table | Field | Notes | +|---|---|---| +| `printer` | `id`, `name` (unique), `brand` (`bambu`/`elegoo`/null), `model`, `ip`, `serial` (unique), `access_code` (never returned; API gives `has_access_code`), `created_at` | brand and model are null together (rows imported from printers.json) | +| `filament_container` | `id`, `printer_id`, `kind` (`ams`/`ams_lite`/`external`), `label`, `created_at`, `version`; `capacity` derived (4/4/1) | every printer gets one "External" holder | +| `spool` | `container_id` | which holder a spool is in; NULL = storage | +| `spool_location_event` | `spool_id`, `container_id`, `at`, `principal_id` | move history, not shown in UI | +| `print` | `printer` (name text), `printer_id`, `job_name`, `job_key`, `status`, `started_at`, `ended_at`, `duration_seconds`, `connection_gaps`, `grams_used`, `weight_source`, `cost_usd`, `deducted_grams`, `charged_rate_per_kg`, `spool_id`, `slice_objects`, `archived_at`, `use_type` | per-print history | +| `print_filament` | `slot`, `spool_id`, `suggested_spool_id`, `grams_used`, `color_hex`, `weight_source`, `deducted_grams`, `charged_rate_per_kg`, `cost_usd` | multi-color components | +| `job_grams` | `job_key`, `grams`, `updated_at` | remembered weights | + +### Live, in memory, served by `GET /api/printers` on `dev` (`PrinterStatus`) + +`id`, `name`, `connected` (the transport session, not power), `state` (gcode_state: IDLE, PREPARE, RUNNING, PAUSE, FINISH, FAILED, SLICING, or "" until first seen), `job_name` (only while a job runs), `camera` (`snapshot`/`unsupported`/`disabled`), `last_print`. Also `GET /api/printers/{id}/snapshot` returns a JPEG. + +### Parsed but **not** stored or exposed + +`mc_percent` (progress) and `gcode_file_prepare_percent` are read from MQTT and used internally only. There are **no** temperatures, ETA, layer or AMS `remain` %. Bambu never reports grams. + +### On the feature branch only (not on `dev`, not in production) + +Added to `PrinterStatus`: `lifecycle` (`connecting` / `synchronizing` / `ready` / `reconnecting` / `invalid_config` / `unsupported` / `stopped`), `ready`, `stale` (state and job are cached from a session that is not ready), `last_report_at`, `last_report_age_seconds`, `failure_category` (`setup_failed` / `setup_timeout` / `synchronization_timeout`), `failure_detail` (a fixed sanitized phrase), `session` (generation counter). There are UI helpers for "Last known: Printing · 4m ago", "Disconnected — reconnecting" and "Last contact 4m ago". + +**The dropped-MQTT state the brief asks for** is `connected=false`, which shows as "Offline" on `dev`. On the feature branch it becomes `lifecycle=reconnecting` with `stale=true` and a report age. + +--- + +## 3. Design System v2.0 color palette (from `http://pages.ccl/design-v2.0/`) + +It matches `web/src/theme.css` on `dev` exactly. **v2.0 has one (dark) theme only.** There is no light theme. + +| Token | Hex | Usage | +|---|---|---| +| `--ccl-charcoal` | `#2c2c30` | page background, near-black (`--surface-page`) | +| `--ccl-charcoal-2` | `#36363a` | raised surface (`--surface-raised`) | +| `--ccl-charcoal-3` | `#1b1b1e` | input / code block, recessed (`--surface-code`) | +| `--ccl-gold` | `#8f7ae0` | brand accent, violet despite the name (`--accent`, `--focus-ring`, alias `--ccl-purple`) | +| `--ccl-gold-dim` | `#7a63cc` | accent hover / pressed (`--accent-hover`) | +| `--ccl-text` | `#f4f4f6` | primary text | +| `--ccl-text-muted` | `#b2b2ba` | secondary text | +| `--ccl-border` | `#3d3d44` | hairline borders | +| `--ccl-border-2` | `#4e4e57` | stronger border / grid lines | +| `--ccl-green` | `#a9d88b` | success | +| `--ccl-red` | `#f08b93` | error | +| `--ccl-white` | `#ffffff` | white | +| `--ccl-ink` | `#2a2431` | text on accent | +| `--ccl-purple-wash` | `rgba(143,122,224,0.12)` | accent tint | + +Other v2.0 rules the redesign has to deal with: +- Radius: **`--radius-none/sm/md` are all `0px`** ("the brand is square. 0 is the rule"). +- Shadows: **flat brand**. The only shadow is `--shadow-raised: 0 1px 0 0 var(--ccl-border)`. +- Font: JetBrains Mono for everything. Sizes are 11/12/14/15/18/22/28/40 px and weights 400/500/700/800. +- Spacing runs from 4 to 64 px. Motion is 100/160 ms with `cubic-bezier(0.2,0,0,1)`. +- There is **no warning or amber token**. Low stock currently reuses red. + +--- + +## 4. Differences between CTXD and the code + +These are recorded so the design follows the code and not the docs. + +| CTXD says | `dev` has | +|---|---| +| `pages/Dashboard.tsx` | No such file. `/` is Floor | +| Printer status pages are out of scope | Printers view with camera, AMS containers and CRUD | +| No authentication | OIDC, sessions, API tokens, reader/operator/admin roles | +| Bambu only | Bambu MQTT + Elegoo Centauri SDCP | +| `weight_source` is `typed`/`recalled`/`unknown` | Also **`sliced`** (read from the 3MF over FTPS). The code says `recalled` is "no longer written" | +| Tare source: manufacturer/community/estimated | Two separate things: `spool.tare_source`/`tare_origin` (where this spool's tare came from: spool, spool type, brand, unknown, none) and `spool_type.source` (how trustworthy the published figure is: manufacturer, community, estimated) | +| Design system at pages.cubecraftlabs.com | 404. The live guide is at `pages.ccl` | + +--- + +## 5. Assumptions I'm working from + +None of these blocks the design. Each is a default I've picked, and each is +repeated in SUMMARY.md so you can overturn it. + +1. **The Dashboard shows `weight_source`, even though `GET /api/prints` does + not return it today.** It is stored in the `print` column, and the cost + payload has it. The mockup marks it as needing an API field. +2. **Weight source has four values** (typed, sliced, recalled, unknown). That + follows the code, not DECISIONS.MD's three. +3. **Tare is shown as two facts:** where this spool's tare came from (origin) + and, when it came from a spool type, that type's trust level (manufacturer, + community, estimated). +4. **The v3 light theme is new.** v2.0 has none, so every light token counts as + "added". +5. **Neumorphism overturns two stated v2.0 brand rules**: 0 radius and flat + shadows. This is recorded as a deliberate departure, not as editing a token + to suit a component. diff --git a/README.md b/README.md index 11c7bb7..e697f86 100644 --- a/README.md +++ b/README.md @@ -1 +1,58 @@ -# Extrudex Design V3.0 \ No newline at end of file +# Extrudex Design V3.0 + +The approved redesign of Extrudex in **Design System v3 (Soft UI / neumorphism)**. +It covers the token set, interactive mockups of every screen (phone, tablet and +desktop), and the design and UX notes behind them. It's a design proposal only; +none of it is wired to the Extrudex API. Implementation is tracked in Maestro +feature **UI Refresh - V3.0** (`224b72a1-8d84-4970-97ea-5dafa4d8ea3b`). + +## Start here + +| Open | What it is | +|---|---| +| [`iteration-3/screens/index.html`](iteration-3/screens/index.html) | **Final design.** Flow map, every screen, 14 guided flows | +| [`iteration-3/screens/devices.html`](iteration-3/screens/devices.html) | Every screen in live phone (390 × 844) and tablet (820 × 1180, 1180 × 820) frames | +| [`design-system-v3.md`](design-system-v3.md) | Tokens changed or added vs v2.0 with reasons, state matrix, dark-mode approach, contrast tables (54/54 WCAG AA) | +| [`design-system-v3.css`](design-system-v3.css) | The v3 token set (light + dark), to publish as the v3.0 guide | +| [`iteration-3/00-changes.md`](iteration-3/00-changes.md) | Latest decisions, code-verified behaviors, the responsive spec, terminology ("empty spool weight") | +| [`iteration-2/00-ux-review.md`](iteration-2/00-ux-review.md) | Navigation model, interaction patterns, data-display rules | + +The screens are self-contained HTML. Open them straight from a clone (no server +needed) and they render identically offline: JetBrains Mono is embedded, and +there are no CDN or network requests. `?theme=light` or `?theme=dark` forces a +theme. + +## Layout + +``` +design-system-v3.css / .md v3 tokens + documentation +fonts/ JetBrains Mono 400/500/700/800 woff2 (@fontsource 5.3.0) + OFL license +00-understanding.md what the app showed and stored before the redesign (2026-09-18) +SUMMARY.md what was built in each iteration, open questions, task lists +screens/, source/ iteration 1 (history) +iteration-2/ iteration 2: navigation + data display (history) +iteration-3/ iteration 3: final screens, tablet/phone, terminology + screens/ built pages (index, devices, 01–10) + source/ build inputs: data.js, app.js, components.css, pages/, build.py +``` + +Iterations 1 and 2 are kept for comparison. They still say "tare"; iteration 3 +says "empty spool weight". + +## Rebuilding the screens + +``` +python3 iteration-3/source/build.py # writes iteration-3/screens/*.html +python3 iteration-2/source/build.py # iteration 2 (optional) +python3 source/checks/table.py # regenerates the contrast table +``` + +The build inlines `design-system-v3.css` (fonts as base64), `source/app.css`, +the iteration's `components.css`, `data.js`, `app.js` and each page into one file. +It needs Python 3 and nothing else. + +## Data + +All data in the mockups is **sample data** shaped on the real inventory (105 +spools, 18 brands, 3 printers) and the 2026-08-12 production snapshot. People, +emails, serials and tokens are placeholders. diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 0000000..c3582cb --- /dev/null +++ b/SUMMARY.md @@ -0,0 +1,157 @@ +# Extrudex Soft UI (Design System v3) proposal: summary + +2026-09-18. This is a proposal only. **Nothing was changed** in the Extrudex +repository (still clean at `dev` `321ce0d`) or in CTXD. Nothing was deleted. + +## What I built and where it is + +Everything is in `~/Design/extrudex-v3/`. + +| File | What it is | +|---|---| +| `00-understanding.md` | Step 1: every field on each existing screen, every stored printer field, the v2.0 palette, and how CTXD differs from the code | +| `design-system-v3.css` | Step 2: the token set. v2.0 base kept, dark and light themes, neumorphism tokens (surfaces, raised and inset pairs, 4 intensity levels, radius scale), state tokens, reference components | +| `design-system-v3.md` | Step 2: every changed or added token with its reason, the state matrix, the dark-mode approach, 2 alternative palettes, and contrast tables for both themes (54/54 pass, plus 9 more pairs) | +| `fonts/` | JetBrains Mono 400/500/700/800 woff2 (the same `@fontsource` 5.3.0 files the app ships) + OFL license | +| `screens/01-dashboard.html` | **New standalone Dashboard**: stock tiles, running low (net + gross, tare unknown shown), net kg by material, recent prints with weight source and upper-bound duration marks, compact printer strip | +| `screens/02-filament.html` | Inventory: working search and filters, table/cards toggle, the 5-part name, net remaining vs tare (+ source) vs gross, Edit, Delete with a confirmation dialog | +| `screens/03-spool-form.html` | Add/Edit spool with a scenario switcher: **409 conflict**, **400 field errors**, **tare unknown**, **add new**, plus a control-state specimen | +| `screens/04-printers.html` | Printers: stored fields only, a "dev today / dev + feature branch" data toggle, the **MQTT dropped** state (A1), and a **Future, not built** section | +| `source/` | Build inputs (`build.py` makes each screen self-contained with base64 fonts) and `checks/table.py`, which regenerates the contrast table | + +Every screen has a Light/Dark toggle (also `?theme=light` or `?theme=dark` in +the URL), has no network dependencies, and was checked at 1440 px and 390 px in +headless Chrome. + +## Decisions that go against DECISIONS.MD or the v2.0 rules + +1. **Styling: "tokens verbatim… do not edit a token to suit a component."** + v3 changes the *values* of a few v2.0 semantic tokens (`--surface-raised`, + `--shadow-raised`, `--focus-ring`, `--radius-sm`, `--radius-md`, + `--text-on-accent` in light). This isn't editing a token to suit a + component. It's a proposed new *version* of the design system, and each + change has a reason in the .md. It still needs your call: adopting it means + updating the guide at `pages.ccl` first and then copying it verbatim into + `theme.css`, as the rule intends. +2. **v2.0 brand rules "0 radius" and "flat shadows"** are overturned. Soft UI + can't exist without them. `--radius-none` stays for tables and code. +3. **Primary buttons changed color, dark theme** (`#8f7ae0` → `#a99af0`), + because v2.0's ink-on-accent is **4.32:1** and fails AA. That's a brand-color + shift on the most visible control. +4. **CONTEXT scope: "printer status pages… deliberately out until daily use."** + The Printers screen was designed because you asked for it. Note that a + printers view already exists on `dev`, embedded in Floor. + +Things I followed even where the code has moved past the docs: +- **Weight source** is shown with 4 values (`typed`, `sliced`, `recalled`, + `unknown`). DECISIONS.MD lists 3. The schema adds `sliced` and says + `recalled` is no longer written. +- **Tare origin**: the code (`resolveTare` in `types.ts`) falls back to a + **brand** tare, labeled as a guess. DECISIONS.MD says tare comes "from + spool_type, never from brand". The mockups never show a brand-derived tare. + +## Open questions + +1. **Adopt or reject** the two brand-rule changes (radius, shadows) and the + lighter primary-button violet? Everything else depends on this. +2. **Floor → Dashboard + Printers.** The proposal splits today's Floor into a + stock-first Dashboard and a separate Printers page. Is that the direction, + or should Floor stay as one page restyled? +3. **Weight source in `GET /api/prints`.** It's stored but not served, so the + Dashboard needs a small API change. OK to add it? +4. **`recalled` vs `sliced`.** Should DECISIONS.MD be updated to say `sliced` + exists and `recalled` is legacy? That's a CTXD edit, which I didn't make. +5. **Brand-tare fallback.** Code and DECISIONS.MD disagree. Which one is right? +6. **Serial display.** I masked the middle of serials (`01P09A••••••4821`). Show + in full, masked, or hidden? +7. **"Tare unknown only" filter** is new on the Filament page (it helps work + down the "weigh it yourself" list). Keep it? +8. **The 409 "reapply my change" button** assumes the client can diff the + fields you changed against the new version. Today the form only shows the + server's message. Do you want that behavior, or just "reload"? +9. **Alternative palettes** A (warm graphite) and B (slate/teal) are in the .md. + Do you want either mocked up? +10. **CTXD is out of date** (Dashboard.tsx, no auth, Bambu only, the design + system URL). Should someone refresh CONTEXT.MD? I made no CTXD edits. + +## Proposed Maestro tasks (titles only, smallest first; not created) + +1. Add `weight_source` to the `GET /api/prints` response +2. Add v3 warning and info status tokens to `theme.css` +3. Add the `--border-control` token and apply it to buttons, inputs and selects +4. Replace the v2.0 focus ring with the v3 2px offset outline +5. Fix primary-button text contrast (4.32:1 → 6.14:1) +6. Show a weight-source chip on each print row +7. Show an upper-bound mark on durations with connection gaps +8. Show "gaps not counted" on prints with null `connection_gaps` +9. Show tare, tare source and gross weight on the Filament table and cards +10. Show "tare unknown" (never 0) wherever gross weight is derived +11. Add the "Tare unknown only" filter to Filament +12. Map spool 400 messages to their fields in SpoolForm +13. Add the 5-part name preview to SpoolForm +14. Add the net/tare/gross scale diagram to SpoolForm +15. Replace the SpoolForm 409 footer text with a conflict panel and reload actions +16. Add a light theme with a stored per-browser toggle +17. Add the neumorphism shadow and radius tokens (after the brand decision) +18. Restyle buttons, inputs and chips with the v3 state matrix +19. Split Floor into a stock-first Dashboard and a Printers page +20. Render the connection-lost state on printer cards (after the printer-connections feature merges) +21. Publish Design System v3 to `pages.ccl` and re-sync `theme.css` verbatim + +--- + +# Iteration 2 (2026-09-18): navigation, data display and interaction + +Everything is in `iteration-2/`. Iteration 1 above is unchanged, for comparison. + +| File | What it is | +|---|---| +| `iteration-2/00-ux-review.md` | 12 findings from iteration 1, the navigation model, flows ranked by frequency, interaction patterns, data-display rules, accessibility notes, 5 new questions, 18 more task titles | +| `iteration-2/screens/index.html` | **Start here.** Flow map plus 8 guided flows that deep-link into each state | +| `iteration-2/screens/01-dashboard.html` | "Needs you" queue first, then stock tiles, recent prints by day, printers and running low. Everything clickable | +| `iteration-2/screens/02-filament.html` | Saved views with counts, Stock/Weights columns, grouping, sortable headers, row ⋯ menu, weigh session | +| `iteration-2/screens/03-prints.html` | **New.** Needs attention (inline suggestions, grams, batch) · All · Running · Archived | +| `iteration-2/screens/04-printers.html` | Urgency order, jump strip, holder fill + Load, stale "last known" state, dev/feature toggle | +| `iteration-2/source/` | `data.js` (one shared model), `app.js` (shell, palette, panel, toasts), `components.css`, pages, `build.py` | + +What's shared across every page: the spool/print/load **panel** (deep-linked, +Esc/back closes, j/k steps), the **⌘K palette**, nav **badges** for work +waiting, **toasts** after each save, a **freshness** indicator with an offline +state, and a phone **tab bar** with a floating Add button. + +**Corrected from iteration 1:** it showed AMS "slot" positions, which the +database doesn't store. Iteration 2 shows holder fill ("3 / 4") instead. + +Verified: every page and panel state loads with no console errors in headless +Chrome, at 1440 px and 390 px, in both themes. There are no network requests, +and no new colors (the v3 contrast table still applies). + +--- + +# Iteration 3 (2026-09-18): remaining screens, tablet and phone + +Everything is in `iteration-3/`. Iterations 1 and 2 are unchanged. + +- **Start here:** `iteration-3/screens/index.html` (flow map, 14 flows) and + `iteration-3/screens/devices.html` (every screen in live phone 390 × 844, + tablet 820 × 1180 and 1180 × 820 frames). +- **New screens:** 05 Lists · 06 Labels · 07 Access · 08 System · 09 Scan: + holder · 10 Scan: spool, plus Add/Edit printer, Manage holders and Remove + printer panels on Printers. +- **Your answers from `iteration-2/00-ux-review.md`, applied:** Change spool + (with real grams-returned wording), lightest-first weigh sessions with + location, Filament badge for unverified tares, and load/attribute palette + commands. +- **Responsive:** three tiers (phone < 700, tablet 700–1180 with a compact + rail, desktop). Spec in `iteration-3/00-changes.md` §4. +- **Findings:** list names are case-sensitive in the schema; audit log, + low-filament report and machine tokens have APIs but no screens on `dev`. +- **Verified:** all 12 pages and 18 panel/palette states load with no console + errors; checked at 390, 820 and 1180 px and desktop, in both themes. No + network requests, no new colors. + +**Terminology update (2026-09-18):** iteration 3's UI now says **"empty spool +weight"** everywhere it said "tare" (verified: no rendered text, tooltip, label +or placeholder contains "tare"). Code and API names (`tare_grams` etc.) are +unchanged. See `iteration-3/00-changes.md` §1b. Open item: GLOSSARY.MD in CTXD +still uses "Tare". diff --git a/design-system-v3.css b/design-system-v3.css new file mode 100644 index 0000000..21d820d --- /dev/null +++ b/design-system-v3.css @@ -0,0 +1,394 @@ +/* + * CubeCraftLabs Design System v3 (proposal): Soft UI / Neumorphism + * + * Built on Design System v2.0 (http://pages.ccl/design-v2.0/, verified + * identical to Extrudex web/src/theme.css at dev 321ce0d on 2026-09-18). + * + * Structure + * 1. Font faces JetBrains Mono, self-hosted woff2, no CDN + * 2. v2.0 base scale kept verbatim, so v2 references still resolve + * 3. v3 theme tokens dark (default, as in v2.0) and light (new) + * 4. Neumorphism surfaces, raised/inset shadow pairs, intensity, radius + * 5. State tokens focus, pressed, disabled, error + * 6. Reference components used by the mockups + * + * Accessibility rules this file enforces: + * - Shadows are decoration. Every control also has a border at 3:1 or more + * against its surface (WCAG 1.4.11), so it stays visible with shadows off. + * - Focus, pressed, disabled and error each change something besides shadow + * direction: the outline, the fill plus a check glyph, a dashed border, or + * a red border plus an icon plus a message. + * - The contrast table is in design-system-v3.md. + * + * Theme selection: dark is the default because v2.0 is dark. Light applies + * when the OS prefers it and nothing is forced, or with data-theme="light" on + * . data-theme="dark" forces dark. + */ + +/* ── 1. Font faces ──────────────────────────────────────────────────────── */ +/* The same files @fontsource/jetbrains-mono 5.3.0 ships (the app's dependency). */ +@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-display: swap; font-weight: 400; src: url('./fonts/jetbrains-mono-latin-400-normal.woff2') format('woff2'); } +@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-display: swap; font-weight: 500; src: url('./fonts/jetbrains-mono-latin-500-normal.woff2') format('woff2'); } +@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-display: swap; font-weight: 700; src: url('./fonts/jetbrains-mono-latin-700-normal.woff2') format('woff2'); } +@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-display: swap; font-weight: 800; src: url('./fonts/jetbrains-mono-latin-800-normal.woff2') format('woff2'); } + +/* ── 2. v2.0 base scale (unchanged) ─────────────────────────────────────── */ +:root { + --ccl-charcoal: #2c2c30; + --ccl-charcoal-2: #36363a; + --ccl-charcoal-3: #1b1b1e; + --ccl-gold: #8f7ae0; + --ccl-gold-dim: #7a63cc; + --ccl-text: #f4f4f6; + --ccl-text-muted: #b2b2ba; + --ccl-border: #3d3d44; + --ccl-border-2: #4e4e57; + --ccl-green: #a9d88b; + --ccl-red: #f08b93; + --ccl-white: #ffffff; + --ccl-ink: #2a2431; + + --ccl-grey: var(--ccl-charcoal); + --ccl-grey-2: var(--ccl-charcoal-2); + --ccl-grey-3: var(--ccl-charcoal-3); + --ccl-purple: var(--ccl-gold); + --ccl-purple-dim: var(--ccl-gold-dim); + --ccl-purple-wash: rgba(143, 122, 224, 0.12); + + /* v3 additions to the base scale: tints derived from the v2.0 hues */ + --ccl-gold-light: #a99af0; /* accent lifted to pass 4.5:1 as text on charcoal */ + --ccl-violet-deep: #5b43b8; /* accent darkened to pass 4.5:1 on the light surface */ + --ccl-charcoal-inset:#232327; /* between charcoal and charcoal-3: dark wells */ + --ccl-amber: #e8c170; /* new: warning (v2.0 had none) */ + --ccl-blue: #8fc3f0; /* new: informational / provenance */ + --ccl-mist: #e4e2ea; /* light page: charcoal's hue, inverted */ + --ccl-mist-2: #f1f0f5; /* light high surface */ + --ccl-mist-3: #d9d6e1; /* light inset */ + + /* Typography, spacing and motion: unchanged from v2.0 */ + --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + --font-body: var(--font-mono); + --font-display: var(--font-mono); + --fw-regular: 400; --fw-medium: 500; --fw-bold: 700; --fw-display: 800; + --fs-caption: 11px; --fs-label: 12px; --fs-body: 14px; --fs-body-lg: 15px; + --fs-h3: 18px; --fs-h2: 22px; --fs-h1: 28px; --fs-display: 40px; + --lh-tight: 1.15; --lh-snug: 1.35; --lh-body: 1.7; + --tracking-wordmark: 0.02em; --tracking-caps: 0.08em; --tracking-caps-wide: 0.14em; + --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-5: 20px; + --space-6: 24px; --space-8: 32px; --space-10: 40px; --space-14: 56px; --space-16: 64px; + --ease-out: cubic-bezier(0.2, 0, 0, 1); + --dur-fast: 100ms; --dur-base: 160ms; + --dur-press: 60ms; /* v3: pressing reacts faster than hover */ + --scrollbar-width: 6px; + + /* ── 4a. Radius scale (v3; replaces v2.0's "0 is the rule") ── */ + --radius-none: 0px; /* tables, code, grid lines: square stays available */ + --radius-xs: 4px; /* chips, swatches, inline code */ + --radius-sm: 8px; /* inputs, small buttons */ + --radius-md: 12px; /* buttons, list rows */ + --radius-lg: 18px; /* cards, panels */ + --radius-xl: 26px; /* dialogs, the app shell */ + --radius-pill: 999px; /* toggles, progress tracks, badges */ + + /* ── 4b. Shadow geometry: distance + blur per intensity level ── */ + --nm-d-1: 2px; --nm-b-1: 5px; /* level 1: chips, small controls */ + --nm-d-2: 4px; --nm-b-2: 10px; /* level 2: buttons, rows */ + --nm-d-3: 8px; --nm-b-3: 18px; /* level 3: cards, panels */ + --nm-d-4: 14px; --nm-b-4: 30px; /* level 4: dialogs */ + + /* Focus ring geometry: identical in both themes */ + --focus-width: 2px; + --focus-offset: 3px; + --border-control-width: 1px; + --border-error-width: 2px; +} + +/* ── 3. Theme tokens: DARK (default) ───────────────────────────────────── */ +:root, +:root[data-theme="dark"] { + color-scheme: dark; + + /* Surfaces */ + --surface-page: var(--ccl-charcoal); /* #2c2c30 */ + --surface-raised: var(--ccl-charcoal); /* v3: same as page; the lift comes from the shadow */ + --surface-high: var(--ccl-charcoal-2); /* #36363a dialogs, menus */ + --surface-inset: var(--ccl-charcoal-inset); /* #232327 inputs, wells, progress tracks */ + --surface-code: var(--ccl-charcoal-3); + + /* Text */ + --text-primary: var(--ccl-text); /* 12.66:1 on page */ + --text-secondary: var(--ccl-text-muted); /* 6.60:1 on page */ + --text-accent: var(--ccl-gold-light); /* 5.67:1 on page */ + --text-on-accent: var(--ccl-ink); /* on --accent-fill */ + --text-on-gold: var(--ccl-ink); + + /* Accent */ + --accent: var(--ccl-gold); /* brand hue, for large marks only */ + --accent-hover: var(--ccl-gold-dim); + --accent-fill: var(--ccl-gold-light); /* primary button fill; ink on it: 6.14:1 */ + --accent-fill-press: #9785e6; + --accent-wash: rgba(169, 154, 240, 0.10); + + /* Status: the v2.0 hues plus two new ones */ + --status-success: var(--ccl-green); + --status-error: var(--ccl-red); + --status-warning: var(--ccl-amber); + --status-info: var(--ccl-blue); + --wash-success: rgba(169, 216, 139, 0.10); + --wash-error: rgba(240, 139, 147, 0.10); + --wash-warning: rgba(232, 193, 112, 0.10); + --wash-info: rgba(143, 195, 240, 0.10); + + /* Borders */ + --border-hairline: var(--ccl-border); /* decorative card edges */ + --border-strong: var(--ccl-border-2); + --border-control: #85858f; /* 1.4.11: 3.81:1 page, 4.29:1 inset, 3.29:1 high */ + --focus-ring: var(--ccl-gold-light); /* 5.67:1 on page */ + + /* ── 4c. Neumorphism shadow pairs, DARK ── + Dark soft UI fails when the "dark" shadow is only a darker charcoal: + nothing on #2c2c30 is visibly darker than #1b1b1e. Four rules make it + work here: + a. The dark half is true black at high alpha, not a darker gray. + b. The light half is white at 4–7% alpha. It reads as sheen, not glow. + c. Raised surfaces add a 1px top rim light (inset, white 6%). The rim + stays visible even where the drop shadow merges into the background. + d. Every control has a 3:1 border anyway, so the shadow never carries + structure on its own. */ + --nm-shadow-dark: rgba(0, 0, 0, 0.62); + --nm-shadow-light: rgba(255, 255, 255, 0.055); + --nm-rim: inset 0 1px 0 rgba(255, 255, 255, 0.06); + --nm-inset-dark: rgba(0, 0, 0, 0.55); + --nm-inset-light: rgba(255, 255, 255, 0.045); + --nm-raised-gradient: linear-gradient(145deg, #313136, #29292d); +} + +/* ── 3. Theme tokens: LIGHT (new in v3) ────────────────────────────────── */ +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + color-scheme: light; + --surface-page: var(--ccl-mist); + --surface-raised: var(--ccl-mist); + --surface-high: var(--ccl-mist-2); + --surface-inset: var(--ccl-mist-3); + --surface-code: #d2cfdb; + --text-primary: var(--ccl-ink); + --text-secondary: #55505f; + --text-accent: var(--ccl-violet-deep); + --text-on-accent: var(--ccl-white); + --text-on-gold: var(--ccl-white); + --accent: var(--ccl-gold); + --accent-hover: var(--ccl-gold-dim); + --accent-fill: var(--ccl-violet-deep); + --accent-fill-press: #4d37a3; + --accent-wash: rgba(91, 67, 184, 0.10); + --status-success: #2c6619; + --status-error: #a8202f; + --status-warning: #7c5100; + --status-info: #1d5e94; + --wash-success: rgba(44, 102, 25, 0.10); + --wash-error: rgba(168, 32, 47, 0.10); + --wash-warning: rgba(124, 81, 0, 0.10); + --wash-info: rgba(29, 94, 148, 0.10); + --border-hairline: #cfccd8; + --border-strong: #b9b5c6; + --border-control: #736e86; + --focus-ring: var(--ccl-violet-deep); + --nm-shadow-dark: rgba(88, 80, 120, 0.30); + --nm-shadow-light: rgba(255, 255, 255, 0.95); + --nm-rim: inset 0 1px 0 rgba(255, 255, 255, 0.7); + --nm-inset-dark: rgba(88, 80, 120, 0.28); + --nm-inset-light: rgba(255, 255, 255, 0.9); + --nm-raised-gradient: linear-gradient(145deg, #eceaf1, #dcd9e3); + } +} +:root[data-theme="light"] { + color-scheme: light; + --surface-page: var(--ccl-mist); + --surface-raised: var(--ccl-mist); + --surface-high: var(--ccl-mist-2); + --surface-inset: var(--ccl-mist-3); + --surface-code: #d2cfdb; + --text-primary: var(--ccl-ink); + --text-secondary: #55505f; + --text-accent: var(--ccl-violet-deep); + --text-on-accent: var(--ccl-white); + --text-on-gold: var(--ccl-white); + --accent: var(--ccl-gold); + --accent-hover: var(--ccl-gold-dim); + --accent-fill: var(--ccl-violet-deep); + --accent-fill-press: #4d37a3; + --accent-wash: rgba(91, 67, 184, 0.10); + --status-success: #2c6619; + --status-error: #a8202f; + --status-warning: #7c5100; + --status-info: #1d5e94; + --wash-success: rgba(44, 102, 25, 0.10); + --wash-error: rgba(168, 32, 47, 0.10); + --wash-warning: rgba(124, 81, 0, 0.10); + --wash-info: rgba(29, 94, 148, 0.10); + --border-hairline: #cfccd8; + --border-strong: #b9b5c6; + --border-control: #736e86; + --focus-ring: var(--ccl-violet-deep); + --nm-shadow-dark: rgba(88, 80, 120, 0.30); + --nm-shadow-light: rgba(255, 255, 255, 0.95); + --nm-rim: inset 0 1px 0 rgba(255, 255, 255, 0.7); + --nm-inset-dark: rgba(88, 80, 120, 0.28); + --nm-inset-light: rgba(255, 255, 255, 0.9); + --nm-raised-gradient: linear-gradient(145deg, #eceaf1, #dcd9e3); +} + +/* ── 4d. Composed shadows: theme-agnostic, built from the pairs above ── */ +:root { + --nm-raised-1: calc(var(--nm-d-1) * -1) calc(var(--nm-d-1) * -1) var(--nm-b-1) var(--nm-shadow-light), + var(--nm-d-1) var(--nm-d-1) var(--nm-b-1) var(--nm-shadow-dark), var(--nm-rim); + --nm-raised-2: calc(var(--nm-d-2) * -1) calc(var(--nm-d-2) * -1) var(--nm-b-2) var(--nm-shadow-light), + var(--nm-d-2) var(--nm-d-2) var(--nm-b-2) var(--nm-shadow-dark), var(--nm-rim); + --nm-raised-3: calc(var(--nm-d-3) * -1) calc(var(--nm-d-3) * -1) var(--nm-b-3) var(--nm-shadow-light), + var(--nm-d-3) var(--nm-d-3) var(--nm-b-3) var(--nm-shadow-dark), var(--nm-rim); + --nm-raised-4: calc(var(--nm-d-4) * -1) calc(var(--nm-d-4) * -1) var(--nm-b-4) var(--nm-shadow-light), + var(--nm-d-4) var(--nm-d-4) var(--nm-b-4) var(--nm-shadow-dark), var(--nm-rim); + --nm-inset-1: inset var(--nm-d-1) var(--nm-d-1) var(--nm-b-1) var(--nm-inset-dark), + inset calc(var(--nm-d-1) * -1) calc(var(--nm-d-1) * -1) var(--nm-b-1) var(--nm-inset-light); + --nm-inset-2: inset var(--nm-d-2) var(--nm-d-2) var(--nm-b-2) var(--nm-inset-dark), + inset calc(var(--nm-d-2) * -1) calc(var(--nm-d-2) * -1) var(--nm-b-2) var(--nm-inset-light); + --nm-flat: none; + /* v2.0 names kept so old rules still resolve */ + --shadow-none: none; + --shadow-raised: var(--nm-raised-2); + /* Borders, v2.0 names */ + --border-width: 1px; + --border: var(--border-width) solid var(--border-hairline); + --border-strong-rule: var(--border-width) solid var(--border-strong); + --border-accent: 1.5px solid var(--accent); + --scrollbar-thumb: var(--border-strong); + --scrollbar-thumb-hover: var(--accent); +} + +/* ── 6. Reference components ────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; } +html { background: var(--surface-page); } +body { + margin: 0; + background: var(--surface-page); + color: var(--text-primary); + font-family: var(--font-body); + font-size: var(--fs-body); + line-height: var(--lh-body); + -webkit-font-smoothing: antialiased; +} +@media (prefers-reduced-motion: reduce) { * { transition: none !important; } } + +/* Surfaces */ +.nm-raised { background: var(--nm-raised-gradient); box-shadow: var(--nm-raised-3); border: 1px solid var(--border-hairline); border-radius: var(--radius-lg); } +.nm-raised-sm { background: var(--surface-raised); box-shadow: var(--nm-raised-2); border: 1px solid var(--border-hairline); border-radius: var(--radius-md); } +.nm-inset { background: var(--surface-inset); box-shadow: var(--nm-inset-2); border: 1px solid var(--border-hairline); border-radius: var(--radius-md); } +.nm-dialog { background: var(--surface-high); box-shadow: var(--nm-raised-4); border: 1px solid var(--border-strong); border-radius: var(--radius-xl); } + +/* Buttons + * rest raised-2 + 1px control border + * hover border takes the accent color + * pressed inset shadow + accent wash fill + accent border (+ ✓ on toggles) + * focus 2px solid outline, offset 3px, in the focus color + * disabled no shadow, dashed border, secondary text, not-allowed cursor + */ +.btn { + font: var(--fw-medium) var(--fs-body)/1 var(--font-body); + color: var(--text-primary); + background: var(--surface-raised); + border: var(--border-control-width) solid var(--border-control); + border-radius: var(--radius-md); + padding: 10px 16px; + min-height: 40px; + box-shadow: var(--nm-raised-2); + cursor: pointer; + display: inline-flex; align-items: center; gap: var(--space-2); + transition: box-shadow var(--dur-base) var(--ease-out), border-color var(--dur-fast), background var(--dur-fast); +} +.btn:hover { border-color: var(--text-accent); } +.btn:active, .btn[aria-pressed="true"], .btn.is-pressed { + box-shadow: var(--nm-inset-1); + background: var(--accent-wash); + border-color: var(--text-accent); + transition-duration: var(--dur-press); +} +.btn[aria-pressed="true"]::before, .btn.is-pressed::before { content: "✓"; font-weight: var(--fw-bold); color: var(--text-accent); } +.btn:focus-visible, .btn.is-focused, .input:focus-visible, .input.is-focused, .select:focus-visible, .select.is-focused { + outline: var(--focus-width) solid var(--focus-ring); + outline-offset: var(--focus-offset); +} +.btn:disabled, .btn.is-disabled { + box-shadow: none; + border-style: dashed; + color: var(--text-secondary); + background: transparent; + cursor: not-allowed; +} +.btn-primary { background: var(--accent-fill); color: var(--text-on-accent); border-color: var(--accent-fill); font-weight: var(--fw-bold); } +.btn-primary:hover { border-color: var(--text-primary); } +.btn-primary:active, .btn-primary.is-pressed { background: var(--accent-fill-press); color: var(--text-on-accent); border-color: var(--text-primary); } +.btn-primary.is-pressed::before { color: var(--text-on-accent); } +.btn-danger { color: var(--status-error); border-color: var(--status-error); } +.btn-sm { min-height: 32px; padding: 6px 12px; font-size: var(--fs-label); border-radius: var(--radius-sm); box-shadow: var(--nm-raised-1); } +.btn-icon { width: 40px; padding: 0; justify-content: center; } + +/* Inputs: always inset, always bordered */ +.input, .select { + font: var(--fw-regular) var(--fs-body)/1.4 var(--font-body); + color: var(--text-primary); + background: var(--surface-inset); + border: var(--border-control-width) solid var(--border-control); + border-radius: var(--radius-sm); + box-shadow: var(--nm-inset-1); + padding: 9px 12px; + min-height: 40px; + width: 100%; +} +.input::placeholder { color: var(--text-secondary); } +.input:disabled, .input.is-disabled, .select:disabled { + box-shadow: none; border-style: dashed; color: var(--text-secondary); background: transparent; +} +/* Error: 2px red border + icon in the label + message. Never color alone. */ +.field.has-error .input, .field.has-error .select, .input.is-error { + border: var(--border-error-width) solid var(--status-error); + padding: 8px 11px; +} +.field-error { + display: flex; gap: var(--space-2); align-items: flex-start; + color: var(--status-error); font-size: var(--fs-label); line-height: var(--lh-snug); margin-top: var(--space-1); +} +.field-error::before { content: "⚠"; font-weight: var(--fw-bold); } + +/* Chips / badges: text + wash + border + glyph */ +.chip { + display: inline-flex; align-items: center; gap: 6px; + font-size: var(--fs-caption); font-weight: var(--fw-bold); + letter-spacing: var(--tracking-caps); text-transform: uppercase; + padding: 2px 9px; border-radius: var(--radius-pill); + border: 1px solid currentColor; line-height: 1.6; + white-space: nowrap; +} +.chip-ok { color: var(--status-success); background: var(--wash-success); } +.chip-err { color: var(--status-error); background: var(--wash-error); } +.chip-warn { color: var(--status-warning); background: var(--wash-warning); } +.chip-info { color: var(--status-info); background: var(--wash-info); } +.chip-accent { color: var(--text-accent); background: var(--accent-wash); } +.chip-neutral { color: var(--text-secondary); background: transparent; } +.chip-dashed { border-style: dashed; } + +/* Progress track (inset) + fill (raised) */ +.track { height: 10px; border-radius: var(--radius-pill); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); overflow: hidden; } +.track > span { display: block; height: 100%; border-radius: var(--radius-pill); background: var(--text-accent); } +.track.is-low > span { background: var(--status-error); } + +/* Swatch: 1–3 colors, a hairline so white/black filament shows on either theme */ +.swatch { display: inline-flex; width: 26px; height: 26px; border-radius: 50%; overflow: hidden; border: 1px solid var(--border-control); box-shadow: var(--nm-raised-1); flex: none; } +.swatch > i { flex: 1; } +.swatch-sm { width: 18px; height: 18px; } + +/* Text helpers */ +.muted { color: var(--text-secondary); } +.accent { color: var(--text-accent); } +.caps { text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); font-size: var(--fs-caption); font-weight: var(--fw-bold); } +.num { font-variant-numeric: tabular-nums; } diff --git a/design-system-v3.md b/design-system-v3.md new file mode 100644 index 0000000..9528a09 --- /dev/null +++ b/design-system-v3.md @@ -0,0 +1,263 @@ +# CubeCraftLabs Design System v3 (proposal): Soft UI + +**Status:** proposal, not adopted. Nothing in the Extrudex repository has changed. +**Base:** Design System v2.0 as served at `http://pages.ccl/design-v2.0/`, verified +on 2026-09-18 to match `web/src/theme.css` on `dev` (`321ce0d`) token for token. +**Tokens:** [`design-system-v3.css`](design-system-v3.css) · **Fonts:** [`fonts/`](fonts/) +(JetBrains Mono 400/500/700/800 woff2 from `@fontsource/jetbrains-mono` 5.3.0, +the same package the app imports; OFL license included). + +--- + +## 1. Principles + +1. **The v2.0 palette stays.** Charcoal, violet accent, green and red are + unchanged. Every v2.0 token name still exists and still resolves. New colors + are tints of v2.0 hues, except for two new status hues (warning, info) that + v2.0 lacked. +2. **Shadow is decoration, never structure.** Every button, input, select, + toggle and checkbox has a border at 3:1 or better against its surface + (WCAG 1.4.11). Turn the shadows off and every control is still findable. +3. **No state relies on shadow direction alone.** See the state matrix in §4. +4. **Offline-identical.** One self-hosted font. No CDN, no remote asset, no + `@import`. The mockups embed the font as base64. +5. **Numbers stay square.** Tables, code and numeric columns keep + `--radius-none`. Soft corners belong to containers and controls, not data. + +## 2. Two v2.0 rules this proposal overturns (deliberately) + +| v2.0 rule | v3 | Why | +|---|---|---| +| "Radius: the brand is square. **0 is the rule.**" | A 0–26 px radius scale | Neumorphism reads as extruded material. A soft raised shape on a square corner looks like a rendering error, not a style. `--radius-none` stays for tables, code and grid lines. | +| "Shadows: **flat brand.** Only a faint lift where truly needed." | A four-level raised/inset shadow system | Soft UI *is* the shadow system. The accessibility cost of that is paid by §1.2 and §4. | + +DECISIONS.MD says "do not edit a token to suit a component". v3 doesn't edit +v2.0 tokens: it adds new ones and remaps the alias layer. The two changes above +are brand-level decisions for you to accept or reject, and they're listed again +in SUMMARY.md. + +--- + +## 3. Every token that changed or was added compared to v2.0 + +**Unchanged** (kept verbatim): the whole `--ccl-*` base scale and its aliases, +`--font-*`, `--fw-*`, `--fs-*`, `--lh-*`, `--tracking-*`, `--space-*`, +`--ease-out`, `--dur-fast`, `--dur-base`, `--scrollbar-width`, `--radius-none`, +`--shadow-none`, `--border-width`. + +### 3a. Base scale: added + +| Token | Value | Reason | +|---|---|---| +| `--ccl-gold-light` | `#a99af0` | v2.0's accent `#8f7ae0` is **3.99:1** as text on charcoal (fails 4.5:1), and v2.0's own ink-on-accent button text is **4.32:1** (fails). A lighter step of the same hue passes both: 5.67:1 as text and 6.14:1 under ink. | +| `--ccl-violet-deep` | `#5b43b8` | The accent for the light theme. `#8f7ae0` on a light surface is about 2.6:1. | +| `--ccl-charcoal-inset` | `#232327` | Neumorphic wells (inputs, tracks) need a step between page `#2c2c30` and code `#1b1b1e`. Code black inside a well looked like a hole. | +| `--ccl-amber` | `#e8c170` | **v2.0 has no warning color.** "Running low", "tare estimated" and "duration is an upper bound" all need a caution that isn't an error. | +| `--ccl-blue` | `#8fc3f0` | An informational/provenance hue for "sliced", "recalled" and "on feature branch" labels, so provenance doesn't borrow success or error meanings. | +| `--ccl-mist` / `-2` / `-3` | `#e4e2ea` / `#f1f0f5` / `#d9d6e1` | The light theme's page, high and inset surfaces. They carry charcoal's slight violet cast, inverted, so both themes are one family. Soft UI can't use pure white: the highlight half of the shadow needs a lighter color than the surface. | + +### 3b. Semantic tokens: changed + +| Token | v2.0 | v3 dark | v3 light | Reason | +|---|---|---|---|---| +| `--surface-raised` | `#36363a` | `#2c2c30` (= page) | `#e4e2ea` | In soft UI a raised surface is the *same* material as the page. The lift comes from the shadow pair. A lighter fill plus a shadow looks like a card lying on top. | +| `--shadow-raised` | `0 1px 0 0 #3d3d44` | `var(--nm-raised-2)` | same | The v2.0 name now points to the level-2 soft shadow, so existing rules pick it up. | +| `--accent-fill` (new) | (none) | `#a99af0` | `#5b43b8` | Primary button fill. The text on it passes AA, which v2.0's did not (4.32:1). | +| `--text-on-accent` | `#2a2431` | `#2a2431` | `#ffffff` | Unchanged in dark. White in light, because the light accent fill is dark. | +| `--focus-ring` | `#8f7ae0` | `#a99af0` | `#5b43b8` | v2.0's ring was 3.45:1 on a raised surface. v3's is at least 5.6:1 everywhere. | +| `--radius-sm` / `--radius-md` | 0 | 8 px / 12 px | same | See §2. | +| `--border-hairline` (light) | (none) | `#3d3d44` | `#cfccd8` | The light theme needs its own value. Hairlines are decorative and not relied on. | +| `--scrollbar-thumb` | `#4e4e57` | `var(--border-strong)` | `#b9b5c6` | Now follows the theme. | + +### 3c. Semantic tokens: added + +| Token | Dark | Light | Reason | +|---|---|---|---| +| `--surface-high` | `#36363a` | `#f1f0f5` | Dialogs and menus. Reuses v2.0's charcoal-2 in dark. | +| `--surface-inset` | `#232327` | `#d9d6e1` | Inputs, wells and progress tracks. | +| `--text-accent` | `#a99af0` | `#5b43b8` | Links and accent text that pass 4.5:1. | +| `--accent-fill-press` | `#9785e6` | `#4d37a3` | The pressed primary button is a different *fill*, not just an inset shadow. | +| `--accent-wash` | 10% of `#a99af0` | 10% of `#5b43b8` | The pressed/selected background for secondary controls. | +| `--status-warning` / `--status-info` | amber / blue | `#7c5100` / `#1d5e94` | Explained in §3a. | +| `--status-success` / `--status-error` (light) | (v2.0 hues) | `#2c6619` / `#a8202f` | The v2.0 hues are too light for a light surface. | +| `--wash-success/error/warning/info` | 10% alpha | 10% alpha | Chip backgrounds. 16% dropped chip text below 4.5:1, so it's 10%. | +| `--border-control` | `#85858f` | `#736e86` | **The 1.4.11 token.** v2.0's strongest border `#4e4e57` is 1.69:1 on charcoal, so it can't identify a control. v3's is 3.29–4.30:1 on every surface. | +| `--border-control-width` / `--border-error-width` | 1 px / 2 px | same | An error changes the border's *width* as well as its color. | +| `--focus-width` / `--focus-offset` | 2 px / 3 px | same | An offset outline, visually separate from the shadow. | +| `--dur-press` | 60 ms | same | The press responds faster than hover, so the push feels physical. | + +### 3d. Neumorphism tokens: added + +| Token | Value | Purpose | +|---|---|---| +| `--radius-xs` … `--radius-pill` | 4 / 8 / 12 / 18 / 26 / 999 px | Chips / inputs / buttons / cards / dialogs / tracks and badges | +| `--nm-d-1…4`, `--nm-b-1…4` | 2/5, 4/10, 8/18, 14/30 px | Shadow distance and blur per **intensity level** | +| `--nm-shadow-dark` | dark `rgba(0,0,0,.62)` · light `rgba(88,80,120,.30)` | Bottom-right half of the raised pair | +| `--nm-shadow-light` | dark `rgba(255,255,255,.055)` · light `rgba(255,255,255,.95)` | Top-left half of the raised pair | +| `--nm-inset-dark` / `--nm-inset-light` | dark `.55` / `.045` · light `.28` / `.9` | Inset (pressed/well) pair | +| `--nm-rim` | dark `inset 0 1px 0 rgba(255,255,255,.06)` | Top-edge rim light (the dark-mode fix, §5) | +| `--nm-raised-gradient` | dark `#313136 → #29292d` · light `#eceaf1 → #dcd9e3` | A 145° surface gradient that sells the convex shape | +| `--nm-raised-1…4` | composed | Level 1 chips · 2 buttons/rows · 3 cards · 4 dialogs | +| `--nm-inset-1…2` | composed | Level 1 inputs/pressed buttons · 2 wells/panels | +| `--nm-flat` | `none` | Disabled controls. They lose their shadow entirely. | + +--- + +## 4. State matrix + +What changes in each state. The "besides shadow" column is what makes each +state identifiable without the shadow. + +| State | Shadow | Besides shadow (the part that must be visible) | +|---|---|---| +| **Rest** | raised-2 | 1 px solid `--border-control` (3:1+) | +| **Hover** | raised-2 | Border becomes `--text-accent` | +| **Pressed / selected** | inset-1 | Background `--accent-wash`, accent border, **✓ glyph** on toggles; primary uses the `--accent-fill-press` fill | +| **Focused** | unchanged | **2 px solid outline, 3 px offset**, `--focus-ring`. Combines with any other state. | +| **Disabled** | none | **Dashed** border, transparent fill, secondary text, `not-allowed` cursor | +| **Error** | inset-1 (inputs) | **2 px** border in `--status-error`, **⚠ icon**, and a message under the field | + +Because each state uses a different channel (outline, fill and glyph, dash, +width and icon), any two states stay distinguishable in grayscale. + +--- + +## 5. Dark-mode neumorphism: the problem and the fix + +**The problem.** Soft UI draws the same surface with a lighter shadow top-left +and a darker one bottom-right. On `#2c2c30`, the conventional recipe (darker and +lighter charcoal, for example `#1b1b1e` and `#3d3d44`) gives about 1.3:1 each +way. The shape vanishes, or turns into a muddy halo. + +**The fix, as four rules in the tokens:** + +1. **The dark half is true black at high alpha** (`rgba(0,0,0,.62)`), not a + darker gray. Alpha black darkens whatever it lands on, so it stays visible + even beside the v2.0 raised gray. +2. **The light half is white at about 5% alpha.** At that level it reads as a + sheen. Any brighter and dark soft UI starts to glow. +3. **A 1 px rim light** (`inset 0 1px 0 rgba(255,255,255,.06)`) on every raised + surface. The rim is sharp where the shadow is soft, so it keeps the top edge + visible even when the drop shadow merges into the page. +4. **The page stays at v2.0's `#2c2c30`, not near-black.** Soft UI needs room + *below* the surface for the dark half, and `#1b1b1e` would leave none. That + is also why wells use the new in-between `#232327`. + +The fifth rule is the accessibility floor, and it's why none of this is +fragile: **every control has a 3:1 border anyway**. If a monitor crushes the +blacks, or someone forces high contrast, the UI loses depth but keeps its +structure. + +--- + +## 6. Alternative palettes (not the main proposal) + +Both would reuse every structural and neumorphism token. Only the base scale +would change. + +| | A: Warm graphite | B: Slate and teal | +|---|---|---| +| Page (dark / light) | `#2e2b29` / `#e9e5e0` | `#27303a` / `#e1e7ec` | +| Accent (dark text / light text) | `#e0a36b` / `#8a4b12` | `#5fd0c0` / `#0f6b61` | +| Mood | Workshop, wood and brass. It suits a print farm but moves away from the CCL violet. | Cooler and more technical. It reads as "instrument panel" and gives the swatches more room. | +| Risk | Amber becomes the accent, so warning needs a new hue | Teal is close to success green, so success would need re-tuning | + +The main proposal keeps v2.0 violet. Both alternatives change the brand color. + +--- + +## 7. Contrast checks + +The script used the WCAG 2.1 relative luminance formula. The v2.0 rows show +what v3 fixes. + +### 7a. v2.0 as it stands today (dark only) + +| Pair | Ratio | Needs | Result | +|---|---|---|---| +| Text `#f4f4f6` on page `#2c2c30` | 12.66:1 | 4.5 | Pass | +| Muted `#b2b2ba` on page | 6.60:1 | 4.5 | Pass | +| **Ink `#2a2431` on accent `#8f7ae0` (primary button)** | **4.32:1** | 4.5 | **FAIL** | +| **Accent `#8f7ae0` as text on page** | **3.99:1** | 4.5 | **FAIL** | +| **Strongest border `#4e4e57` vs page (control edge)** | **1.69:1** | 3 | **FAIL** | +| Focus ring `#8f7ae0` on raised `#36363a` | 3.45:1 | 3 | Pass (narrowly) | + +### 7b. v3, both themes + +All 54 checks pass. + +| Theme | Pair | Foreground | Background | Ratio | Needs | Result | +|---|---|---|---|---|---|---| +| Dark | Body text | `#f4f4f6` | `#2c2c30` | 12.66:1 | 4.5:1 | Pass | +| Dark | Body text on inset (inputs) | `#f4f4f6` | `#232327` | 14.25:1 | 4.5:1 | Pass | +| Dark | Body text on high surface (dialog) | `#f4f4f6` | `#36363a` | 10.95:1 | 4.5:1 | Pass | +| Dark | Secondary text | `#b2b2ba` | `#2c2c30` | 6.60:1 | 4.5:1 | Pass | +| Dark | Secondary text on inset | `#b2b2ba` | `#232327` | 7.43:1 | 4.5:1 | Pass | +| Dark | Secondary text on high | `#b2b2ba` | `#36363a` | 5.71:1 | 4.5:1 | Pass | +| Dark | Accent text / links | `#a99af0` | `#2c2c30` | 5.67:1 | 4.5:1 | Pass | +| Dark | Accent text on high | `#a99af0` | `#36363a` | 4.91:1 | 4.5:1 | Pass | +| Dark | Text on primary button | `#2a2431` | `#a99af0` | 6.14:1 | 4.5:1 | Pass | +| Dark | Success text | `#a9d88b` | `#2c2c30` | 8.53:1 | 4.5:1 | Pass | +| Dark | Error text | `#f08b93` | `#2c2c30` | 5.84:1 | 4.5:1 | Pass | +| Dark | Error text on high | `#f08b93` | `#36363a` | 5.05:1 | 4.5:1 | Pass | +| Dark | Warning text | `#e8c170` | `#2c2c30` | 8.14:1 | 4.5:1 | Pass | +| Dark | Info text | `#8fc3f0` | `#2c2c30` | 7.44:1 | 4.5:1 | Pass | +| Dark | Control border vs page (1.4.11) | `#85858f` | `#2c2c30` | 3.81:1 | 3:1 | Pass | +| Dark | Control border vs inset | `#85858f` | `#232327` | 4.29:1 | 3:1 | Pass | +| Dark | Control border vs high | `#85858f` | `#36363a` | 3.29:1 | 3:1 | Pass | +| Dark | Focus ring vs page | `#a99af0` | `#2c2c30` | 5.67:1 | 3:1 | Pass | +| Dark | Focus ring vs high | `#a99af0` | `#36363a` | 4.91:1 | 3:1 | Pass | +| Dark | Primary button fill vs page | `#a99af0` | `#2c2c30` | 5.67:1 | 3:1 | Pass | +| Dark | Error border vs inset | `#f08b93` | `#232327` | 6.58:1 | 3:1 | Pass | +| Dark | Warning border vs page | `#e8c170` | `#2c2c30` | 8.14:1 | 3:1 | Pass | +| Dark | ok chip text on 10% wash | `#a9d88b` | `#383d39` | 6.80:1 | 4.5:1 | Pass | +| Dark | err chip text on 10% wash | `#f08b93` | `#40363a` | 4.88:1 | 4.5:1 | Pass | +| Dark | warn chip text on 10% wash | `#e8c170` | `#3f3b36` | 6.50:1 | 4.5:1 | Pass | +| Dark | info chip text on 10% wash | `#8fc3f0` | `#363b43` | 6.03:1 | 4.5:1 | Pass | +| Dark | acc chip text on 10% wash | `#a99af0` | `#383743` | 4.77:1 | 4.5:1 | Pass | +| Light | Body text | `#2a2431` | `#e4e2ea` | 11.73:1 | 4.5:1 | Pass | +| Light | Body text on inset (inputs) | `#2a2431` | `#d9d6e1` | 10.51:1 | 4.5:1 | Pass | +| Light | Body text on high surface (dialog) | `#2a2431` | `#f1f0f5` | 13.28:1 | 4.5:1 | Pass | +| Light | Secondary text | `#55505f` | `#e4e2ea` | 6.06:1 | 4.5:1 | Pass | +| Light | Secondary text on inset | `#55505f` | `#d9d6e1` | 5.43:1 | 4.5:1 | Pass | +| Light | Secondary text on high | `#55505f` | `#f1f0f5` | 6.86:1 | 4.5:1 | Pass | +| Light | Accent text / links | `#5b43b8` | `#e4e2ea` | 5.57:1 | 4.5:1 | Pass | +| Light | Accent text on high | `#5b43b8` | `#f1f0f5` | 6.30:1 | 4.5:1 | Pass | +| Light | Text on primary button | `#ffffff` | `#5b43b8` | 7.14:1 | 4.5:1 | Pass | +| Light | Success text | `#2c6619` | `#e4e2ea` | 5.42:1 | 4.5:1 | Pass | +| Light | Error text | `#a8202f` | `#e4e2ea` | 5.62:1 | 4.5:1 | Pass | +| Light | Error text on high | `#a8202f` | `#f1f0f5` | 6.36:1 | 4.5:1 | Pass | +| Light | Warning text | `#7c5100` | `#e4e2ea` | 5.39:1 | 4.5:1 | Pass | +| Light | Info text | `#1d5e94` | `#e4e2ea` | 5.31:1 | 4.5:1 | Pass | +| Light | Control border vs page (1.4.11) | `#736e86` | `#e4e2ea` | 3.80:1 | 3:1 | Pass | +| Light | Control border vs inset | `#736e86` | `#d9d6e1` | 3.41:1 | 3:1 | Pass | +| Light | Control border vs high | `#736e86` | `#f1f0f5` | 4.30:1 | 3:1 | Pass | +| Light | Focus ring vs page | `#5b43b8` | `#e4e2ea` | 5.57:1 | 3:1 | Pass | +| Light | Focus ring vs high | `#5b43b8` | `#f1f0f5` | 6.30:1 | 3:1 | Pass | +| Light | Primary button fill vs page | `#5b43b8` | `#e4e2ea` | 5.57:1 | 3:1 | Pass | +| Light | Error border vs inset | `#a8202f` | `#d9d6e1` | 5.03:1 | 3:1 | Pass | +| Light | Warning border vs page | `#7c5100` | `#e4e2ea` | 5.39:1 | 3:1 | Pass | +| Light | ok chip text on 10% wash | `#2c6619` | `#d2d6d5` | 4.74:1 | 4.5:1 | Pass | +| Light | err chip text on 10% wash | `#a8202f` | `#decfd7` | 4.81:1 | 4.5:1 | Pass | +| Light | warn chip text on 10% wash | `#7c5100` | `#dad4d3` | 4.73:1 | 4.5:1 | Pass | +| Light | info chip text on 10% wash | `#1d5e94` | `#d0d5e1` | 4.64:1 | 4.5:1 | Pass | +| Light | acc chip text on 10% wash | `#5b43b8` | `#d6d2e5` | 4.83:1 | 4.5:1 | Pass | + +Additional pairs checked: + +| Theme | Pair | Ratio | Needs | Result | +|---|---|---|---|---| +| Dark | Ink on primary fill `#a99af0` | 6.14:1 | 4.5 | Pass | +| Dark | Ink on pressed primary `#9785e6` | 4.88:1 | 4.5 | Pass | +| Light | White on primary fill `#5b43b8` | 7.14:1 | 4.5 | Pass | +| Light | White on pressed primary `#4d37a3` | 8.78:1 | 4.5 | Pass | +| Dark | Progress fill `#a99af0` vs track `#232327` | 6.39:1 | 3 | Pass | +| Light | Progress fill `#5b43b8` vs track `#d9d6e1` | 4.99:1 | 3 | Pass | +| Dark | Low-stock fill `#f08b93` vs track | 6.58:1 | 3 | Pass | +| Light | Low-stock fill `#a8202f` vs track | 5.03:1 | 3 | Pass | +| Dark | Accent text on pressed wash (≈`#3a3848`) | 4.66:1 | 4.5 | Pass | + +**Not covered by the table:** filament swatch colors are user data, not tokens. +Each swatch gets a `--border-control` ring, so a charcoal-black or mist-white +filament still shows an edge on either theme, and the color name is always +printed beside it. diff --git a/fonts/OFL-LICENSE.txt b/fonts/OFL-LICENSE.txt new file mode 100644 index 0000000..8f7ed67 --- /dev/null +++ b/fonts/OFL-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/fonts/jetbrains-mono-latin-400-normal.woff2 b/fonts/jetbrains-mono-latin-400-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..585887339efd032b6b715466ae72d86e306bec79 GIT binary patch literal 21168 zcmV)FK)=6tPew8T0RR9108+335C8xG0O80008&f<0RR9100000000000000000000 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 T=ND-DucNQ`tADQ-AuI&|D5jKI literal 0 HcmV?d00001 diff --git a/fonts/jetbrains-mono-latin-500-normal.woff2 b/fonts/jetbrains-mono-latin-500-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..be878e68fa71bc1f08894c0882d07adbe485ef99 GIT binary patch literal 21832 zcmV)CK*GOwPew8T0RR91098l;5C8xG0O8O80954w0RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/fonts/jetbrains-mono-latin-800-normal.woff2 b/fonts/jetbrains-mono-latin-800-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..6ae3c7f568403ff730503a2f949ce932d021db56 GIT binary patch literal 21236 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 literal 0 HcmV?d00001 diff --git a/iteration-2/00-ux-review.md b/iteration-2/00-ux-review.md new file mode 100644 index 0000000..0e1ac07 --- /dev/null +++ b/iteration-2/00-ux-review.md @@ -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 diff --git a/iteration-2/screens/01-dashboard.html b/iteration-2/screens/01-dashboard.html new file mode 100644 index 0000000..9de2abe --- /dev/null +++ b/iteration-2/screens/01-dashboard.html @@ -0,0 +1,1342 @@ + + + + + +Extrudex Dashboard + + + + + + + + + diff --git a/iteration-2/screens/02-filament.html b/iteration-2/screens/02-filament.html new file mode 100644 index 0000000..6d32f3c --- /dev/null +++ b/iteration-2/screens/02-filament.html @@ -0,0 +1,1380 @@ + + + + + +Extrudex Filament + + + + + + + + + diff --git a/iteration-2/screens/03-prints.html b/iteration-2/screens/03-prints.html new file mode 100644 index 0000000..7d989f9 --- /dev/null +++ b/iteration-2/screens/03-prints.html @@ -0,0 +1,1350 @@ + + + + + +Extrudex Prints + + + + + + + + + diff --git a/iteration-2/screens/04-printers.html b/iteration-2/screens/04-printers.html new file mode 100644 index 0000000..2a473bf --- /dev/null +++ b/iteration-2/screens/04-printers.html @@ -0,0 +1,1350 @@ + + + + + +Extrudex Printers + + + + + + + + + diff --git a/iteration-2/screens/index.html b/iteration-2/screens/index.html new file mode 100644 index 0000000..5470a2d --- /dev/null +++ b/iteration-2/screens/index.html @@ -0,0 +1,1337 @@ + + + + + +Extrudex Flow Map + + + + + + + + + diff --git a/iteration-2/source/app.js b/iteration-2/source/app.js new file mode 100644 index 0000000..59f676c --- /dev/null +++ b/iteration-2/source/app.js @@ -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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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 ''; } + 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 '
'; + } + + /* provenance: quiet icon + words */ + var TSRC = { spool: ['⚖', 'weighed bare', ''], manufacturer: ['◆', 'manufacturer', ''], community: ['◇', 'community', ''], estimated: ['≈', 'estimated', 'warn'] }; + function tareProv(s) { + if (s.tare == null) return '?tare unknown'; + var t = TSRC[s.tsrc]; return '' + t[0] + '' + t[1] + ''; + } + function remProv(s) { return s.remSrc === 'weighed' ? '⚖weighed' + (s.weighedAt ? ' ' + esc(s.weighedAt) : '') + '' : '✎typed'; } + 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 '' + w[0] + '' + w[1] + ''; } + function durCell(p) { + if (p.status === 'running') return '' + dur(since(p.start)) + 'so far'; + if (p.status === 'unknown') return 'no durationend not seen'; + if (p.gaps) return '≤ ' + dur(p.dur) + 'upper bound · ' + p.gaps + ' gap' + (p.gaps > 1 ? 's' : '') + ''; + if (p.gaps == null) return '' + dur(p.dur) + 'gaps not counted'; + return '' + dur(p.dur) + ''; + } + var PST = { running: ['run', 'Running'], finished: ['ok', 'Finished'], failed: ['err', 'Failed'], unknown: ['warn', 'Unknown'] }; + function printSt(p) { var s = PST[p.status]; return '' + s[1] + ''; } + function printerSt(pr) { + if (!pr.connected) return '' + (pr.lifecycle === 'reconnecting' ? 'Reconnecting' : 'Offline') + ''; + if (pr.state === 'RUNNING') return 'Printing'; + if (pr.state === 'PAUSE') return 'Paused'; + if (pr.state === 'FAILED') return 'Failed'; + return 'Idle'; + } + /* 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 ? '' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '' : '!color ' + (f.slot + 1) + ' unattributed'; }).join('
'); + var s = p.spool && spool(p.spool); + return s ? '' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '' : (p.status === 'running' ? '—' : '!no spool'); + } + + /* ── shell ───────────────────────────────────────────────── */ + var ICON = { + dashboard: '', + filament: '', + prints: '', + printers: '', + lists: '', + labels: '', + access: '', + search: '', + sun: '' + }; + 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 ? '' + need + '' : '', + printers: down ? '' + down + '' : '' + }; + } + function shell(page) { + var b = badges(); + var nav = PAGES.map(function (p) { return '' + ICON[p[0]] + '' + p[1] + '' + (b[p[0]] || '') + ''; }).join(''); + var unl = SPOOLS.filter(function (s) { return !s.label; }).length; + var rail = ''; + var tabbar = ''; + var top = '
' + + 'Live · updated 3s ago' + + '
'; + var mock = '
'; + var drawer = '
'; + var pal = ''; + return { rail: rail, tabbar: tabbar, top: top, rest: mock + drawer + pal + '
' }; + } + + /* ── 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 = '' + html + ''; + $('#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) ? '
' : ''; + return '
' + icon + '

' + title + '

' + sub + '' + (extra || '') + '
' + nav + '
'; + } + function tabs(list) { + return '
' + list.map(function (t) { return ''; }).join('') + '
'; + } + + function spoolDrawer(s) { + var w = where(s); + var h = head(sw(s.c, '" style="width:44px;height:44px'), esc(s.color) + ' #' + s.id + '', 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 = '
' + Math.round(s.rem).toLocaleString('en-US') + ' gof ' + g(s.tot) + ' net' + remProv(s) + '
' + + '
' + meter(s) + '
' + (low(s) ? '

⚠ Below the 200 g line. Reorder or finish it on a small job.

' : '') + '
' + + '

On the scale

' + (gr == null + ? '

Gross is unknown because no empty-spool weight is known. It is never treated as 0.

' + : '
Empty spool (tare)
' + g(s.tare) + ' ' + tareProv(s) + '
+ Filament (net)
' + g(s.rem) + '
= Gross, on a scale
' + g(gr) + '
') + '
' + + '
Location
' + (w.loaded ? '' + esc(w.text) + '' : esc(w.text)) + ' ·
' + + '
Value left
' + usd(value(s)) + ' at ' + usd(s.kg) + '/kg
' + + '
Spool type
' + (s.stype ? esc(s.stype) : 'none chosen') + '
' + + '
Label
' + (s.label ? 'printed' : '!not printed ') + '
' + + '
Record
version ' + s.v + '
' + + '

Prints from this spool · ' + used.length + '

' + (used.length ? '
' + used.map(function (p) { var f = (p.filaments || []).filter(function (f) { return f.spool === s.id; })[0]; return '' + printSt(p) + '' + esc(p.job) + '' + g(f ? f.grams : p.grams) + '' + when(p.start) + ''; }).join('') + '
' : '

No prints drew from this spool yet.

') + '
' + + '

⚠API gap Location history is stored in spool_location_event but there is no endpoint to read it.

'; + foot = ''; + } + if (D.tab === 'weigh') { + if (s.tare == null) { + body = '' + + '' + + '

or, more accurate
' + + ''; + foot = ''; + } else { + body = '
' + + '

Result

Now' + g(s.rem) + '→—

Type the reading and the result appears here before you save.

'; + foot = '' + (D.list && D.list.indexOf(s.id) < D.list.length - 1 ? '' : ''); + } + } + if (D.tab === 'move') { + var dests = PRINTERS.map(function (pr) { + return '
' + esc(pr.name) + '' + (pr.connected ? '' : '⚡disconnected (loading still recorded)') + '
' + + 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 ''; }).join('') + '
'; + }).join(''); + body = '

Where is the spool going? Full holders are disabled. Extrudex records which holder a spool is in, not its tray position.

' + + '
Storage
' + + '
' + dests; + foot = ''; + } + if (D.tab === 'edit') { return editDrawer(s); } + return h + t + '
' + body + '
' + foot + '
'; + } + + 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('', 'Add spool', 'New spools carry no version') : head(sw(s.c, '" style="width:44px;height:44px'), 'Edit ' + esc(s.color) + ' #' + s.id + '', '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 ''; } + var demo = '
Demo state
' + [['normal', 'Normal'], ['errors', '400 errors'], ['conflict', '409 conflict']].map(function (x) { return ''; }).join('') + '
'; + var conflict = st === 'conflict' ? '' : ''; + var errsum = st === 'errors' ? '
⚠ 2 fields need fixing. Nothing was saved.
' : ''; + var name = '
Reads as
' + esc(fullName(s)) + '
brand · speed · material · type · finish (italic = optional)
'; + var body = demo + conflict + errsum + name + + '
' + sel('Brand', s.brand, 1) + sel('Print speed', s.speed, 0, '') + sel('Material', s.mat, 1) + sel('Filament type', s.type, 0, '') + sel('Finish', s.fin, 1) + '
' + + '
' + (st === 'errors' ? '

Give the color a name, even "Unknown". server: color_name is required

' : '') + '
' + + '
' + + '
' + + '
' + (st === 'errors' ? '

1,040 g is more than this spool holds (' + g(s.tot) + '). server: remaining_grams cannot exceed total_grams

' : 'To measure it, use the Weigh tab instead.') + '
' + + '
' + + '
' + + '
' + + ''; + var foot = (st === 'conflict' ? 'Save is off until you choose a version.' : '') + ''; + return h + t + '
' + body + '
' + foot + '
'; + } + + function printDrawer(p) { + var pr = printer(p.printer), n = needs(p); + var h = head('' + ICON.prints + '', esc(p.job), printSt(p) + ' · ' + esc(pr.name) + ' · print #' + p.id); + var fix = ''; + if (n.length) { + fix = '

Needs ' + n.map(function (x) { return x.t; }).join(' + ') + '

' + fixers(p) + '
'; + } + var body = fix + '
Printer
' + esc(pr.name) + '
Started
' + when(p.start) + '
Ended
' + (p.end ? when(p.end) : p.status === 'running' ? 'still running' : '—') + '
' + + '
Duration
' + durCell(p) + '
' + + '
Job key
' + esc(p.key) + 'The identity. Two jobs can share a name, never a path.
' + + '
Filament
' + (p.grams == null ? 'not known yet' : '' + g(p.grams) + ' ' + weightProv(p) + (p.status === 'failed' ? 'Failed prints record the full job weight. Correct by hand if needed.' : '')) + '
' + + '
Spool
' + spoolLinks(p) + '
Cost
' + (p.cost == null ? 'pending: needs weight and spool' : usd(p.cost) + (p.filaments && p.filaments.some(function (f) { return f.spool == null; }) ? ' so far' : '')) + '
' + + '
Use
' + (p.use == null ? 'Not recorded' : '') + '
' + + (p.filaments ? '

Colors

' + p.filaments.map(function (f) { var s = f.spool && spool(f.spool); return ''; }).join('') + '
#ColorSpoolGramsCost
' + (f.slot + 1) + ' ' + f.hex + '' + (s ? '' + esc(short(s)) + '' : '!unattributed') + '' + g(f.grams) + '' + usd(f.cost) + '
' : ''); + var foot = p.status === 'running' ? 'Name and weight are captured when the print ends.' : ''; + return h + '
' + body + '
' + foot + '
'; + } + function fixers(p) { + var out = ''; + if (p.grams == null) out += ''; + 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 += '
Spool' + (f.slot != null ? ' for color ' + (f.slot + 1) + ' ' : '') + '' + + (sug ? '
' + sw(sug.c, 'swatch-sm') + '' + esc(short(sug)) + 'Suggested: same color, loaded in ' + esc(where(sug).short) + '. Not charged until you confirm.
' : '') + + '
'; + }); + return out; + } + function loadDrawer(cid) { + var c = container(cid), inIt = contents(cid); + var h = head('' + ICON.printers + '', '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 = '
' + + cands.map(function (s) { return ''; }).join('') + '
' + + '

Scanning the holder\'s QR label does the same thing from a phone (existing /c/:id scan session).

'; + return h + '
' + body + '
'; + } + + /* ── 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 += '
' + esc(it.sec) + '
'; last = it.sec; } + html += '
' + (it.html || '') + '' + esc(it.t) + '' + esc(it.m || '') + '
'; + }); + $('#pal-list').innerHTML = html || '

No matches

Try a color, brand, #47, a job name or a printer.

'; + $('#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 · retrying' : 'Live · updated ' + fresh.n + 's ago'; + } + function demo(kind) { + var main = $('#page'); + if (kind === 'loading') { + var keep = main.innerHTML; + main.innerHTML = '
' + [1, 2, 3, 4].map(function () { return '
'; }).join('') + '
' + [1, 2, 3, 4, 5].map(function () { return '
'; }).join('') + '
'; + 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 = '⚠Can\'t reach Extrudex. You are looking at data from 09:41:07, and it is not updating. Retrying every 10 s. Saving is paused.'; 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', 'Skip to content
' + s.rail + '
' + s.top + '
' + 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('' + a.getAttribute('data-soon') + ' 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 ' + esc(ss.color + ' · ' + ss.brand) + ' into ' + esc(cc2.printer.model + ' · ' + cc2.c.label) + ' View'); 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 ' + esc(sp.color) + ' 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 ' + esc(ws.color + ' · ' + ws.brand) + ': ' + 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 #57. Print its label' : '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 = '
Delete ' + esc(ds.color + ' · ' + fullName(ds)) + '?Its print history stays. Prints keep their grams and cost and only lose the link to this spool. This can\'t be undone.
'; $('#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 ? '' + (r - s.rem > 0 ? '+' : '') + (r - s.rem).toLocaleString('en-US') + ' g' : ''; + $('#w-note').innerHTML = !v ? 'Type the reading and the result appears here before you save.' : v < s.tare ? '⚠ ' + g(v) + ' is less than the ' + g(s.tare) + ' empty spool. One of them is wrong. Nothing will be saved.' : r > s.tot + 1 ? '⚠ That leaves ' + g(r) + ', more than the spool holds full (' + g(s.tot) + '). Check the total or the tare.' : '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 }; +})(); diff --git a/iteration-2/source/build.py b/iteration-2/source/build.py new file mode 100644 index 0000000..6ec7f38 --- /dev/null +++ b/iteration-2/source/build.py @@ -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 = """""" + +def build(slug, title, desc): + src = (HERE / 'pages' / f'{slug}.html').read_text() + css = ''.join(re.findall(r'', src, re.S)) + js = ''.join(re.findall(r'', src, re.S)) + html = f""" + + + + +{title} + +{THEME} + + + + + + + +""" + (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) diff --git a/iteration-2/source/components.css b/iteration-2/source/components.css new file mode 100644 index 0000000..bec5ef5 --- /dev/null +++ b/iteration-2/source/components.css @@ -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); } diff --git a/iteration-2/source/data.js b/iteration-2/source/data.js new file mode 100644 index 0000000..b8a6d5f --- /dev/null +++ b/iteration-2/source/data.js @@ -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; diff --git a/iteration-2/source/pages/01-dashboard.html b/iteration-2/source/pages/01-dashboard.html new file mode 100644 index 0000000..76d77e1 --- /dev/null +++ b/iteration-2/source/pages/01-dashboard.html @@ -0,0 +1,76 @@ + + diff --git a/iteration-2/source/pages/02-filament.html b/iteration-2/source/pages/02-filament.html new file mode 100644 index 0000000..a9a44ac --- /dev/null +++ b/iteration-2/source/pages/02-filament.html @@ -0,0 +1,114 @@ + + diff --git a/iteration-2/source/pages/03-prints.html b/iteration-2/source/pages/03-prints.html new file mode 100644 index 0000000..fc4f054 --- /dev/null +++ b/iteration-2/source/pages/03-prints.html @@ -0,0 +1,84 @@ + + diff --git a/iteration-2/source/pages/04-printers.html b/iteration-2/source/pages/04-printers.html new file mode 100644 index 0000000..fc89f06 --- /dev/null +++ b/iteration-2/source/pages/04-printers.html @@ -0,0 +1,84 @@ + + diff --git a/iteration-2/source/pages/index.html b/iteration-2/source/pages/index.html new file mode 100644 index 0000000..a243e2f --- /dev/null +++ b/iteration-2/source/pages/index.html @@ -0,0 +1,71 @@ + + diff --git a/iteration-3/00-changes.md b/iteration-3/00-changes.md new file mode 100644 index 0000000..ec0de55 --- /dev/null +++ b/iteration-3/00-changes.md @@ -0,0 +1,142 @@ +# Iteration 3: the remaining screens, plus tablet and phone + +2026-09-18. Built on iteration 2 (left unchanged in `../iteration-2/`). Same v3 +tokens, same shared panel/palette/toast system. **Start at +`screens/index.html`** (flow map, 14 guided flows) or **`screens/devices.html`** +(every screen in live phone, tablet-portrait and tablet-landscape frames). + +--- + +## 1. Your answers, applied + +| Question | Your answer | What changed | +|---|---|---| +| One-click suggestion | Fine if accurate; fixable by hand | Kept one click. Every attributed print now has **Change spool** in its panel, and it states the real effect: *"46 g goes back to Elegoo Black #42 and comes off the spool you pick; cost is re-rated."* (That's what `SetPrintSpool` does on `dev`.) Rows attributed before deductions were tracked get the real refusal: reconcile to the same spool first (see print **Test_cube**). | +| Weigh-session order | By weight, with location visible | The session always runs **lightest first**, whatever the table sort. Each step opens with a **"Find it: Shelf C"** strip and "spool 2 of 4 · lightest first". | +| Landing page | Dashboard | Unchanged. | +| Filament badge | Yes: it flags an assumed weight that could be off | **Filament [7]** badge = empty spool weight **unverified**: 3 unknown + 4 estimated (Generic 180 g, Refill 35 g). Its tooltip splits the two. The saved view is renamed **Empty spool weight unverified**, and the Dashboard item reads "7 spools have an unverified empty spool weight: 3 can't be weighed yet, 4 could be off." | +| Palette commands | Makes sense | `load ‹spool› into ‹holder›` and `attribute ‹print› to ‹spool›`. They're typed in stages: the list narrows to spools or prints, then to holders or spools. Full holders show "full · 4/4" and refuse. Two new actions in the empty palette prefill them. | + +> **Check my reading of 4a.** I took "identifies an assumed weight" to include +> *estimated* empty spool weights, not just unknown ones. If you meant unknown only, it's a +> one-line change and the badge drops to 3. + +--- + +## 1b. Terminology: "empty spool weight", not "tare" (changed 2026-09-18) + +"Tare" isn't how the 3D printing community talks, so every user-facing string +now says **empty spool weight**. The short form **empty spool** is used only +where space is tight: column headers, and the scale equation "262 g empty spool ++ 812 g filament = 1,074 g". Examples: + +| Was | Now | +|---|---| +| Tare unverified (view) | Empty spool weight unverified | +| Empty spool (tare) | Empty spool / Empty spool weight | +| tare unknown | empty spool weight unknown | +| Set a tare · Save tare | Set empty spool weight · Save empty spool weight | +| Remaining = reading − 140 g tare | Remaining = reading − 140 g empty spool | +| Brand tare | Brand empty spool weight | + +**Not changed:** code and API names (`tare_grams`, `tare_source`, +`effective_tare_grams`, `tare_origin`). Renaming those would be an API break for +no user benefit. CTXD's GLOSSARY.MD still defines "Tare"; I haven't edited CTXD +(read-only for this task). You may want to add "empty spool weight" there as +the user-facing term. + +Iterations 1 and 2 still say "tare"; they're kept as snapshots. + +--- + +## 2. New screens + +| Screen | Who | What it's for | Built from on `dev` | +|---|---|---|---| +| **05 Lists** | operator+ | The 5 name-part lists + spool types. A name bar shows which part you're editing ("Sunlu **Rapid** PETG CF Silk"). "Used by N spools" links to Filament filtered. Remove is disabled with the reason when in use. Spool types show empty spool weight + trust level; adding one enforces "weight and source together, never 0 g". Demos: rename conflict (409), add error. | `ManageLookups.tsx`, `lookup` store rules | +| **06 Labels** | operator+ | Queue of spools without a label (preselected) → one PDF. Previews at the **real 51 × 21 mm layout** (19 mm QR · Brand / Color · taxonomy · #id). Tabs: all spools (reprint), holders (per-printer holder labels that start scan sessions). A format reference is included. | `labels.go`, `/api/spool-labels/unprinted`, `/api/filament-containers/{id}/label` | +| **07 Access** | admin | Authentik owns people and roles; Extrudex shows its local view. Role cards (reader / operator / admin, from the route policy). People with sessions (idle and hard expiry) and **Revoke…** with an inline confirm. **Machines & API tokens**: new token shown **once** (`exd_…`), per-token revoke. | `AccessAdministration.tsx`; machines/tokens have **API but no screen** on `dev` | +| **08 System** | admin | **Low-filament report**: schedule, threshold, weekly delivery history (retries, errors), **Run this week's now**. **Audit log**: who/what/when with links to the thing changed, filters, paging. **This device**: install, update-ready, offline readiness. | `webhook.go`, `audit.go`, `PWAControls.tsx`; webhook and audit have **API but no screen** on `dev` | +| **09 Scan: holder** (`/c/:id`) | phone-first | Scan a holder label → a session for this tab. Each spool scanned **loads** (from storage), **returns** (already here) or **moves here** (from another holder). No mode picker. Full → refused, nothing changes. Result card + haptic, a session log, "Done scanning". Camera sheet (demo) + type-a-number fallback. Reader view shows contents only. | `ScanSession.tsx`, `ScannedSpoolRoute` | +| **10 Scan: spool** (`/s/:id`) | phone-first | A spool label scanned with no holder session active: big remaining, **Lives in**, one-handed weigh-in (68 px input, before → after, the same two refusals), then open its holder or full details. Empty spool weight unknown → pick a spool type first. | `ScanWeighSpool.tsx` | +| **Printer panels** (on Printers) | admin | **Add / edit printer** (brand filters models from `printerCatalog.ts`; access code write-only; Elegoo has none), **Manage holders** (add AMS/AMS Lite, the external holder can't be removed, loaded holders can't be removed, print holder label), **Remove printer** (type the exact name). | `PrinterForm.tsx`, `ManagePrinterContainers.tsx`, `ConfirmRemovePrinter.tsx` | +| **Devices** | — | Every screen in 3 live frames, dark/light. | — | + +The nav now reaches everything: **Manage** (Lists, Labels, Access, System) on +the rail, and a **More** tab on phones. + +--- + +## 3. Things I checked against the code (and corrected in the design) + +| Claim I almost made | What `dev` actually does | Design now says | +|---|---|---| +| "Run the report again resends it" | A delivered week returns its existing result; only a retrying week is retried (`runPeriod`) | "Already delivered Monday 08:00; nothing was sent again. Running it never sends a duplicate." | +| "Remove printer, and history is kept" | Also **refused while any holder still has spools**, and an open print is **settled as unknown** | Both shown in the remove panel; confirm disabled while loaded | +| Re-attributing a charged print is blocked | Allowed: grams are **returned** to the old spool and taken from the new one, *except* rows from before tracking (`ErrUnreconciledAttribution`) | "Change spool" with the exact effect, and the real refusal | +| Scanning a spool label always opens it | Only with no holder session active; otherwise it's loaded/moved into the active holder | Both routes designed; each page explains the other | +| Sample token `ctxd_…` | Extrudex tokens are `exd_…` | Fixed | + +**Two findings for you** (not design changes): +1. **List names are case-sensitive in the schema** (`UNIQUE`, no `COLLATE + NOCASE`), so "petg" can be added beside "PETG", which is the very problem + the lists exist to prevent. The Lists screen shows a *proposed* + near-duplicate warning, labeled as a proposal. +2. **Audit log, low-filament report and machine tokens have APIs but no + screens on `dev`.** They're designed here and tagged "API exists · no screen + on dev yet". + +--- + +## 4. Responsive specification + +| | Phone < 700 px | Tablet 700–1180 px | Desktop > 1180 px | +|---|---|---|---| +| Navigation | Bottom bar: Dashboard · Filament · Prints · Printers · **More** (sheet with Manage + theme), badges on icons | **Compact rail** (88 px): icon + label, badges top-right, Manage group | Full rail with labels, Manage group, theme switch | +| Search | "Search…" pill, no shortcut hint | Full placeholder; shortcut hint hidden on touch | Full placeholder + ⌘K | +| Primary action | Floating **+** (56 px) above the bar | Header button | Header button | +| Panel | Full-screen sheet | 480 px side sheet over the page | 520 px side sheet | +| Tables | Low-priority columns dropped (`hide-m`); Lists become stacked rows | Tablet-only drops (`hide-t`): Value, Cost, Use; Weigh becomes icon-only | All columns | +| Touch | 44 px+ targets; menu items 12 px padding; `pointer: coarse` rules | Same `pointer: coarse` rules | Mouse sizes, keyboard hints | +| Scan pages | Designed for this: 64 px scan button, 68 px scale input, haptic on result | Centered 560 px column | Centered 560 px column | +| Dashboard | One column; attention buttons full-width | One column below 980 px, two above | Two columns | +| Printers | One card per row | 2 per row (portrait 1, landscape 2) | 3 per row | + +--- + +## 5. New open questions + +1. **Empty spool weight badge scope.** Unknown + estimated (as built), or unknown only? See §1. +2. **Near-duplicate list names.** Add a case-insensitive uniqueness check (a + schema change plus migration), or only warn in the UI? +3. **Holder scan session end.** `dev` keeps the active holder for the browser + session. Is an explicit **Done scanning** (as designed) wanted, and should it + also time out after a few minutes idle? +4. **Machine tokens, audit log, low-filament report.** Worth building screens + for all three now, or keep them API-only until something needs them? +5. **Label printing.** Always PDF download, or is there a specific label printer + you'd want a direct-print path for? + +## 6. More implementation tasks (titles only, smallest first) + +1. Rename the "Tare unknown" view to "Empty spool weight unverified" and include estimated weights +2. Add a Filament nav badge for unverified empty spool weights +3. Always run weigh sessions lightest first +4. Show the spool's location at the top of the weigh step +5. Add "Change spool" to attributed prints, stating the returned grams +6. Show the unreconciled-attribution refusal with its fix +7. Add a case-insensitive near-duplicate warning to Lists +8. Show "Used by N spools" with a link on every list value +9. Add label previews at 51 × 21 mm proportions +10. Add holder labels to the Labels screen +11. Add the "load … into …" palette command +12. Add the "attribute … to …" palette command +13. Add a More tab and sheet to the phone navigation +14. Add the compact tablet rail (700–1180 px) +15. Add a Manage holders panel with the removal rules +16. Add "Remove printer" with name confirmation and the loaded-holders refusal +17. Add a "Done scanning" control to holder scan sessions +18. Add the phone weigh-from-label layout +19. Add an audit log screen over `GET /api/admin/audit` +20. Add a low-filament report screen over the webhook endpoints +21. Add a machine and API token screen with show-once tokens diff --git a/iteration-3/screens/01-dashboard.html b/iteration-3/screens/01-dashboard.html new file mode 100644 index 0000000..1e2d53f --- /dev/null +++ b/iteration-3/screens/01-dashboard.html @@ -0,0 +1,1509 @@ + + + + + +Extrudex Dashboard + + + + + + + + + diff --git a/iteration-3/screens/02-filament.html b/iteration-3/screens/02-filament.html new file mode 100644 index 0000000..9c860f8 --- /dev/null +++ b/iteration-3/screens/02-filament.html @@ -0,0 +1,1548 @@ + + + + + +Extrudex Filament + + + + + + + + + diff --git a/iteration-3/screens/03-prints.html b/iteration-3/screens/03-prints.html new file mode 100644 index 0000000..4fe0a80 --- /dev/null +++ b/iteration-3/screens/03-prints.html @@ -0,0 +1,1517 @@ + + + + + +Extrudex Prints + + + + + + + + + diff --git a/iteration-3/screens/04-printers.html b/iteration-3/screens/04-printers.html new file mode 100644 index 0000000..64b5a8c --- /dev/null +++ b/iteration-3/screens/04-printers.html @@ -0,0 +1,1517 @@ + + + + + +Extrudex Printers + + + + + + + + + diff --git a/iteration-3/screens/05-lists.html b/iteration-3/screens/05-lists.html new file mode 100644 index 0000000..a8b1905 --- /dev/null +++ b/iteration-3/screens/05-lists.html @@ -0,0 +1,1509 @@ + + + + + +Extrudex Lists + + + + + + + + + diff --git a/iteration-3/screens/06-labels.html b/iteration-3/screens/06-labels.html new file mode 100644 index 0000000..ce51e53 --- /dev/null +++ b/iteration-3/screens/06-labels.html @@ -0,0 +1,1509 @@ + + + + + +Extrudex Labels + + + + + + + + + diff --git a/iteration-3/screens/07-access.html b/iteration-3/screens/07-access.html new file mode 100644 index 0000000..42e6905 --- /dev/null +++ b/iteration-3/screens/07-access.html @@ -0,0 +1,1491 @@ + + + + + +Extrudex Access + + + + + + + + + diff --git a/iteration-3/screens/08-system.html b/iteration-3/screens/08-system.html new file mode 100644 index 0000000..ac188ad --- /dev/null +++ b/iteration-3/screens/08-system.html @@ -0,0 +1,1486 @@ + + + + + +Extrudex System + + + + + + + + + diff --git a/iteration-3/screens/09-scan-holder.html b/iteration-3/screens/09-scan-holder.html new file mode 100644 index 0000000..70084f8 --- /dev/null +++ b/iteration-3/screens/09-scan-holder.html @@ -0,0 +1,1512 @@ + + + + + +Extrudex Holder Scan + + + + + + + + + diff --git a/iteration-3/screens/10-scan-spool.html b/iteration-3/screens/10-scan-spool.html new file mode 100644 index 0000000..bc64df8 --- /dev/null +++ b/iteration-3/screens/10-scan-spool.html @@ -0,0 +1,1480 @@ + + + + + +Extrudex Spool Scan + + + + + + + + + diff --git a/iteration-3/screens/devices.html b/iteration-3/screens/devices.html new file mode 100644 index 0000000..9ac0a76 --- /dev/null +++ b/iteration-3/screens/devices.html @@ -0,0 +1,1482 @@ + + + + + +Extrudex Devices + + + + + + + + + diff --git a/iteration-3/screens/index.html b/iteration-3/screens/index.html new file mode 100644 index 0000000..b4842eb --- /dev/null +++ b/iteration-3/screens/index.html @@ -0,0 +1,1512 @@ + + + + + +Extrudex Flow Map + + + + + + + + + diff --git a/iteration-3/source/app.js b/iteration-3/source/app.js new file mode 100644 index 0000000..cae5c4d --- /dev/null +++ b/iteration-3/source/app.js @@ -0,0 +1,553 @@ +/* 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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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 }; + } + /* Unknown, or an estimated figure nobody weighed: the weigh-in would rest on a guess. */ + function unverified(s) { return s.tare == null || s.tsrc === 'estimated'; } + 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 ''; } + 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 '
'; + } + + /* provenance: quiet icon + words */ + var TSRC = { spool: ['⚖', 'weighed bare', ''], manufacturer: ['◆', 'manufacturer', ''], community: ['◇', 'community', ''], estimated: ['≈', 'estimated', 'warn'] }; + function tareProv(s) { + if (s.tare == null) return '?empty spool weight unknown'; + var t = TSRC[s.tsrc]; return '' + t[0] + '' + t[1] + ''; + } + function remProv(s) { return s.remSrc === 'weighed' ? '⚖weighed' + (s.weighedAt ? ' ' + esc(s.weighedAt) : '') + '' : '✎typed'; } + 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 '' + w[0] + '' + w[1] + ''; } + function durCell(p) { + if (p.status === 'running') return '' + dur(since(p.start)) + 'so far'; + if (p.status === 'unknown') return 'no durationend not seen'; + if (p.gaps) return '≤ ' + dur(p.dur) + 'upper bound · ' + p.gaps + ' gap' + (p.gaps > 1 ? 's' : '') + ''; + if (p.gaps == null) return '' + dur(p.dur) + 'gaps not counted'; + return '' + dur(p.dur) + ''; + } + var PST = { running: ['run', 'Running'], finished: ['ok', 'Finished'], failed: ['err', 'Failed'], unknown: ['warn', 'Unknown'] }; + function printSt(p) { var s = PST[p.status]; return '' + s[1] + ''; } + function printerSt(pr) { + if (!pr.connected) return '' + (pr.lifecycle === 'reconnecting' ? 'Reconnecting' : 'Offline') + ''; + if (pr.state === 'RUNNING') return 'Printing'; + if (pr.state === 'PAUSE') return 'Paused'; + if (pr.state === 'FAILED') return 'Failed'; + return 'Idle'; + } + /* 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 ? '' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '' : '!color ' + (f.slot + 1) + ' unattributed'; }).join('
'); + var s = p.spool && spool(p.spool); + return s ? '' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '' : (p.status === 'running' ? '—' : '!no spool'); + } + + /* ── shell ───────────────────────────────────────────────── */ + var ICON = { + dashboard: '', + filament: '', + prints: '', + printers: '', + lists: '', + labels: '', + access: '', + system: '', + more: '', + search: '', + sun: '' + }; + var MANAGE = [['lists', 'Lists', '05-lists.html', 0], ['labels', 'Labels', '06-labels.html', 0], ['access', 'Access', '07-access.html', 1], ['system', 'System', '08-system.html', 1]]; + 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; + var unv = SPOOLS.filter(unverified), unk = unv.filter(function (s) { return s.tare == null; }).length; + return { + filament: unv.length ? '' + unv.length + '' : '', + prints: need ? '' + need + '' : '', + printers: down ? '' + down + '' : '' + }; + } + function shell(page) { + var b = badges(); + var nav = PAGES.map(function (p) { return '' + ICON[p[0]] + '' + p[1] + '' + (b[p[0]] || '') + ''; }).join(''); + var unl = SPOOLS.filter(function (s) { return !s.label; }).length; + var rail = ''; + var inManage = MANAGE.some(function (m) { return m[0] === page; }); + var tabbar = '' + + ''; + var top = '
' + + 'Live · updated 3s ago' + + '
'; + var mock = '
'; + var drawer = '
'; + var pal = ''; + return { rail: rail, tabbar: tabbar, top: top, rest: mock + drawer + pal + '
' }; + } + + /* ── 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 = { '02-filament.html': 'filament', '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 = '' + html + ''; + $('#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; D.session = false; D.brand = 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); + else if (D.kind === 'pedit') el.innerHTML = peditDrawer(D.id); + else if (D.kind === 'holders') el.innerHTML = holdersDrawer(D.id); + else if (D.kind === 'premove') el.innerHTML = premoveDrawer(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) ? '
' : ''; + return '
' + icon + '

' + title + '

' + sub + '' + (extra || '') + '
' + nav + '
'; + } + function tabs(list) { + return '
' + list.map(function (t) { return ''; }).join('') + '
'; + } + + function spoolDrawer(s) { + var w = where(s); + var h = head(sw(s.c, '" style="width:44px;height:44px'), esc(s.color) + ' #' + s.id + '', 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 = '
' + Math.round(s.rem).toLocaleString('en-US') + ' gof ' + g(s.tot) + ' net' + remProv(s) + '
' + + '
' + meter(s) + '
' + (low(s) ? '

⚠ Below the 200 g line. Reorder or finish it on a small job.

' : '') + '
' + + '

On the scale

' + (gr == null + ? '

Gross is unknown because no empty spool weight is known. It is never treated as 0.

' + : '
Empty spool
' + g(s.tare) + ' ' + tareProv(s) + '
+ Filament (net)
' + g(s.rem) + '
= Gross, on a scale
' + g(gr) + '
') + '
' + + '
Location
' + (w.loaded ? '' + esc(w.text) + '' : esc(w.text)) + ' ·
' + + '
Value left
' + usd(value(s)) + ' at ' + usd(s.kg) + '/kg
' + + '
Spool type
' + (s.stype ? esc(s.stype) : 'none chosen') + '
' + + '
Label
' + (s.label ? 'printed' : '!not printed ') + '
' + + '
Record
version ' + s.v + '
' + + '

Prints from this spool · ' + used.length + '

' + (used.length ? '
' + used.map(function (p) { var f = (p.filaments || []).filter(function (f) { return f.spool === s.id; })[0]; return '' + printSt(p) + '' + esc(p.job) + '' + g(f ? f.grams : p.grams) + '' + when(p.start) + ''; }).join('') + '
' : '

No prints drew from this spool yet.

') + '
' + + '

⚠API gap Location history is stored in spool_location_event but there is no endpoint to read it.

'; + foot = ''; + } + if (D.tab === 'weigh') { + if (s.tare == null) { + body = '' + + '' + + '

or, more accurate
' + + ''; + foot = ''; + } else { + var wl = where(s), li = D.list ? D.list.indexOf(s.id) : -1; + body = (D.session && li >= 0 ? '
Weigh sessionspool ' + (li + 1) + ' of ' + D.list.length + ' · lightest first
' : '') + + '
Find it' + esc(wl.text) + '' + sw(s.c, 'swatch-sm') + '' + esc(s.color) + ' · #' + s.id + '
' + + '
' + + '

Result

Now' + g(s.rem) + '→—

Type the reading and the result appears here before you save.

'; + foot = '' + (D.list && D.list.indexOf(s.id) < D.list.length - 1 ? '' : ''); + } + } + if (D.tab === 'move') { + var dests = PRINTERS.map(function (pr) { + return '
' + esc(pr.name) + '' + (pr.connected ? '' : '⚡disconnected (loading still recorded)') + '
' + + 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 ''; }).join('') + '
'; + }).join(''); + body = '

Where is the spool going? Full holders are disabled. Extrudex records which holder a spool is in, not its tray position.

' + + '
Storage
' + + '
' + dests; + foot = ''; + } + if (D.tab === 'edit') { return editDrawer(s); } + return h + t + '
' + body + '
' + foot + '
'; + } + + 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('', 'Add spool', 'New spools carry no version') : head(sw(s.c, '" style="width:44px;height:44px'), 'Edit ' + esc(s.color) + ' #' + s.id + '', '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 ''; } + var demo = '
Demo state
' + [['normal', 'Normal'], ['errors', '400 errors'], ['conflict', '409 conflict']].map(function (x) { return ''; }).join('') + '
'; + var conflict = st === 'conflict' ? '' : ''; + var errsum = st === 'errors' ? '
⚠ 2 fields need fixing. Nothing was saved.
' : ''; + var name = '
Reads as
' + esc(fullName(s)) + '
brand · speed · material · type · finish (italic = optional)
'; + var body = demo + conflict + errsum + name + + '
' + sel('Brand', s.brand, 1) + sel('Print speed', s.speed, 0, '') + sel('Material', s.mat, 1) + sel('Filament type', s.type, 0, '') + sel('Finish', s.fin, 1) + '
' + + '
' + (st === 'errors' ? '

Give the color a name, even "Unknown". server: color_name is required

' : '') + '
' + + '
' + + '
' + + '
' + (st === 'errors' ? '

1,040 g is more than this spool holds (' + g(s.tot) + '). server: remaining_grams cannot exceed total_grams

' : 'To measure it, use the Weigh tab instead.') + '
' + + '
' + + '
' + + '
' + + ''; + var foot = (st === 'conflict' ? 'Save is off until you choose a version.' : '') + ''; + return h + t + '
' + body + '
' + foot + '
'; + } + + function printDrawer(p) { + var pr = printer(p.printer), n = needs(p); + var h = head('' + ICON.prints + '', esc(p.job), printSt(p) + ' · ' + esc(pr.name) + ' · print #' + p.id); + var fix = ''; + if (n.length) { + fix = '

Needs ' + n.map(function (x) { return x.t; }).join(' + ') + '

' + fixers(p) + '
'; + } + var body = fix + '
Printer
' + esc(pr.name) + '
Started
' + when(p.start) + '
Ended
' + (p.end ? when(p.end) : p.status === 'running' ? 'still running' : '—') + '
' + + '
Duration
' + durCell(p) + '
' + + '
Job key
' + esc(p.key) + 'The identity. Two jobs can share a name, never a path.
' + + '
Filament
' + (p.grams == null ? 'not known yet' : '' + g(p.grams) + ' ' + weightProv(p) + (p.status === 'failed' ? 'Failed prints record the full job weight. Correct by hand if needed.' : '')) + '
' + + '
Spool
' + spoolLinks(p) + changeSpool(p) + '
Cost
' + (p.cost == null ? 'pending: needs weight and spool' : usd(p.cost) + (p.filaments && p.filaments.some(function (f) { return f.spool == null; }) ? ' so far' : '')) + '
' + + '
Use
' + (p.use == null ? 'Not recorded' : '') + '
' + + (p.filaments ? '

Colors

' + p.filaments.map(function (f) { var s = f.spool && spool(f.spool); return ''; }).join('') + '
#ColorSpoolGramsCost
' + (f.slot + 1) + ' ' + f.hex + '' + (s ? '' + esc(short(s)) + '' : '!unattributed') + '' + g(f.grams) + '' + usd(f.cost) + '
' : ''); + var foot = p.status === 'running' ? 'Name and weight are captured when the print ends.' : ''; + return h + '
' + body + '
' + foot + '
'; + } + function changeSpool(p) { + var attributed = p.filaments ? p.filaments.filter(function (f) { return f.spool; }) : (p.spool ? [{ spool: p.spool, grams: p.grams, slot: null }] : []); + if (!attributed.length || p.status === 'running') return ''; + if (p.unreconciled) return ''; + return attributed.map(function (f) { + var from = spool(f.spool); + return '
Change spool' + (f.slot != null ? ' for color ' + (f.slot + 1) : '') + '
' + + '' + (f.grams != null ? '' + g(f.grams) + ' goes back to ' + esc(short(from)) + ' #' + from.id + ' and comes off the spool you pick. Cost is re-rated at that spool\'s $/kg.' : 'No weight yet, so nothing moves until one is entered.') + '' + + '
'; + }).join(''); + } + function fixers(p) { + var out = ''; + if (p.grams == null) out += ''; + 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 += '
Spool' + (f.slot != null ? ' for color ' + (f.slot + 1) + ' ' : '') + '' + + (sug ? '
' + sw(sug.c, 'swatch-sm') + '' + esc(short(sug)) + 'Suggested: same color, loaded in ' + esc(where(sug).short) + '. Not charged until you confirm.
' : '') + + '
'; + }); + return out; + } + var MODELS = { bambu: ['A1', 'A1 mini', 'P1P', 'P1S', 'X1 Carbon', 'X1E', 'H2D', 'H2C', 'H2S'], elegoo: ['Centauri', 'Centauri Carbon', 'OrangeStorm Giga'] }; + function peditDrawer(id) { + var pr = id === 'new' ? null : printer(+id), st = D.state; + var h = head('' + ICON.printers + '', pr ? 'Edit ' + esc(pr.name) : 'Add printer', pr ? 'Connection details · admin' : 'Extrudex connects read-only; nothing is sent to the printer'); + var brand = pr ? 'bambu' : (D.brand || 'bambu'); + function f(label, hint, input, err) { return '
' + input + (err ? '

' + err + '

' : '') + '
'; } + var body = '
Demo state
' + [['normal', 'Normal'], ['errors', '400 errors']].map(function (x) { return ''; }).join('') + '
' + + '
' + f('Brand', '', '') + + f('Model', '', '') + '
' + + f('Name', 'shown everywhere; must be unique', '', st === 'errors' && !pr ? 'A printer called “P1S - Prints Charming” already exists. Names must be unique.' : '') + + '
' + f('IP address', 'same network as Extrudex', '', st === 'errors' ? '10.60.1.300 isn\'t a valid IPv4 address.' : '') + + f('Serial', 'on the printer\'s label', '') + '
' + + (brand === 'bambu' ? f('Access code', pr ? 'leave blank to keep the stored code' : 'from the printer screen: Settings → LAN', '') + '

Stored, never shown again. Read-only MQTT needs only IP, serial and access code, no Developer Mode.

' + : '

Elegoo Centauri uses unauthenticated SDCP, so there is no access code.

') + + (pr ? '' : '
After you add it
  • An External holder is created automatically.
  • Add its AMS units under Manage holders.
  • Capture starts on the first status report.
'); + return h + '
' + body + '
'; + } + function holdersDrawer(id) { + var pr = printer(+id); + var h = head('' + ICON.printers + '', 'Holders · ' + esc(pr.model), esc(pr.name) + ' · where filament can be loaded'); + var body = pr.containers.map(function (c) { + var n = contents(c.id).length, ext = c.kind === 'external'; + return '
' + esc(c.label) + '' + { ams: 'AMS', ams_lite: 'AMS Lite', external: 'External' }[c.kind] + ' · holds ' + c.cap + ' · #' + HOLDER_IDS[c.id] + '' + n + ' loaded
' + + '
' + + (ext ? 'Every printer keeps exactly one external holder.' : n ? 'Unload its ' + n + ' spool' + (n > 1 ? 's' : '') + ' first.' : '') + '
'; + }).join('') + + '

Add an AMS unit

' + + '

Capacity comes from the type, so a holder can\'t disagree with its hardware. Tray positions aren\'t recorded.

'; + return h + '
' + body + '
'; + } + function premoveDrawer(id) { + var pr = printer(+id), loaded = pr.containers.reduce(function (a, c) { return a + contents(c.id).length; }, 0), open = pr.openPrint && print(pr.openPrint); + var h = head('!', 'Remove ' + esc(pr.name) + '?', esc(pr.model) + ' · ' + esc(pr.ip)); + var body = '
  • Capture stops for this printer.
  • Print history is kept, including cost records.
  • ' + (open ? '
  • ' + esc(open.job) + ' is open, so it will be closed as unknown with no duration, because nobody will watch it end.
  • ' : '') + '
' + + (loaded ? '' : '') + + ''; + return h + '
' + body + '
'; + } + function loadDrawer(cid) { + var c = container(cid), inIt = contents(cid); + var h = head('' + ICON.printers + '', '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 = '
' + + cands.map(function (s) { return ''; }).join('') + '
' + + '

Scanning the holder\'s QR label does the same thing from a phone (existing /c/:id scan session).

'; + return h + '
' + body + '
'; + } + + /* ── palette ─────────────────────────────────────────────── */ + var P = { items: [], sel: 0 }; + function palItems(q) { + q = q.replace(/^\s+/, '').toLowerCase(); if (!/^(load|attribute)\s/.test(q)) q = q.trim(); else q = q.replace(/\s+$/, function (m) { return /\s(into|to)\s+$/.test(q) ? ' ' : ''; }); + 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: 'Load a spool into a holder…', m: 'load ‹spool› into ‹holder›', run: function () { $('#pal-q').value = 'load '; palRender(); }, keep: 1 }, + { sec: 'Actions', ico: '⇄', t: 'Attribute a print to a spool…', m: 'attribute ‹print› to ‹spool›', run: function () { $('#pal-q').value = 'attribute '; 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 lm = /^load(?:\s|$)/.test(q) && /^load\s*(.*?)(?:\s+into\b\s*(.*))?$/.exec(q), am = /^attribute(?:\s|$)/.test(q) && /^attribute\s*(.*?)(?:\s+to\b\s*(.*))?$/.exec(q); + if (lm) return loadCmd(lm[1] || '', lm[2]); + if (am) return attrCmd(am[1] || '', am[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 holders() { var out = []; PRINTERS.forEach(function (pr) { pr.containers.forEach(function (c) { out.push({ id: c.id, pr: pr, c: c, n: contents(c.id).length, hay: (pr.name + ' ' + pr.model + ' ' + c.label + ' ' + c.kind).toLowerCase() }); }); }); return out; } + function spoolHay(s) { return ('#' + s.id + ' ' + s.id + ' ' + fullName(s) + ' ' + s.color + ' ' + where(s).text).toLowerCase(); } + function loadCmd(sq, hq) { + var sp = SPOOLS.filter(function (s) { return !sq || spoolHay(s).indexOf(sq) >= 0; }).slice(0, 6); + if (hq == null) return sp.map(function (s) { return { sec: 'Load which spool? (then type “ into …”)', html: sw(s.c, 'swatch-sm'), t: s.color + ' · ' + fullName(s), m: '#' + s.id + ' · ' + where(s).short, keep: 1, run: function () { $('#pal-q').value = 'load #' + s.id + ' into '; palRender(); } }; }); + var s1 = sp[0]; if (!s1) return []; + return holders().filter(function (h) { return !hq || h.hay.indexOf(hq) >= 0; }).map(function (h) { + var full = h.n >= h.c.cap && s1.cont !== h.id; + return { sec: 'Load ' + s1.color + ' · ' + s1.brand + ' (#' + s1.id + ') into…', ico: full ? '✕' : '⇥', t: h.pr.model + ' · ' + h.c.label, m: full ? 'full · ' + h.n + '/' + h.c.cap : h.n + '/' + h.c.cap + ' in use', run: function () { if (full) return toast(h.c.label + ' on ' + h.pr.model + ' is full. Unload a spool first.', 'warn'); s1.cont = h.id; s1.v++; toast('Loaded ' + esc(s1.color + ' · ' + s1.brand) + ' into ' + esc(h.pr.model + ' · ' + h.c.label)); if (PAGE.refresh) PAGE.refresh(); } }; + }); + } + function attrCmd(pq, sq) { + var pr = printsNeeding().filter(function (p) { return !pq || (p.job + ' ' + p.key).toLowerCase().indexOf(pq) >= 0; }); + if (sq == null) return pr.map(function (p) { return { sec: 'Attribute which print? (then type “ to …”)', ico: '▤', t: p.job, m: needs(p).map(function (n) { return n.t; }).join(', '), keep: 1, run: function () { $('#pal-q').value = 'attribute ' + p.job.toLowerCase() + ' to '; palRender(); } }; }); + var p1 = pr[0]; if (!p1) return []; + return SPOOLS.filter(function (s) { return !sq || spoolHay(s).indexOf(sq) >= 0; }).slice(0, 8).map(function (s) { + return { sec: 'Attribute ' + p1.job + ' to…', html: sw(s.c, 'swatch-sm'), t: s.color + ' · ' + fullName(s), m: '#' + s.id + ' · ' + g(s.rem), run: function () { if (p1.filaments) p1.filaments.forEach(function (f) { if (f.spool == null) f.spool = s.id; }); else p1.spool = s.id; toast('Attributed ' + esc(p1.job) + ' to ' + esc(short(s)) + (p1.grams != null ? ' · ' + g(p1.grams) + ' deducted' : '')); if (PAGE.refresh) PAGE.refresh(); } }; + }); + } + 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 += '
' + esc(it.sec) + '
'; last = it.sec; } + html += '
' + (it.html || '') + '' + esc(it.t) + '' + esc(it.m || '') + '
'; + }); + $('#pal-list').innerHTML = html || '

No matches

Try a color, brand, #47, a job name or a printer.

'; + $('#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 · retrying' : 'Live · updated ' + fresh.n + 's ago'; + } + function demo(kind) { + var main = $('#page'); + if (kind === 'loading') { + var keep = main.innerHTML; + main.innerHTML = '
' + [1, 2, 3, 4].map(function () { return '
'; }).join('') + '
' + [1, 2, 3, 4, 5].map(function () { return '
'; }).join('') + '
'; + 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 = '⚠Can\'t reach Extrudex. You are looking at data from 09:41:07, and it is not updating. Retrying every 10 s. Saving is paused.'; 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|pedit|holders|premove)=([\w-]+)(?:&tab=(\w+))?(?:&state=(\w+))?/.exec(location.hash); + if (m) { var id = (m[1] === 'load' || m[2] === 'new') ? 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', 'Skip to content
' + s.rail + '
' + s.top + '
' + 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]'))) { e.preventDefault(); 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('' + a.getAttribute('data-soon') + ' 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-pedit]'))) { e.preventDefault(); var pv = a.getAttribute('data-pedit'); return openDrawer('pedit', pv === 'new' ? 'new' : +pv); } + if ((a = t.closest('[data-holders]'))) { e.preventDefault(); return openDrawer('holders', +a.getAttribute('data-holders')); } + if ((a = t.closest('[data-premove]'))) { e.preventDefault(); return openDrawer('premove', +a.getAttribute('data-premove')); } + if (t.id === 'rm-go') { closeDrawer(); return toast('Removed ' + esc(t.getAttribute('data-name')) + '. Its print history is kept.'); } + 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 ' + esc(ss.color + ' · ' + ss.brand) + ' into ' + esc(cc2.printer.model + ' · ' + cc2.c.label) + ' View'); 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 ' + esc(sp.color) + ' 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 ' + esc(ws.color + ' · ' + ws.brand) + ': ' + 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 #57. Print its label' : '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 = '
Delete ' + esc(ds.color + ' · ' + fullName(ds)) + '?Its print history stays. Prints keep their grams and cost and only lose the link to this spool. This can\'t be undone.
'; $('#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 ? '' + (r - s.rem > 0 ? '+' : '') + (r - s.rem).toLocaleString('en-US') + ' g' : ''; + $('#w-note').innerHTML = !v ? 'Type the reading and the result appears here before you save.' : v < s.tare ? '⚠ ' + g(v) + ' is less than the ' + g(s.tare) + ' empty spool. One of them is wrong. Nothing will be saved.' : r > s.tot + 1 ? '⚠ That leaves ' + g(r) + ', more than the spool holds full (' + g(s.tot) + '). Check the total or the empty spool weight.' : 'Saved with the empty spool weight it used, so correcting that weight later re-bases this reading.'; + $('#w-save').disabled = !ok; if ($('#w-next')) $('#w-next').disabled = !ok; + } + if (e.target.id === 'rm-name') { $('#rm-go').disabled = e.target.value !== $('#rm-go').getAttribute('data-name'); } + 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('change', function (e) { if (e.target.id === 'pe-brand') { D.brand = e.target.value; renderDrawer(); } }); + 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 = { unverified: unverified, holderIds: HOLDER_IDS, 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 }; +})(); diff --git a/iteration-3/source/build.py b/iteration-3/source/build.py new file mode 100644 index 0000000..56dd13a --- /dev/null +++ b/iteration-3/source/build.py @@ -0,0 +1,79 @@ +"""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 = """""" + +def build(slug, title, desc): + src = (HERE / 'pages' / f'{slug}.html').read_text() + css = ''.join(re.findall(r'', src, re.S)) + js = ''.join(re.findall(r'', src, re.S)) + html = f""" + + + + +{title} + +{THEME} + + + + + + + +""" + (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 3.'), + ('02-filament', 'Extrudex Filament', 'Filament inventory with drawer, filters and weigh session, iteration 3.'), + ('03-prints', 'Extrudex Prints', 'Print history and attribution queue, iteration 3.'), + ('04-printers', 'Extrudex Printers', 'Printers with holders, load flow, admin panels and connection state.'), + ('05-lists', 'Extrudex Lists', 'Taxonomy lists the spool name is built from.'), + ('06-labels', 'Extrudex Labels', 'Spool and holder QR labels.'), + ('07-access', 'Extrudex Access', 'People, sessions, machines and API tokens.'), + ('08-system', 'Extrudex System', 'Low-filament report, audit log and this device.'), + ('09-scan-holder', 'Extrudex Holder Scan', 'Phone-first holder scan session.'), + ('10-scan-spool', 'Extrudex Spool Scan', 'Phone-first scanned spool and weigh-in.'), + ('devices', 'Extrudex Devices', 'Every screen in phone and tablet frames.'), +] +for p in PAGES: + if (HERE / 'pages' / f'{p[0]}.html').exists(): + build(*p) diff --git a/iteration-3/source/components.css b/iteration-3/source/components.css new file mode 100644 index 0000000..9a4805c --- /dev/null +++ b/iteration-3/source/components.css @@ -0,0 +1,275 @@ +/* ── 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: 699px) { + .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: 699px) { + .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); } + +/* ── Tablet tier (700–1180 px): compact rail, side-sheet panel ───────────── */ +@media (min-width: 700px) and (max-width: 1180px) { + .shell { grid-template-columns: 88px minmax(0, 1fr); gap: var(--space-5); padding: var(--space-4) var(--space-5); } + .rail { padding: var(--space-3) 8px; gap: var(--space-3); align-items: stretch; } + .wordmark { justify-content: center; font-size: 0; padding: 4px 0; } + .wordmark-mark { width: 40px; height: 40px; font-size: 18px; } + .nav-label { font-size: 9px; padding: 6px 0 0; text-align: center; letter-spacing: .06em; } + .nav a { flex-direction: column; gap: 4px; padding: 10px 2px 8px; font-size: 11px; text-align: center; justify-content: center; } + .nav a svg { width: 22px; height: 22px; } + .nav a > span:not(.count) { line-height: 1.1; } + .nav a .sub { display: none; } + .nav .count { position: absolute; top: 4px; right: 10px; margin: 0; min-width: 18px; height: 18px; font-size: 10px; padding: 0 4px; } + .rail-foot .seg, .rail-foot .small { display: none; } + .rail-foot { border-top: 0; padding-top: 0; } + .drawer { width: min(480px, calc(100vw - 96px)); } + .hide-t { display: none !important; } + .page-head h1 { font-size: var(--fs-h2); } +} +/* Tablet touch targets: at least 44 px for primary interactive rows */ +@media (pointer: coarse) { + .btn-sm { min-height: 40px; } + .tbl td { padding-top: 12px; padding-bottom: 12px; } + .menu button, .menu a { padding: 12px 12px; } + .kbd { display: none; } +} +.more-sheet { position: fixed; left: 8px; right: 8px; bottom: 84px; top: auto; z-index: 37; padding: 8px; } +.more-sheet a svg, .more-sheet button svg { width: 18px; height: 18px; } +.findit { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 12px 14px; border-radius: var(--radius-md); border: 2px solid var(--text-accent); background: var(--accent-wash); } +.findit b { font-size: 17px; } +.findit .sub { width: 100%; text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); font-weight: 700; } +details.chg { margin-top: 8px; } +details.chg summary { cursor: pointer; font-size: var(--fs-label); color: var(--text-accent); font-weight: 700; } +.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: 8px; } +.holder-h { display: flex; gap: var(--space-3); align-items: center; font-size: var(--fs-label); flex-wrap: wrap; } +.tabbar { grid-template-columns: repeat(5, 1fr) !important; } diff --git a/iteration-3/source/data.js b/iteration-3/source/data.js new file mode 100644 index 0000000..ad710ba --- /dev/null +++ b/iteration-3/source/data.js @@ -0,0 +1,96 @@ +/* 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, unreconciled: true } +]; + +var LOW = 200; + +/* ── Iteration 3 additions ───────────────────────────────────────────── */ +/* Lookup lists (Lists screen). `used` = spools referencing the value in the + * real 105-spool inventory where known from import/filament_review.tsv. Brand + * tares are the last-resort guess the form labels as such. */ +var LOOKUPS = { + brands: [['Sunlu', 35, null], ['Elegoo', 27, null], ['Overture', 7, null], ['TECSONAR', 6, null], ['Polymaker', 4, null], ['Inland', 4, null], ['ZIRO', 3, null], ['iSANMATE', 3, null], ['Sovol', 2, null], ['JAYO', 2, null], ['GratKit', 2, null], ['Creality', 2, null], ['AMOLEN', 2, null], ['TINMORRY', 1, null], ['Kingroon', 1, null], ['Geeetech', 1, null], ['Anycubic', 1, null], ['3DimsFila', 1, null], ['Prusament', 0, null], ['eSun', 0, null], ['Hatchbox', 0, null], ['Bambu Lab', 0, null]], + materials: [['PLA', 55], ['PETG', 34], ['ASA', 10], ['TPU', 3], ['PA6', 2], ['ABS', 1]], + types: [['Standard', 0], ['CF', 3], ['GF', 4], ['+', 6], ['+ 2.0', 14], ['HF', 0], ['95A', 3]], + speeds: [['Standard', 0], ['Rapid', 18], ['High Speed', 9]], + finishes: [['Basic', 44], ['Matte', 17], ['Silk', 6], ['Sparkle', 3], ['Translucent', 4], ['Metallic', 0], ['Wood', 5], ['Marble', 12], ['Rock', 1], ['Gradient', 9], ['Glow', 0]], + spool_types: [['Generic — 1 kg spool (assumed)', 33, 180, 'estimated'], ['Sunlu — 1 kg plastic', 23, 140, 'community'], ['Elegoo — 1 kg cardboard', 20, 154, 'manufacturer'], ['Sunlu — 250 g mini', 8, 60, 'community'], ['Overture — cardboard, PLA lines', 6, 155, 'community'], ['Refill — bare cardboard core', 4, 35, 'estimated'], ['Inland — PLA Basics cardboard', 4, 143, 'community'], ['Polymaker — 0.5 kg cardboard', 3, 190, 'manufacturer'], ['Polymaker — 1 kg cardboard', 1, 140, 'manufacturer'], ['Overture — cardboard, PETG / ASA', 1, 132, 'community'], ['Bambu Lab — reusable', 0, 250, 'manufacturer'], ['Bambu Lab — cardboard', 0, 196, 'estimated'], ['Hatchbox — 1 kg', 0, 225, 'manufacturer'], ['Creality — Hyper cardboard', 0, 176, 'community']] +}; + +/* Holder ids as the database numbers them (used on holder labels, /c/:id). */ +var HOLDER_IDS = { 'p1-ams1': 1, 'p1-ext': 2, 'p2-ams1': 3, 'p2-ext': 4, 'p3-lite': 5, 'p3-ext': 6 }; + +/* Access (humans come from Authentik; Extrudex shows its local projection). */ +var PEOPLE = [ + { id: 1, name: 'Joshua King', email: 'owner@cubecraft (sample)', role: 'admin', lastSeen: '2026-09-18T09:40', sessions: [{ created: '2026-09-18T07:02', lastSeen: '2026-09-18T09:40', idleExp: '2026-09-18T17:40', maxExp: '2026-09-25T07:02', revoked: null, you: true }, { created: '2026-09-15T20:11', lastSeen: '2026-09-17T22:03', idleExp: '2026-09-18T06:03', maxExp: '2026-09-22T20:11', revoked: null }] }, + { id: 2, name: 'Jordyn', email: 'jordyn@cubecraft (sample)', role: 'operator', lastSeen: '2026-09-17T19:28', sessions: [{ created: '2026-09-17T18:50', lastSeen: '2026-09-17T19:28', idleExp: '2026-09-18T03:28', maxExp: '2026-09-24T18:50', revoked: null }] }, + { id: 3, name: 'Guest tablet', email: null, role: 'reader', lastSeen: '2026-09-09T12:00', sessions: [] } +]; +var MACHINES = [ + { id: 10, name: 'hermes-gateway', role: 'operator', tokens: [{ id: 21, name: 'gateway 2026-09', created: '2026-09-01T10:00', lastUsed: '2026-09-18T09:39', revoked: null }, { id: 17, name: 'gateway 2026-08', created: '2026-08-03T10:00', lastUsed: '2026-08-31T23:58', revoked: '2026-09-01T10:02' }] }, + { id: 11, name: 'uptime-kuma', role: 'reader', tokens: [{ id: 22, name: 'health probe', created: '2026-09-05T14:30', lastUsed: '2026-09-18T09:41', revoked: null }] } +]; +var AUDIT = [ + { id: 9031, who: 'Joshua King', action: 'spool.weighed', type: 'spool', rid: 47, ctx: '860 g → 812 g · tare 262 g (spool)', at: '2026-09-18T09:39' }, + { id: 9030, who: 'hermes-gateway', action: 'print.spool_set', type: 'print', rid: 211, ctx: 'spool #42 · 46 g deducted at $11.74/kg', at: '2026-09-18T05:28' }, + { id: 9029, who: 'Jordyn', action: 'spool.moved', type: 'spool', rid: 55, ctx: 'Shelf D → A1 · AMS Lite', at: '2026-09-17T19:26' }, + { id: 9028, who: 'Jordyn', action: 'print.use_type_set', type: 'print', rid: 209, ctx: 'personal', at: '2026-09-17T19:25' }, + { id: 9027, who: 'Joshua King', action: 'lookup.updated', type: 'spool_type', rid: 4, ctx: 'Sunlu — 250 g mini · tare 60 g · community', at: '2026-09-17T08:12' }, + { id: 9026, who: 'Joshua King', action: 'printer.updated', type: 'printer', rid: 3, ctx: 'ip changed · access code unchanged', at: '2026-09-16T21:40' }, + { id: 9025, who: 'Joshua King', action: 'session.revoked', type: 'principal', rid: 3, ctx: '1 local session', at: '2026-09-09T12:05' } +]; +var WEBHOOK = { enabled: true, schedule: 'Mondays 08:00 America/New_York', threshold: 200, target: 'configured (URL kept in a root-only file, never shown)', + events: [{ period: '2026-W38', observed: '2026-09-14T08:00', state: 'delivered', attempts: 1, spools: 4, delivered: '2026-09-14T08:00' }, { period: '2026-W37', observed: '2026-09-07T08:00', state: 'delivered', attempts: 3, spools: 3, delivered: '2026-09-07T08:21', lastError: 'HTTP 502 from receiver (retried)' }, { period: '2026-W36', observed: '2026-08-31T08:00', state: 'no_low', attempts: 0, spools: 0 }] }; diff --git a/iteration-3/source/pages/01-dashboard.html b/iteration-3/source/pages/01-dashboard.html new file mode 100644 index 0000000..f009d73 --- /dev/null +++ b/iteration-3/source/pages/01-dashboard.html @@ -0,0 +1,76 @@ + + diff --git a/iteration-3/source/pages/02-filament.html b/iteration-3/source/pages/02-filament.html new file mode 100644 index 0000000..5e3e2f6 --- /dev/null +++ b/iteration-3/source/pages/02-filament.html @@ -0,0 +1,115 @@ + + diff --git a/iteration-3/source/pages/03-prints.html b/iteration-3/source/pages/03-prints.html new file mode 100644 index 0000000..92ee09e --- /dev/null +++ b/iteration-3/source/pages/03-prints.html @@ -0,0 +1,84 @@ + + diff --git a/iteration-3/source/pages/04-printers.html b/iteration-3/source/pages/04-printers.html new file mode 100644 index 0000000..056387d --- /dev/null +++ b/iteration-3/source/pages/04-printers.html @@ -0,0 +1,84 @@ + + diff --git a/iteration-3/source/pages/05-lists.html b/iteration-3/source/pages/05-lists.html new file mode 100644 index 0000000..77a2c7e --- /dev/null +++ b/iteration-3/source/pages/05-lists.html @@ -0,0 +1,76 @@ + + diff --git a/iteration-3/source/pages/06-labels.html b/iteration-3/source/pages/06-labels.html new file mode 100644 index 0000000..a26f2f2 --- /dev/null +++ b/iteration-3/source/pages/06-labels.html @@ -0,0 +1,76 @@ + + diff --git a/iteration-3/source/pages/07-access.html b/iteration-3/source/pages/07-access.html new file mode 100644 index 0000000..e9e7ddf --- /dev/null +++ b/iteration-3/source/pages/07-access.html @@ -0,0 +1,58 @@ + + diff --git a/iteration-3/source/pages/08-system.html b/iteration-3/source/pages/08-system.html new file mode 100644 index 0000000..a41a250 --- /dev/null +++ b/iteration-3/source/pages/08-system.html @@ -0,0 +1,53 @@ + + diff --git a/iteration-3/source/pages/09-scan-holder.html b/iteration-3/source/pages/09-scan-holder.html new file mode 100644 index 0000000..8a6f327 --- /dev/null +++ b/iteration-3/source/pages/09-scan-holder.html @@ -0,0 +1,79 @@ + + diff --git a/iteration-3/source/pages/10-scan-spool.html b/iteration-3/source/pages/10-scan-spool.html new file mode 100644 index 0000000..a984de3 --- /dev/null +++ b/iteration-3/source/pages/10-scan-spool.html @@ -0,0 +1,47 @@ + + diff --git a/iteration-3/source/pages/devices.html b/iteration-3/source/pages/devices.html new file mode 100644 index 0000000..9171daa --- /dev/null +++ b/iteration-3/source/pages/devices.html @@ -0,0 +1,49 @@ + + diff --git a/iteration-3/source/pages/index.html b/iteration-3/source/pages/index.html new file mode 100644 index 0000000..1c54403 --- /dev/null +++ b/iteration-3/source/pages/index.html @@ -0,0 +1,79 @@ + + diff --git a/screens/01-dashboard.html b/screens/01-dashboard.html new file mode 100644 index 0000000..b7fb0ff --- /dev/null +++ b/screens/01-dashboard.html @@ -0,0 +1,718 @@ + + + + + +Extrudex Dashboard + + + + + +
+ +
+
+ Design System v3 proposal + Mockup with sample data from the real inventory. Nothing here is wired to the API. + + dev on dev today + feature feature branch only + API gap stored, not served +
+
+
+

Dashboard

+

Friday 18 September · refreshes every 10 s · last update 09:41:07

+
+ +
+ +
+

Stock

+
+
Spools10518 brands · 6 materials
+
Filament left net95.72 kgfilament only, empty spools excluded
+
Stock value$1,721.18remaining net g × each spool’s $/kg
+
Running low⚠ 5under 200 g net each
+
+
+ +
+
+
+
+

Recent prints

+ + All prints → +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
JobStatusDurationFilamentWeight source API gapCost
Gridfinity_bins_3x2P1S - Prints Charming · 08:37● Running1h 04m so far—weighed when it endspending
Benchy_x4H2C - Constipation Orion · 02:15✓ Finished≤ 3h 12m + upper bound · 1 gap46 gSliced from this run's 3MF$0.53
Sign_CubeCraftH2C - Constipation Orion · yesterday 21:48 · 2 colors✓ Finished2h 05m64 g 41 g + 23 gSliced$0.92
Cable_clipsA1 - Jordyns Layer Slayer · yesterday 19:30? Unknownno durationend not seen12 gTyped someone weighed it$0.14
Lid_v3P1S - Prints Charming · yesterday 16:02✕ Failed47m118 g full job weightTyped$1.38
Planter_smallA1 - Jordyns Layer Slayer · 16 Sep 10:20✓ Finished5h 40m212 gRecalled remembered for this job$2.49
Test_cubeP1S - Prints Charming · 7 Aug 14:11✓ Finished31m gaps not counted9 gUnknown predates provenance$0.10
+
+
+
≤Upper bound. The connection dropped during the print, so the time includes a gap nobody watched.
+
TypedA measurement. It survives an identity correction.
+
SlicedRead from the job's 3MF over FTPS.
+
RecalledA copy of a weight remembered for the same gcode path.
+
UnknownWritten before the source was recorded. Neither path will move it.
+
+
+
+ + +
+
+
+ + + diff --git a/screens/02-filament.html b/screens/02-filament.html new file mode 100644 index 0000000..7931d00 --- /dev/null +++ b/screens/02-filament.html @@ -0,0 +1,724 @@ + + + + + +Extrudex Filament + + + + + +
+ +
+
+ Design System v3 proposal + Mockup with sample data from the real inventory. Nothing here is wired to the API. + + dev on dev today + feature feature branch only + API gap stored, not served +
+
+
+

Filament

+

105 spools · 18 brands · 95.72 kg net · $1,721.18

+
+
+ Labels outstanding: 12 + + + + Add spool +
+
+ +
+ + + + +
+ + + + +
+ + +
+
+
+ +
+
+
+ + + + + + + + + +
Spool color · brand speed material type finishRemaining netEmpty spool (tare)Gross on scaleValueLocationActions
+
+
+
Page 1 of 9
+
+
+
+ + +
+

Delete ?

+

This removes the spool from inventory. Its print history is kept: prints that used it keep their grams and cost, and only lose the link to this spool.

+

This can't be undone. If you just ran the spool out, you can also set remaining to 0 g and keep the record.

+
+ + +
+
+
+ + +
+
+ + + diff --git a/screens/03-spool-form.html b/screens/03-spool-form.html new file mode 100644 index 0000000..9a6d001 --- /dev/null +++ b/screens/03-spool-form.html @@ -0,0 +1,828 @@ + + + + + +Extrudex Spool Form + + + + + +
+ +
+
+ Design System v3 proposal + Mockup with sample data from the real inventory. Nothing here is wired to the API. + + dev on dev today + feature feature branch only + API gap stored, not served +
+
+
+

Add / Edit spool

+

Opens as a dialog over Filament. Pick a scenario to see each state.

+
+ ← Back to Filament +
+ +
+ Scenario +
+ + + + +
+
+ +
+ +
+ +
+

Control states: v3 specimen

+

Every state changes something other than shadow direction. Switch themes with the toggle to check both.

+
+
Rest
raised + 1px border (3:1)
+
Hover
border turns accent
+
Pressed / on
inset + wash fill + ✓ + accent border
+
Focused
2px outline, 3px offset
+
Disabled
flat + dashed border + muted text
+
Primary
ink on light violet (6.14:1 dark)
+
Primary pressed
deeper fill + ✓ + ring
+
Danger
red text + red border
+
Input rest
inset + border
+
Input focus
outline, border unchanged
+
Input disabled
dashed, flat
+

Too much

Input error
2px red + ⚠ + message
+
+
+ + +
+
+ + + diff --git a/screens/04-printers.html b/screens/04-printers.html new file mode 100644 index 0000000..d4e3b46 --- /dev/null +++ b/screens/04-printers.html @@ -0,0 +1,751 @@ + + + + + +Extrudex Printers + + + + + +
+ +
+
+ Design System v3 proposal + Mockup with sample data from the real inventory. Nothing here is wired to the API. + + dev on dev today + feature feature branch only + API gap stored, not served +
+
+
+

Printers

+

3 printers · status polled every 10 s · MQTT is read-only

+
+
+ + +
+
+ +
+ Data shown +
+ + +
+ Fields tagged feature exist only on feature/7efc2030…-printer-connections (4e17f9e), not in production. +
+ +
+
+

In progress 1

+
+
+
+

P1S - Prints Charming

Bambu Lab · P1S
+ ● Printing + +
+
Ready · last report 2s agofeature
+
Connected
+ +
+ Gridfinity_bins_3x21h 04m + started 08:37 · weight read from the 3MF when it endsso far +
+
+
Loaded filamentnot confirmed in use
+
+
AMS 14 / 4
+
1Sunlu · Black PLA + 2.01000 g
+
2Sunlu · Copper PLA + Silk⚠ 97 g
+
3Overture · Mist Gray PLA Marble920 g
+
4JAYO · Black Rapid PETG Matte655 g
+
+
+
External0 / 1
+
1Empty
+
+
+
Connection details (stored) +
+
IP
10.60.1.242
Serial
01P09A••••••4821
Access code
✓ stored, never shown
Added
7 Aug 2026
+
Session
#3 feature
+
+

Last finished: Lid_v3 · failed yesterday 16:49

+
+
+
+ +
+

Needs attention 1

+
+
+
+

A1 - Jordyns Layer Slayer

Bambu Lab · A1
+ ⟳ Reconnecting + ⚡ Offline + +
+ +
+

⚡ MQTT connection lost

+ Disconnected, reconnecting. Last contact 6m ago (09:35:11). feature + Extrudex's MQTT session to this printer dropped. The time it dropped isn't recorded on dev. +
    +
  • The printer itself may still be printing. This is Extrudex's connection, not the printer's power.
  • +
  • Print capture is paused. A job that starts and finishes during the outage won't be recorded.
  • +
  • On reconnect: if Coaster_set is still running, its record stays open and gets +1 gap (its duration becomes an upper bound). Otherwise it closes as unknown, with no duration.
  • +
+
+ +
+ Coaster_setOPEN RECORD + started 08:55 · watched until the connection dropped + Last known: Printing · 6m ago. Cached from the lost session, not live. feature +
+
Camera unavailable while the printer is offline
+
+
Loaded filamentfrom Extrudex records, not the printer
+
+
AMS Lite2 / 4
+
1Sunlu · Black PETG CF Matte812 g
+
2AMOLEN · Red Yellow Blue PLA Matte744 g
+
3Empty
+
4Empty
+
+
+
Connection details (stored) +
+
IP
10.60.1.x (sample)
Serial
039••••••••7710
Access code
✓ stored, never shown
+
Lifecycle
reconnecting · stale: true feature
+
Failure
none reported. A reconnect can have no failure detail.
+
Session
#14 (increments on every reconnect)
+
+
Retrying automatically. There's nothing to press.
+
+
+
+ +
+

Idle 1

+
+
+
+

H2C - Constipation Orion

Bambu Lab · H2C
+ Idle + +
+
Ready · last report 4s ago featureConnected
+

Last: Benchy_x4 · finished 05:27 · ≤ 3h 12m upper bound · 1 gap

+
+
AMS 13 / 4
+
1Elegoo · Black Rapid PETG⚠ 142 g
+
2Elegoo · Blue Rapid PETG1000 g
+
3Elegoo · White PLA Matte610 g
+
4Empty
+
+
Connection details (stored) +
IP
10.60.1.202
Serial
31B••••••••0093
Access code
✓ stored, never shown
+
+
+
+ +
+ +
+

Future, not built Ideas that need data Extrudex doesn't store

+

Nothing in this box can be built from today's data. Each item says what's missing.

+
+

Progress % and time left

+ mc_percent is parsed from MQTT but used only internally. It isn't stored or returned by /api/printers. ETA isn't read at all.
+

Nozzle and bed temperature

220 °C · 60 °C + Temperatures aren't parsed from the report.
+

Current layer

Layer 112 / 190 + Layer fields aren't parsed.
+

AMS remaining % and humidity

82% · humidity 2 + Not read. AMS remain is only meaningful for genuine RFID spools, which isn't what's mostly run here.
+

Alert when MQTT drops

🔔 notify after 5 min + Drops only show in container logs today (CONTEXT open work #4). On dev, nothing stores when it dropped.
+

Which spool is feeding now

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

Dashboard

+

Friday 18 September · refreshes every 10 s · last update 09:41:07

+
+ +
+ +
+

Stock

+
+
Spools10518 brands · 6 materials
+
Filament left net95.72 kgfilament only, empty spools excluded
+
Stock value$1,721.18remaining net g × each spool’s $/kg
+
Running low⚠ 5under 200 g net each
+
+
+ +
+
+
+
+

Recent prints

+ + All prints → +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
JobStatusDurationFilamentWeight source API gapCost
Gridfinity_bins_3x2P1S - Prints Charming · 08:37● Running1h 04m so far—weighed when it endspending
Benchy_x4H2C - Constipation Orion · 02:15✓ Finished≤ 3h 12m + upper bound · 1 gap46 gSliced from this run's 3MF$0.53
Sign_CubeCraftH2C - Constipation Orion · yesterday 21:48 · 2 colors✓ Finished2h 05m64 g 41 g + 23 gSliced$0.92
Cable_clipsA1 - Jordyns Layer Slayer · yesterday 19:30? Unknownno durationend not seen12 gTyped someone weighed it$0.14
Lid_v3P1S - Prints Charming · yesterday 16:02✕ Failed47m118 g full job weightTyped$1.38
Planter_smallA1 - Jordyns Layer Slayer · 16 Sep 10:20✓ Finished5h 40m212 gRecalled remembered for this job$2.49
Test_cubeP1S - Prints Charming · 7 Aug 14:11✓ Finished31m gaps not counted9 gUnknown predates provenance$0.10
+
+
+
≤Upper bound. The connection dropped during the print, so the time includes a gap nobody watched.
+
TypedA measurement. It survives an identity correction.
+
SlicedRead from the job's 3MF over FTPS.
+
RecalledA copy of a weight remembered for the same gcode path.
+
UnknownWritten before the source was recorded. Neither path will move it.
+
+
+
+ + +
diff --git a/source/pages/02-filament.html b/source/pages/02-filament.html new file mode 100644 index 0000000..b15f4bd --- /dev/null +++ b/source/pages/02-filament.html @@ -0,0 +1,182 @@ + + +
+
+

Filament

+

105 spools · 18 brands · 95.72 kg net · $1,721.18

+
+
+ Labels outstanding: 12 + + + + Add spool +
+
+ +
+ + + + +
+ + + + +
+ + +
+
+
+ +
+
+
+ + + + + + + + + +
Spool color · brand speed material type finishRemaining netEmpty spool (tare)Gross on scaleValueLocationActions
+
+
+
Page 1 of 9
+
+
+
+ + +
+

Delete ?

+

This removes the spool from inventory. Its print history is kept: prints that used it keep their grams and cost, and only lose the link to this spool.

+

This can't be undone. If you just ran the spool out, you can also set remaining to 0 g and keep the record.

+
+ + +
+
+
+ + diff --git a/source/pages/03-spool-form.html b/source/pages/03-spool-form.html new file mode 100644 index 0000000..f5a85d3 --- /dev/null +++ b/source/pages/03-spool-form.html @@ -0,0 +1,286 @@ + + +
+
+

Add / Edit spool

+

Opens as a dialog over Filament. Pick a scenario to see each state.

+
+ ← Back to Filament +
+ +
+ Scenario +
+ + + + +
+
+ +
+ +
+ +
+

Control states: v3 specimen

+

Every state changes something other than shadow direction. Switch themes with the toggle to check both.

+
+
Rest
raised + 1px border (3:1)
+
Hover
border turns accent
+
Pressed / on
inset + wash fill + ✓ + accent border
+
Focused
2px outline, 3px offset
+
Disabled
flat + dashed border + muted text
+
Primary
ink on light violet (6.14:1 dark)
+
Primary pressed
deeper fill + ✓ + ring
+
Danger
red text + red border
+
Input rest
inset + border
+
Input focus
outline, border unchanged
+
Input disabled
dashed, flat
+

Too much

Input error
2px red + ⚠ + message
+
+
+ + diff --git a/source/pages/04-printers.html b/source/pages/04-printers.html new file mode 100644 index 0000000..b1600db --- /dev/null +++ b/source/pages/04-printers.html @@ -0,0 +1,209 @@ + + +
+
+

Printers

+

3 printers · status polled every 10 s · MQTT is read-only

+
+
+ + +
+
+ +
+ Data shown +
+ + +
+ Fields tagged feature exist only on feature/7efc2030…-printer-connections (4e17f9e), not in production. +
+ +
+
+

In progress 1

+
+
+
+

P1S - Prints Charming

Bambu Lab · P1S
+ ● Printing + +
+
Ready · last report 2s agofeature
+
Connected
+ +
+ Gridfinity_bins_3x21h 04m + started 08:37 · weight read from the 3MF when it endsso far +
+
+
Loaded filamentnot confirmed in use
+
+
AMS 14 / 4
+
1Sunlu · Black PLA + 2.01000 g
+
2Sunlu · Copper PLA + Silk⚠ 97 g
+
3Overture · Mist Gray PLA Marble920 g
+
4JAYO · Black Rapid PETG Matte655 g
+
+
+
External0 / 1
+
1Empty
+
+
+
Connection details (stored) +
+
IP
10.60.1.242
Serial
01P09A••••••4821
Access code
✓ stored, never shown
Added
7 Aug 2026
+
Session
#3 feature
+
+

Last finished: Lid_v3 · failed yesterday 16:49

+
+
+
+ +
+

Needs attention 1

+
+
+
+

A1 - Jordyns Layer Slayer

Bambu Lab · A1
+ ⟳ Reconnecting + ⚡ Offline + +
+ +
+

⚡ MQTT connection lost

+ Disconnected, reconnecting. Last contact 6m ago (09:35:11). feature + Extrudex's MQTT session to this printer dropped. The time it dropped isn't recorded on dev. +
    +
  • The printer itself may still be printing. This is Extrudex's connection, not the printer's power.
  • +
  • Print capture is paused. A job that starts and finishes during the outage won't be recorded.
  • +
  • On reconnect: if Coaster_set is still running, its record stays open and gets +1 gap (its duration becomes an upper bound). Otherwise it closes as unknown, with no duration.
  • +
+
+ +
+ Coaster_setOPEN RECORD + started 08:55 · watched until the connection dropped + Last known: Printing · 6m ago. Cached from the lost session, not live. feature +
+
Camera unavailable while the printer is offline
+
+
Loaded filamentfrom Extrudex records, not the printer
+
+
AMS Lite2 / 4
+
1Sunlu · Black PETG CF Matte812 g
+
2AMOLEN · Red Yellow Blue PLA Matte744 g
+
3Empty
+
4Empty
+
+
+
Connection details (stored) +
+
IP
10.60.1.x (sample)
Serial
039••••••••7710
Access code
✓ stored, never shown
+
Lifecycle
reconnecting · stale: true feature
+
Failure
none reported. A reconnect can have no failure detail.
+
Session
#14 (increments on every reconnect)
+
+
Retrying automatically. There's nothing to press.
+
+
+
+ +
+

Idle 1

+
+
+
+

H2C - Constipation Orion

Bambu Lab · H2C
+ Idle + +
+
Ready · last report 4s ago featureConnected
+

Last: Benchy_x4 · finished 05:27 · ≤ 3h 12m upper bound · 1 gap

+
+
AMS 13 / 4
+
1Elegoo · Black Rapid PETG⚠ 142 g
+
2Elegoo · Blue Rapid PETG1000 g
+
3Elegoo · White PLA Matte610 g
+
4Empty
+
+
Connection details (stored) +
IP
10.60.1.202
Serial
31B••••••••0093
Access code
✓ stored, never shown
+
+
+
+ +
+ +
+

Future, not built Ideas that need data Extrudex doesn't store

+

Nothing in this box can be built from today's data. Each item says what's missing.

+
+

Progress % and time left

+ mc_percent is parsed from MQTT but used only internally. It isn't stored or returned by /api/printers. ETA isn't read at all.
+

Nozzle and bed temperature

220 °C · 60 °C + Temperatures aren't parsed from the report.
+

Current layer

Layer 112 / 190 + Layer fields aren't parsed.
+

AMS remaining % and humidity

82% · humidity 2 + Not read. AMS remain is only meaningful for genuine RFID spools, which isn't what's mostly run here.
+

Alert when MQTT drops

🔔 notify after 5 min + Drops only show in container logs today (CONTEXT open work #4). On dev, nothing stores when it dropped.
+

Which spool is feeding now

▶ AMS 1 · slot 2 + "Loaded" is recorded; "in use" isn't. That's why the list says not confirmed in use.
+
+
+ +