Add the Extrudex Design System v3 bundle
Tokens (light + dark, neumorphism, 54/54 WCAG AA contrast pairs), the self-hosted JetBrains Mono fonts, and interactive mockups of every screen across three iterations. Iteration 3 is final: phone, tablet and desktop tiers, device preview, and "empty spool weight" terminology. The build scripts regenerate the screens byte-identically from source. Implementation is tracked in Maestro feature UI Refresh - V3.0 (224b72a1-8d84-4970-97ea-5dafa4d8ea3b). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9ba0d409c8
commit
d9ed1898b9
@@ -0,0 +1,2 @@
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
@@ -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 `<dialog>`)
|
||||
|
||||
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.
|
||||
@@ -1 +1,58 @@
|
||||
# Extrudex Design V3.0
|
||||
# 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.
|
||||
|
||||
+157
@@ -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".
|
||||
@@ -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
|
||||
* <html>. 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; }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,247 @@
|
||||
# Iteration 2: navigation, data display and interaction
|
||||
|
||||
2026-09-18. Builds on iteration 1 (`../screens/`, left unchanged for comparison).
|
||||
Same Design System v3 tokens; the new components are in
|
||||
`source/components.css`. Open **`screens/index.html`** first: it's the flow map,
|
||||
with eight guided flows that deep-link into each state.
|
||||
|
||||
---
|
||||
|
||||
## 1. What was wrong with iteration 1
|
||||
|
||||
| # | Where | Problem | Why it matters | Iteration 2 fix |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Nav, Dashboard | "Prints" and "All prints →" linked to `#`. Dead ends. | You lose trust in a nav item the first time it does nothing. | A real **Prints** page (history + attention queue, as on `dev`) |
|
||||
| 2 | Filament → Edit | Edit navigated to a separate page | You lose your place, filter and scroll every time | **Detail panel** opens over the list; Esc/back returns you to where you were |
|
||||
| 3 | Dashboard, Printers | A spool in "Running low" or an AMS slot wasn't clickable | The most natural next click went nowhere | Every spool, print and holder opens the same panel, on any page |
|
||||
| 4 | Dashboard | Reported numbers but never said what to do | The kill criterion is **daily use**, so the home screen has to hand you today's jobs | **"Needs you"** queue, each item one click from its fix |
|
||||
| 5 | Everywhere | Almost every value was an uppercase chip (status, provenance, warnings) | Nothing stands out when everything is loud, so a legend box was needed | Chip/label hierarchy (§5.1); provenance became quiet text with a tooltip |
|
||||
| 6 | Filament table | Tare, source, gross, value, location and two actions in every row, 2–3 lines each | Too dense to scan 105 rows | **Stock / Weights** column sets; the full breakdown moves to the panel |
|
||||
| 7 | All pages | No freshness, loading, empty or error state | The app polls every 10 s; stale data shown as live is the worst failure | "Live · updated 3s ago" in the header, an offline banner with the data's age, skeletons, empty states |
|
||||
| 8 | Filament | "P1S · AMS 1 · **slot 2**" | **Invented.** The schema stores the holder, not the tray position | Holders show fill ("3 / 4") and contents; tray position listed under Future |
|
||||
| 9 | Mobile | Nav squeezed into icon rows; no reachable primary action | Weighing happens at the shelf, on a phone | Bottom tab bar with badges, a floating **+** button, the panel as a full-screen sheet |
|
||||
| 10 | Every page | A large proposal banner on top of every screen | Pushed content down; it read as part of the app | A small "Mockup" pill bottom-left, with the legend and demo controls inside |
|
||||
| 11 | Printers | One card per section in three stacked sections | Two-thirds of the width empty; the lost printer was mid-page | Urgency order (lost → printing → idle) in one row, plus a "Jump to" strip |
|
||||
| 12 | Printers ↔ Dashboard | "Open printer" landed at the top of the page | You had to find the right card yourself | `04-printers.html#printer=3` scrolls to that card and highlights it |
|
||||
|
||||
---
|
||||
|
||||
## 2. Navigation model
|
||||
|
||||
```
|
||||
┌ Rail (desktop) / tab bar (phone) ─────────────────────────────┐
|
||||
│ Dashboard · Filament · Prints [3] · Printers [1] │ ← badges = work waiting
|
||||
│ Manage: Lists · Labels [4] · Access (admin) │ ← kept on dev, not redesigned
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
Top bar on every page: [ Search… ⌘K ] ● Live · updated 3s ago [◐]
|
||||
```
|
||||
|
||||
- **Four destinations, ranked by how often they're used.** Manage items are
|
||||
second-tier and grouped separately. Access is labeled "admin".
|
||||
- **Badges only for work waiting**: Prints = prints missing a spool or weight
|
||||
(amber); Printers = disconnected printers (red). No badge for totals, so a
|
||||
badge always means "go here".
|
||||
- **One page header pattern**: title, a one-line summary with the key numbers,
|
||||
**one** primary button on the right, everything else under "More ▾".
|
||||
- **Deep links for every state**, so browser Back, bookmarks and sharing all
|
||||
work:
|
||||
|
||||
| URL | Opens |
|
||||
|---|---|
|
||||
| `02-filament.html?f=low&q=black` | Filament, Low view, filtered |
|
||||
| `…#spool=47` · `#spool=47&tab=weigh` · `&tab=edit&state=conflict` | Spool panel, on a tab, optionally in a demo state |
|
||||
| `03-prints.html?tab=needs` · `#print=210` | Prints tab / print panel |
|
||||
| `04-printers.html#printer=3` · `#load=p3-lite` | Scroll to printer / load a holder |
|
||||
| `?palette=weigh%20black` | Palette open with a query (demo hook) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Core flows (most frequent first)
|
||||
|
||||
| Flow | Steps in iteration 2 | Iteration 1 |
|
||||
|---|---|---|
|
||||
| **Morning check** | Dashboard → "Needs you" → Review 3 → **Use** suggestion ×2, type 1 weight | Not possible; no queue |
|
||||
| **Attribute a print** | Prints › Needs attention: suggestion shown inline → **Use**. Or batch-select → one spool | Not designed |
|
||||
| **Weigh a spool** | Row **⚖ Weigh**, or palette `weigh copper` → type reading → see before → after → Save | Open the form, find the weigh box, save the whole form |
|
||||
| **Weigh several** | Filament › Low → **Start weighing** → **Save & weigh next** (j/k also steps) | One at a time, reopening the form each time |
|
||||
| **Load a spool** | Printers → holder **+ Load** → filter stored spools → pick → Load | Not designed |
|
||||
| **Move / unload** | Panel › Move: storage or any holder; full holders disabled with the reason | Only by typing a location |
|
||||
| **Add a spool** | **+ Add spool** (header, FAB, palette) → panel with 5-part name preview | Separate page |
|
||||
| **Handle a 409** | Panel › Edit: side-by-side you-opened / now / your-edit → reapply or discard | Same content, but on a separate page |
|
||||
| **Tare unknown** | Tare unknown view → Weigh tab explains, offers spool type or weigh-empty | Only a hint under the form field |
|
||||
| **Find anything** | `/` or `⌘K` → spools, prints, printers and actions in one list | Filament search only |
|
||||
|
||||
---
|
||||
|
||||
## 4. Interaction patterns
|
||||
|
||||
**Panel (drawer) vs page.** Use a page for a *collection* and a panel for *one
|
||||
thing*. The panel sits on the right (520 px) and becomes a full-screen sheet
|
||||
under 860 px. Tabs: Overview · Weigh · Move · Edit for spools; a single view for
|
||||
prints, with the fix at the top when something is missing.
|
||||
- Opens with the heading focused (or the tab's main input), traps Tab, and
|
||||
returns focus to the row that opened it.
|
||||
- **Esc**, the ✕, clicking the backdrop or browser **Back** closes it.
|
||||
- **↑ / ↓ buttons and j / k** step through the list it was opened from. The
|
||||
footer's **Save & weigh next** does the same while weighing.
|
||||
- **Unsaved changes**: closing Edit with changes shows an inline "Discard
|
||||
unsaved changes?" bar, not a browser `confirm()`.
|
||||
|
||||
**Command palette.** `/` or `⌘K` from anywhere. Empty, it shows actions (Add
|
||||
spool, Weigh a spool…, Prints needing attention, Switch theme) plus Go to.
|
||||
Typing matches color, brand, `#id`, location, job name, gcode path or printer.
|
||||
The `weigh ` prefix turns the list into "Weigh which spool?". ↑↓ ↵ Esc all work.
|
||||
It uses the ARIA combobox + listbox pattern.
|
||||
|
||||
**Row actions.** A whole row is clickable and focusable (↵ opens it). The most
|
||||
common action (**⚖ Weigh**) is visible on the row; the rest sit in a ⋯ menu,
|
||||
which is always visible so touch and keyboard users can reach it. Destructive
|
||||
items are last, after a separator, in red.
|
||||
|
||||
**Confirmation.** Only for destructive actions, and it says the consequence:
|
||||
"Its print history stays… This can't be undone." Everything else saves
|
||||
directly and confirms with a **toast** that names what changed ("Weighed Copper
|
||||
· Sunlu: 97 g → 58 g"), with a **View** link. There's no fake Undo, because the
|
||||
API has none.
|
||||
|
||||
**Batch.** Batch attribution follows `dev`'s AttributionQueue (select → attribute
|
||||
to one spool). The weigh session is the batch pattern for weighing.
|
||||
|
||||
**Keyboard map.** `/` `⌘K` palette · `↵` open row · `Esc` close · `j` `k` next
|
||||
and previous in the panel · `Tab` stays inside the panel while it's open.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data display rules
|
||||
|
||||
### 5.1 Three visual tiers, used for nothing else
|
||||
|
||||
| Tier | Looks like | Used for | Examples |
|
||||
|---|---|---|---|
|
||||
| **State** | Colored dot/shape + bold word | What something *is doing* | ● Running · ● Finished · ◆ Unknown · ■ Failed · ○ Idle · ◆ Reconnecting |
|
||||
| **Needs action** | Amber/red text with `!` or `⚠`, or an amber meter | Something *you* should do | `! no spool` · `⚠ 97 g` · `? tare unknown` |
|
||||
| **Provenance** | Small gray icon + lowercase word, tooltip explains | *Where a number came from* | `✎ typed` · `▦ sliced` · `⎘ recalled` · `⚖ weighed bare` · `◇ community` · `≈ estimated` |
|
||||
|
||||
Each state has its own **shape** (dot, diamond, square, ring), so status never
|
||||
relies on color alone. Chips (pills) remain only for view counts, filters and
|
||||
the "Future, not built" marker.
|
||||
|
||||
### 5.2 Numbers
|
||||
|
||||
- Grams: `1,000 g` with thousands separators, always a space before the unit.
|
||||
Totals over 1 kg: `95.72 kg`. Money: `$1,721.18`.
|
||||
- Right-aligned tabular numbers in tables; the unit is never dropped.
|
||||
- **Net is the default.** "Remaining" always means net filament. Gross appears
|
||||
only with its tare beside it (`262 g tare + 812 g = 1,074 g`), so the three
|
||||
can't be confused.
|
||||
- **Unknown is written out** (`unknown`, `no duration`, `—` only for "not
|
||||
applicable yet"). A missing tare is never 0.
|
||||
- Durations: `3h 12m`. An upper bound is `≤ 3h 12m` in amber, with the gap
|
||||
count beneath.
|
||||
|
||||
### 5.3 Time
|
||||
|
||||
Today and yesterday are relative words plus a clock time (`Today 08:37`,
|
||||
`Yesterday 21:48`); older dates are `16 Sep 10:20`. Freshness is relative
|
||||
(`updated 3s ago`, `last contact 6m ago`), because the question it answers is
|
||||
"can I trust this?".
|
||||
|
||||
### 5.4 Meters
|
||||
|
||||
The remaining meter carries a **tick at the 200 g line**, so "how close to low"
|
||||
reads without a number. The fill turns amber below it.
|
||||
|
||||
### 5.5 Progressive disclosure
|
||||
|
||||
| Where | Shows | Moves to the panel |
|
||||
|---|---|---|
|
||||
| Filament row | Spool, remaining meter, location *or* tare/gross (column set), value | Full tare/gross breakdown, source, spool type, label, version, prints from this spool |
|
||||
| Print row | Job, state, duration, grams + source, spool, cost | Job key, why the source matters, per-color table, use type, archive |
|
||||
| Printer card | State, now/last job, camera, holder contents | Connection details (collapsed) |
|
||||
|
||||
### 5.6 System states on every list
|
||||
|
||||
Loading → skeletons of the real layout (never a spinner over an empty page).
|
||||
Empty → what's missing and one action ("Clear filters"). Stale → header dot
|
||||
turns amber, and a banner gives the data's age and says saving is paused.
|
||||
All-done → "✓ Nothing needs you right now."
|
||||
|
||||
---
|
||||
|
||||
## 6. Accessibility of the new pieces
|
||||
|
||||
- Panel: `role="dialog"`, `aria-modal`, labeled by its heading, focus trap, focus
|
||||
returned on close.
|
||||
- Palette: `role="combobox"` + `listbox` with `aria-activedescendant`.
|
||||
- Tabs and segmented controls use `aria-selected`/`aria-pressed` **and** show a
|
||||
raised fill plus bold text, not shadow alone.
|
||||
- Sortable headers use `aria-sort` with a visible ▲/▼/↕.
|
||||
- Meters use `role="meter"` with min, max and now.
|
||||
- Toasts sit in an `aria-live="polite"` region. The offline banner is
|
||||
`role="alert"`.
|
||||
- Skip link, and focus rings on every interactive element (including rows).
|
||||
- Motion (drawer slide, highlight flash, skeleton shimmer) turns off under
|
||||
`prefers-reduced-motion`.
|
||||
- All contrast pairs are unchanged from v3 (54/54 pass). No new colors were
|
||||
introduced.
|
||||
|
||||
---
|
||||
|
||||
## 7. Things I fixed for honesty
|
||||
|
||||
- **Tray positions removed** (see §1 #8). The Move flow chooses a *holder*, and
|
||||
full holders are disabled with the reason.
|
||||
- **"Loaded filament: recorded by Extrudex, not read from the printer"** on
|
||||
every printer card.
|
||||
- **API gaps are labeled where they bite**: spool location history
|
||||
(`spool_location_event` has no read endpoint) and weight source on
|
||||
`/api/prints`.
|
||||
- The weigh flow shows before → after and refuses, with the reason, a reading
|
||||
below tare or above a full spool (the same two checks `dev` does).
|
||||
|
||||
## 8. Not redesigned in this iteration
|
||||
|
||||
Lists (taxonomy), Labels, Access administration, the Add/Edit **printer** form,
|
||||
and the phone scan session (`/s/:id`, `/c/:id`). They keep their `dev` screens.
|
||||
The nav links to them show a toast saying so.
|
||||
|
||||
## 9. New open questions
|
||||
|
||||
1. **Suggested spool acceptance.** On `dev` a suggestion is "not confirmed or
|
||||
charged" until accepted. Iteration 2 puts **Use** one click away. Is one
|
||||
click enough confirmation for a deduction, or should it ask?
|
||||
1a. On click is fine as long as it's accurate. It can always be manually updated if it was an error.
|
||||
2. **Weigh session order.** It follows the current list order (least remaining
|
||||
first). Would shelf order be better? That needs location to be structured,
|
||||
not free text.
|
||||
2a. Weight would be best, but the location should also be visible, that way I can see the rolls that are lowest and use them first.
|
||||
3. **Default landing page.** Dashboard, or straight to Prints › Needs attention
|
||||
when it's non-empty?
|
||||
3a. Dashboard
|
||||
4. **Badge scope.** Should Filament get a badge for "tare unknown", or does that
|
||||
become nagging, since most tares resolve from a spool type?
|
||||
4a. Yes, this is good. It identifies an assumed weight and could be off.
|
||||
5. **Palette actions.** Worth adding "Load ‹spool› into ‹holder›" and "Attribute
|
||||
‹print› to ‹spool›" as typed commands?
|
||||
5a. Makes sense to me.
|
||||
|
||||
## 10. Extra implementation tasks from iteration 2 (titles only, smallest first)
|
||||
|
||||
1. Add a freshness indicator driven by the last successful poll
|
||||
2. Show an offline banner with the data's age when polling fails
|
||||
3. Add a 200 g tick to the remaining meter
|
||||
4. Replace provenance chips with icon + text labels and tooltips
|
||||
5. Make spool rows and holder contents open a detail panel
|
||||
6. Deep-link panel state in the URL (`#spool=`, `#print=`, `&tab=`)
|
||||
7. Add Stock / Weights column sets to Filament
|
||||
8. Add saved views with counts to Filament (All, Low, Loaded, In storage, Tare unknown)
|
||||
9. Add a Weigh tab with a live before/after preview
|
||||
10. Add a weigh session (Save & weigh next)
|
||||
11. Add a Move tab that picks a holder, disabling full ones
|
||||
12. Add "+ Load" on printer holders with a stored-spool picker
|
||||
13. Add a Needs attention tab to Prints with inline suggestion acceptance
|
||||
14. Add nav badges for prints needing attention and disconnected printers
|
||||
15. Add a "Needs you" queue to the Dashboard
|
||||
16. Add a ⌘K command palette over spools, prints, printers and actions
|
||||
17. Add a mobile bottom tab bar and a floating Add button
|
||||
18. Order printer cards by urgency and support `#printer=` scroll-to
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 '<span class="swatch ' + (cls || '') + '" aria-hidden="true">' + colors.map(function (h) { return '<i style="background:' + h + '"></i>'; }).join('') + '</span>'; }
|
||||
function meter(s) {
|
||||
var pct = Math.max(0, Math.min(100, s.rem / s.tot * 100)), tick = Math.min(100, LOW / s.tot * 100);
|
||||
return '<div class="meter' + (low(s) ? ' is-low' : '') + '" role="meter" aria-valuemin="0" aria-valuemax="' + s.tot + '" aria-valuenow="' + s.rem + '" aria-label="' + esc(s.color) + ' remaining"><span style="width:' + pct + '%"></span><i class="tick" style="left:' + tick + '%" title="200 g low-stock line"></i></div>';
|
||||
}
|
||||
|
||||
/* provenance: quiet icon + words */
|
||||
var TSRC = { spool: ['⚖', 'weighed bare', ''], manufacturer: ['◆', 'manufacturer', ''], community: ['◇', 'community', ''], estimated: ['≈', 'estimated', 'warn'] };
|
||||
function tareProv(s) {
|
||||
if (s.tare == null) return '<span class="prov warn"><i>?</i>tare unknown</span>';
|
||||
var t = TSRC[s.tsrc]; return '<span class="prov ' + t[2] + '" title="' + esc(s.tsrc === 'spool' ? 'This reel was weighed empty' : 'From spool type: ' + s.stype) + '"><i>' + t[0] + '</i>' + t[1] + '</span>';
|
||||
}
|
||||
function remProv(s) { return s.remSrc === 'weighed' ? '<span class="prov"><i>⚖</i>weighed' + (s.weighedAt ? ' ' + esc(s.weighedAt) : '') + '</span>' : '<span class="prov"><i>✎</i>typed</span>'; }
|
||||
var WSRC = { typed: ['✎', 'typed', '', 'A measurement someone entered. Survives an identity correction.'], sliced: ['▦', 'sliced', 'info', "Read from this run's 3MF over FTPS."], recalled: ['⎘', 'recalled', 'info', 'Copied from the weight remembered for this gcode path. No longer written.'], unknown: ['?', 'source unknown', 'warn', 'Written before the source was recorded. Neither correction path will move it.'] };
|
||||
function weightProv(p) { if (!p.wsrc) return ''; var w = WSRC[p.wsrc]; return '<span class="prov ' + w[2] + '" title="' + esc(w[3]) + '"><i>' + w[0] + '</i>' + w[1] + '</span>'; }
|
||||
function durCell(p) {
|
||||
if (p.status === 'running') return '<span class="num">' + dur(since(p.start)) + '</span><span class="sub">so far</span>';
|
||||
if (p.status === 'unknown') return '<span class="muted" title="Nobody saw this print end. The only measurable figure is the outage, which is not how long anything printed.">no duration</span><span class="sub">end not seen</span>';
|
||||
if (p.gaps) return '<span class="num strong" style="color:var(--status-warning)" title="The connection dropped ' + (p.gaps === 1 ? 'once' : p.gaps + ' times') + ' during this print. That time is inside the figure and nobody watched it, so it is an upper bound.">≤ ' + dur(p.dur) + '</span><span class="sub prov warn">upper bound · ' + p.gaps + ' gap' + (p.gaps > 1 ? 's' : '') + '</span>';
|
||||
if (p.gaps == null) return '<span class="num">' + dur(p.dur) + '</span><span class="sub" title="Predates gap counting: nobody asked whether the connection dropped.">gaps not counted</span>';
|
||||
return '<span class="num">' + dur(p.dur) + '</span>';
|
||||
}
|
||||
var PST = { running: ['run', 'Running'], finished: ['ok', 'Finished'], failed: ['err', 'Failed'], unknown: ['warn', 'Unknown'] };
|
||||
function printSt(p) { var s = PST[p.status]; return '<span class="st ' + s[0] + '">' + s[1] + '</span>'; }
|
||||
function printerSt(pr) {
|
||||
if (!pr.connected) return '<span class="st warn">' + (pr.lifecycle === 'reconnecting' ? 'Reconnecting' : 'Offline') + '</span>';
|
||||
if (pr.state === 'RUNNING') return '<span class="st run">Printing</span>';
|
||||
if (pr.state === 'PAUSE') return '<span class="st warn">Paused</span>';
|
||||
if (pr.state === 'FAILED') return '<span class="st err">Failed</span>';
|
||||
return '<span class="st idle">Idle</span>';
|
||||
}
|
||||
/* what a print still needs; empty while running */
|
||||
function needs(p) {
|
||||
if (p.status === 'running') return [];
|
||||
var n = [];
|
||||
if (p.grams == null) n.push({ k: 'weight', t: 'filament weight' });
|
||||
if (p.filaments) p.filaments.forEach(function (f) { if (f.spool == null) n.push({ k: 'spool', t: 'spool for color ' + (f.slot + 1), f: f }); });
|
||||
else if (p.spool == null) n.push({ k: 'spool', t: 'spool' });
|
||||
return n;
|
||||
}
|
||||
function printsNeeding() { return PRINTS.filter(function (p) { return needs(p).length && !p.archived; }); }
|
||||
function spoolLinks(p) {
|
||||
if (p.filaments) return p.filaments.map(function (f) { var s = f.spool && spool(f.spool); return s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '">' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '</a>' : '<span class="prov warn"><i>!</i>color ' + (f.slot + 1) + ' unattributed</span>'; }).join('<br>');
|
||||
var s = p.spool && spool(p.spool);
|
||||
return s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '" class="nowrap">' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '</a>' : (p.status === 'running' ? '<span class="muted">—</span>' : '<span class="prov warn"><i>!</i>no spool</span>');
|
||||
}
|
||||
|
||||
/* ── shell ───────────────────────────────────────────────── */
|
||||
var ICON = {
|
||||
dashboard: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="2"/><rect x="14" y="3" width="7" height="5" rx="2"/><rect x="14" y="12" width="7" height="9" rx="2"/><rect x="3" y="16" width="7" height="5" rx="2"/></svg>',
|
||||
filament: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v6M12 15v6"/></svg>',
|
||||
prints: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 20h14M7 16l3-9h4l3 9M9 12h6"/></svg>',
|
||||
printers: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 17h8M9 7h6v5H9z"/></svg>',
|
||||
lists: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/></svg>',
|
||||
labels: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="7.5" r="1.5"/></svg>',
|
||||
access: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0M17 11l2 2 4-4"/></svg>',
|
||||
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></svg>',
|
||||
sun: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>'
|
||||
};
|
||||
var PAGES = [['dashboard', 'Dashboard', '01-dashboard.html'], ['filament', 'Filament', '02-filament.html'], ['prints', 'Prints', '03-prints.html'], ['printers', 'Printers', '04-printers.html']];
|
||||
function badges() {
|
||||
var need = printsNeeding().length, down = PRINTERS.filter(function (p) { return !p.connected; }).length;
|
||||
return {
|
||||
prints: need ? '<span class="count count-warn" title="' + need + ' prints need a spool or weight">' + need + '</span>' : '',
|
||||
printers: down ? '<span class="count count-err" title="' + down + ' printer not connected">' + down + '</span>' : ''
|
||||
};
|
||||
}
|
||||
function shell(page) {
|
||||
var b = badges();
|
||||
var nav = PAGES.map(function (p) { return '<a href="' + p[2] + '"' + (p[0] === page ? ' aria-current="page"' : '') + '>' + ICON[p[0]] + '<span>' + p[1] + '</span>' + (b[p[0]] || '') + '</a>'; }).join('');
|
||||
var unl = SPOOLS.filter(function (s) { return !s.label; }).length;
|
||||
var rail = '<nav class="rail nm-raised" aria-label="Main">' +
|
||||
'<a class="wordmark" href="01-dashboard.html" style="text-decoration:none;color:inherit"><span class="wordmark-mark" aria-hidden="true">X</span>Extrudex</a>' +
|
||||
'<div class="nav nav-group">' + nav + '</div>' +
|
||||
'<div class="nav nav-group"><span class="nav-label">Manage</span>' +
|
||||
'<a href="#" data-soon="Lists">' + ICON.lists + '<span>Lists</span></a>' +
|
||||
'<a href="#" data-soon="Labels">' + ICON.labels + '<span>Labels</span><span class="count" style="color:var(--text-secondary)" title="' + unl + ' spools without a printed label (sample)">' + unl + '</span></a>' +
|
||||
'<a href="#" data-soon="Access">' + ICON.access + '<span>Access</span><span class="sub" style="margin-left:auto">admin</span></a></div>' +
|
||||
'<div class="rail-foot"><div class="seg" role="group" aria-label="Color theme"><button type="button" data-set-theme="light">Light</button><button type="button" data-set-theme="dark">Dark</button></div>' +
|
||||
'<span class="small muted">Signed in · operator</span></div></nav>';
|
||||
var tabbar = '<nav class="tabbar nm-raised" aria-label="Main">' + PAGES.map(function (p) { return '<a href="' + p[2] + '"' + (p[0] === page ? ' aria-current="page"' : '') + '>' + ICON[p[0]] + (b[p[0]] || '') + '<span>' + p[1] + '</span></a>'; }).join('') + '</nav>';
|
||||
var top = '<div class="topbar"><button class="search-trigger" type="button" data-palette>' + ICON.search + '<span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"><span class="long">Search spools, prints, printers…</span><span class="short">Search…</span></span><span class="kbd long">⌘K</span></button>' +
|
||||
'<span class="fresh" id="fresh" role="status"><span class="dot" aria-hidden="true"></span><span id="fresh-t">Live · updated 3s ago</span></span>' +
|
||||
'<button class="btn btn-icon btn-sm" type="button" data-toggle-theme aria-label="Switch color theme">' + ICON.sun + '</button></div>';
|
||||
var mock = '<div class="mock"><button type="button" data-menu="mockmenu" aria-expanded="false">Mockup<span class="long"> · v3 iteration 2</span></button><div class="menu" id="mockmenu" role="dialog" aria-label="About this mockup">' +
|
||||
'<p><b>Sample data</b>, based on the real inventory. Nothing is wired to the API.</p>' +
|
||||
'<p><span class="prov info"><i>◈</i>feature</span> fields exist only on the printer-connections branch. <span class="prov warn"><i>⚠</i>API gap</span> means stored but not served.</p><hr>' +
|
||||
'<button type="button" data-demo="loading">Replay first load (skeletons)</button>' +
|
||||
'<button type="button" data-demo="offline">Simulate polling failure</button>' +
|
||||
'<button type="button" data-demo="online">Back to live</button><hr>' +
|
||||
'<a href="index.html">Flow map of all screens →</a></div></div>';
|
||||
var drawer = '<div class="drawer-scrim" data-close-drawer></div><aside class="drawer nm-dialog" id="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title" hidden></aside>';
|
||||
var pal = '<div class="pal-scrim" id="pal" role="presentation"><div class="pal nm-dialog" role="dialog" aria-modal="true" aria-label="Search and commands"><div class="pal-input">' + ICON.search +
|
||||
'<input id="pal-q" role="combobox" aria-expanded="true" aria-controls="pal-list" aria-autocomplete="list" placeholder="Type a spool, #id, job, printer or action…" autocomplete="off"><span class="kbd">esc</span></div>' +
|
||||
'<div class="pal-list" id="pal-list" role="listbox"></div><div class="pal-foot"><span><span class="kbd">↑</span> <span class="kbd">↓</span> move</span><span><span class="kbd">↵</span> open</span><span><span class="kbd">/</span> or <span class="kbd">⌘K</span> anywhere</span></div></div></div>';
|
||||
return { rail: rail, tabbar: tabbar, top: top, rest: mock + drawer + pal + '<div class="toasts" id="toasts" aria-live="polite"></div>' };
|
||||
}
|
||||
|
||||
/* ── theme ───────────────────────────────────────────────── */
|
||||
function setTheme(v) {
|
||||
document.documentElement.setAttribute('data-theme', v);
|
||||
$$('[data-set-theme]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.getAttribute('data-set-theme') === v)); });
|
||||
try { localStorage.setItem('extrudex-v3-theme', v); } catch (e) {}
|
||||
}
|
||||
|
||||
/* ── toasts ──────────────────────────────────────────────── */
|
||||
function updateBadges() {
|
||||
var b = badges();
|
||||
$$('.nav a, .tabbar a').forEach(function (a) {
|
||||
var key = { '03-prints.html': 'prints', '04-printers.html': 'printers' }[a.getAttribute('href')]; if (!key) return;
|
||||
var old = a.querySelector('.count'); if (old) old.parentNode.removeChild(old);
|
||||
if (b[key]) a.querySelector('span').insertAdjacentHTML(a.closest('.tabbar') ? 'beforebegin' : 'afterend', b[key]);
|
||||
});
|
||||
}
|
||||
function toast(html, kind) {
|
||||
setTimeout(updateBadges, 0);
|
||||
var t = document.createElement('div'); t.className = 'toast ' + (kind || ''); t.setAttribute('role', 'status');
|
||||
t.innerHTML = '<span>' + html + '</span><button class="btn btn-sm btn-icon" type="button" aria-label="Dismiss">✕</button>';
|
||||
$('#toasts').appendChild(t);
|
||||
var kill = function () { if (t.parentNode) t.parentNode.removeChild(t); };
|
||||
t.querySelector('button').onclick = kill; setTimeout(kill, 6000);
|
||||
}
|
||||
|
||||
/* ── drawer ──────────────────────────────────────────────── */
|
||||
var D = { kind: null, id: null, tab: 'overview', state: 'normal', list: null, lastFocus: null, dirty: false };
|
||||
function openDrawer(kind, id, tab, push) {
|
||||
D.kind = kind; D.id = id; D.tab = tab || 'overview'; D.state = 'normal'; D.dirty = false;
|
||||
if (!document.body.classList.contains('drawer-open')) D.lastFocus = document.activeElement;
|
||||
renderDrawer();
|
||||
var el = $('#drawer'); el.hidden = false;
|
||||
requestAnimationFrame(function () { document.body.classList.add('drawer-open'); var f = el.querySelector('[autofocus]') || el.querySelector('h2'); if (f) { f.setAttribute('tabindex', f.tagName === 'H2' ? '-1' : f.getAttribute('tabindex')); f.focus(); } });
|
||||
var h = '#' + kind + '=' + id + (D.tab !== 'overview' ? '&tab=' + D.tab : '');
|
||||
if (push !== false && location.hash !== h) history.pushState({ d: 1 }, '', h);
|
||||
$$('[data-row-spool],[data-row-print]').forEach(function (r) { r.classList.toggle('is-selected', (kind === 'spool' && +r.getAttribute('data-row-spool') === id) || (kind === 'print' && +r.getAttribute('data-row-print') === id)); });
|
||||
}
|
||||
function closeDrawer(push) {
|
||||
if (D.dirty && !confirmDiscard()) return;
|
||||
document.body.classList.remove('drawer-open'); D.kind = null;
|
||||
setTimeout(function () { if (!D.kind) $('#drawer').hidden = true; }, 240);
|
||||
if (push !== false && location.hash) history.pushState({}, '', location.pathname + location.search);
|
||||
$$('.is-selected').forEach(function (r) { r.classList.remove('is-selected'); });
|
||||
if (D.lastFocus && D.lastFocus.focus) D.lastFocus.focus();
|
||||
}
|
||||
function confirmDiscard() {
|
||||
var bar = $('#discard-bar'); if (bar) { bar.hidden = false; bar.querySelector('button').focus(); return false; }
|
||||
return true;
|
||||
}
|
||||
function renderDrawer() {
|
||||
var el = $('#drawer');
|
||||
if (D.kind === 'spool') el.innerHTML = spoolDrawer(spool(D.id));
|
||||
else if (D.kind === 'new') el.innerHTML = editDrawer(null);
|
||||
else if (D.kind === 'print') el.innerHTML = printDrawer(print(D.id));
|
||||
else if (D.kind === 'load') el.innerHTML = loadDrawer(D.id);
|
||||
if (window.PAGE && PAGE.onDrawer) PAGE.onDrawer();
|
||||
}
|
||||
function head(icon, title, sub, extra) {
|
||||
var list = D.list || [], i = list.indexOf(D.id);
|
||||
var nav = (D.kind === 'spool' && i >= 0 && list.length > 1) ? '<div class="drawer-nav"><button class="btn btn-sm btn-icon" type="button" data-step="-1" aria-label="Previous spool in list" ' + (i === 0 ? 'disabled' : '') + '>↑</button><button class="btn btn-sm btn-icon" type="button" data-step="1" aria-label="Next spool in list" ' + (i === list.length - 1 ? 'disabled' : '') + '>↓</button></div>' : '';
|
||||
return '<div class="drawer-head">' + icon + '<div style="flex:1;min-width:0"><h2 id="drawer-title">' + title + '</h2><span class="sub">' + sub + '</span>' + (extra || '') + '</div>' + nav + '<button class="btn btn-sm btn-icon" type="button" data-close-drawer aria-label="Close panel (Esc)">✕</button></div>';
|
||||
}
|
||||
function tabs(list) {
|
||||
return '<div class="drawer-tabs"><div class="tabs" role="tablist" aria-label="Spool panel">' + list.map(function (t) { return '<button type="button" role="tab" data-tab="' + t[0] + '" aria-selected="' + (D.tab === t[0]) + '">' + t[1] + '</button>'; }).join('') + '</div></div>';
|
||||
}
|
||||
|
||||
function spoolDrawer(s) {
|
||||
var w = where(s);
|
||||
var h = head(sw(s.c, '" style="width:44px;height:44px'), esc(s.color) + ' <span class="muted" style="font-weight:400">#' + s.id + '</span>', esc(fullName(s)) + ' · ' + esc(w.text));
|
||||
var t = tabs([['overview', 'Overview'], ['weigh', 'Weigh'], ['move', w.loaded ? 'Move' : 'Load / move'], ['edit', 'Edit']]);
|
||||
var body = '', foot = '';
|
||||
if (D.tab === 'overview') {
|
||||
var used = PRINTS.filter(function (p) { return p.spool === s.id || (p.filaments || []).some(function (f) { return f.spool === s.id; }); });
|
||||
var gr = gross(s);
|
||||
body = '<div class="nm-inset panel"><div class="row" style="align-items:baseline"><span style="font-size:34px;font-weight:800;line-height:1" class="num' + (low(s) ? '" style="color:var(--status-warning)' : '') + '">' + Math.round(s.rem).toLocaleString('en-US') + '<span style="font-size:15px"> g</span></span><span class="muted">of ' + g(s.tot) + ' net</span><span class="spacer"></span>' + remProv(s) + '</div>' +
|
||||
'<div style="margin-top:12px">' + meter(s) + '</div>' + (low(s) ? '<p class="small" style="margin:10px 0 0;color:var(--status-warning)"><b>⚠ Below the 200 g line.</b> Reorder or finish it on a small job.</p>' : '') + '</div>' +
|
||||
'<div class="panel nm-raised-sm"><h3>On the scale</h3>' + (gr == null
|
||||
? '<p class="small" style="margin:0">Gross is <b>unknown</b> because no empty-spool weight is known. It is never treated as 0.</p><div class="row" style="margin-top:12px"><button class="btn btn-sm" type="button" data-tab="weigh">Set a tare</button></div>'
|
||||
: '<dl class="kv"><dt>Empty spool (tare)</dt><dd>' + g(s.tare) + ' ' + tareProv(s) + '</dd><dt>+ Filament (net)</dt><dd>' + g(s.rem) + '</dd><dt class="strong" style="color:var(--text-primary)">= Gross, on a scale</dt><dd class="strong">' + g(gr) + '</dd></dl>') + '</div>' +
|
||||
'<dl class="kv"><dt>Location</dt><dd>' + (w.loaded ? '<a href="04-printers.html#printer=' + w.printer.id + '">' + esc(w.text) + '</a>' : esc(w.text)) + ' · <button class="btn btn-sm" type="button" data-tab="move" style="min-height:26px;padding:2px 8px">Move</button></dd>' +
|
||||
'<dt>Value left</dt><dd>' + usd(value(s)) + ' <span class="sub" style="display:inline">at ' + usd(s.kg) + '/kg</span></dd>' +
|
||||
'<dt>Spool type</dt><dd>' + (s.stype ? esc(s.stype) : '<span class="muted">none chosen</span>') + '</dd>' +
|
||||
'<dt>Label</dt><dd>' + (s.label ? 'printed' : '<span class="prov warn"><i>!</i>not printed</span> <button class="btn btn-sm" type="button" data-fake="Label PDF generated for #' + s.id + '" style="min-height:26px;padding:2px 8px">Print label</button>') + '</dd>' +
|
||||
'<dt>Record</dt><dd class="muted">version ' + s.v + '</dd></dl>' +
|
||||
'<div><h3 class="section" style="margin-bottom:8px">Prints from this spool <span class="muted">· ' + used.length + '</span></h3>' + (used.length ? '<div class="stack" style="gap:6px">' + used.map(function (p) { var f = (p.filaments || []).filter(function (f) { return f.spool === s.id; })[0]; return '<a class="row nm-raised-sm" style="padding:8px 12px;text-decoration:none;color:inherit;gap:10px" href="#print=' + p.id + '" data-print="' + p.id + '">' + printSt(p) + '<span class="strong" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis">' + esc(p.job) + '</span><span class="num">' + g(f ? f.grams : p.grams) + '</span><span class="sub" style="display:inline">' + when(p.start) + '</span></a>'; }).join('') + '</div>' : '<p class="small muted" style="margin:0">No prints drew from this spool yet.</p>') + '</div>' +
|
||||
'<p class="small muted" style="margin:0"><span class="prov warn"><i>⚠</i>API gap</span> Location history is stored in <code>spool_location_event</code> but there is no endpoint to read it.</p>';
|
||||
foot = '<button class="btn btn-danger btn-sm" type="button" data-delete="' + s.id + '" style="margin-right:auto">Delete…</button><button class="btn" type="button" data-tab="edit">Edit</button><button class="btn btn-primary" type="button" data-tab="weigh" ' + (s.tare == null ? 'title="Needs a tare first"' : '') + '>Weigh</button>';
|
||||
}
|
||||
if (D.tab === 'weigh') {
|
||||
if (s.tare == null) {
|
||||
body = '<div class="banner"><b>?</b><span>This spool has <b>no empty-spool weight</b>, so a scale reading can\'t be turned into filament. Set one first. It is never assumed to be 0.</span></div>' +
|
||||
'<label class="field stack" style="gap:6px"><span class="strong small">Pick a spool type</span><select class="select" autofocus><option>Choose…</option><option>Generic — 1 kg spool (assumed) · 180 g · estimated</option><option>Creality — Hyper cardboard · 176 g · community</option><option>Creality — legacy plastic · 138 g · community</option></select><span class="sub">Fastest. The figure\'s trust level is shown next to it.</span></label>' +
|
||||
'<div class="row"><hr style="flex:1;border:0;border-top:1px solid var(--border-hairline)"><span class="sub">or, more accurate</span><hr style="flex:1;border:0;border-top:1px solid var(--border-hairline)"></div>' +
|
||||
'<label class="field stack" style="gap:6px"><span class="strong small">Weigh the empty spool</span><input class="input num" inputmode="numeric" placeholder="e.g. 210 g"><span class="sub">Only possible once the spool is used up, or from an identical empty.</span></label>';
|
||||
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" data-fake="Tare saved for #' + s.id + '. Weigh-in unlocked.">Save tare</button>';
|
||||
} else {
|
||||
body = '<div class="panel nm-inset"><label class="stack" style="gap:8px"><span class="strong">Whole spool on the scale</span><input class="input num" id="scale" inputmode="numeric" autofocus placeholder="full reads ' + g(s.tare + s.tot) + '" style="font-size:22px;min-height:56px"><span class="sub">Remaining = reading − ' + g(s.tare) + ' tare (' + tareProv(s) + ')</span></label></div>' +
|
||||
'<div class="panel nm-raised-sm" id="weigh-out" aria-live="polite"><h3>Result</h3><div class="row"><span class="muted">Now</span><b class="num">' + g(s.rem) + '</b><span class="muted">→</span><b class="num" id="w-after">—</b><span class="spacer"></span><span id="w-diff" class="num"></span></div><p class="small muted" id="w-note" style="margin:8px 0 0">Type the reading and the result appears here before you save.</p></div>';
|
||||
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" id="w-save" disabled>Save weigh-in</button>' + (D.list && D.list.indexOf(s.id) < D.list.length - 1 ? '<button class="btn" type="button" id="w-next" disabled>Save & weigh next ↓</button>' : '');
|
||||
}
|
||||
}
|
||||
if (D.tab === 'move') {
|
||||
var dests = PRINTERS.map(function (pr) {
|
||||
return '<div class="stack" style="gap:8px"><div class="row"><b class="small">' + esc(pr.name) + '</b>' + (pr.connected ? '' : '<span class="prov warn"><i>⚡</i>disconnected (loading still recorded)</span>') + '</div><div class="row" style="gap:8px;align-items:stretch">' +
|
||||
pr.containers.map(function (c) { var n = contents(c.id).length, full = n >= c.cap && s.cont !== c.id, here = s.cont === c.id; return '<button type="button" class="slot" style="min-height:64px;flex:1" data-dest="' + c.id + '" ' + (full ? 'disabled title="Full: unload a spool first"' : '') + ' aria-pressed="' + here + '"><span class="sn">' + esc(c.label) + '</span><span class="g">' + n + ' / ' + c.cap + (here ? ' · here now' : full ? ' · full' : '') + '</span></button>'; }).join('') + '</div></div>';
|
||||
}).join('');
|
||||
body = '<p class="small muted" style="margin:0">Where is the spool going? Full holders are disabled. Extrudex records which holder a spool is in, not its tray position.</p>' +
|
||||
'<div class="stack" style="gap:8px"><b class="small">Storage</b><div class="row" style="gap:8px;align-items:stretch"><button type="button" class="slot" style="min-height:64px;flex:1" data-dest="storage" aria-pressed="' + !s.cont + '"><span class="sn">Shelf / storage</span><span class="g">' + (s.cont ? 'unload' : 'here now') + '</span></button></div>' +
|
||||
'<label class="stack" style="gap:6px"><span class="sub">Where in storage (optional)</span><input class="input" value="' + esc(s.loc || '') + '" placeholder="Shelf A2, Dry box 1…"></label></div>' + dests;
|
||||
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" id="m-save" disabled>Move spool</button>';
|
||||
}
|
||||
if (D.tab === 'edit') { return editDrawer(s); }
|
||||
return h + t + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
|
||||
}
|
||||
|
||||
function editDrawer(s) {
|
||||
var isNew = !s; s = s || { id: null, c: ['#888888'], color: '', brand: 'Sunlu', speed: null, mat: 'PLA', type: null, fin: 'Basic', rem: 1000, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', kg: 0, loc: '', v: null };
|
||||
var st = D.state;
|
||||
var h = isNew ? head('<span class="swatch" style="width:44px;height:44px"><i style="background:#888"></i></span>', 'Add spool', 'New spools carry no version') : head(sw(s.c, '" style="width:44px;height:44px'), 'Edit ' + esc(s.color) + ' <span class="muted" style="font-weight:400">#' + s.id + '</span>', 'Opened 09:32 at version ' + (st === 'conflict' ? s.v - 1 : s.v));
|
||||
var t = isNew ? '' : tabs([['overview', 'Overview'], ['weigh', 'Weigh'], ['move', where(s).loaded ? 'Move' : 'Load / move'], ['edit', 'Edit']]);
|
||||
function sel(label, val, req, opts) { return '<label class="stack" style="gap:6px"><span class="small strong">' + label + ' ' + (req ? '<span class="sub" style="display:inline;color:var(--text-accent)">required</span>' : '<span class="sub" style="display:inline">optional</span>') + '</span><select class="select"><option>' + (val || 'None') + '</option>' + (opts || '') + '</select></label>'; }
|
||||
var demo = '<div class="row small" style="gap:8px"><span class="muted">Demo state</span><div class="tabs" role="group" aria-label="Demo state">' + [['normal', 'Normal'], ['errors', '400 errors'], ['conflict', '409 conflict']].map(function (x) { return '<button type="button" data-state="' + x[0] + '" aria-selected="' + (st === x[0]) + '">' + x[1] + '</button>'; }).join('') + '</div></div>';
|
||||
var conflict = st === 'conflict' ? '<div class="nm-inset" role="alert" style="padding:16px;border:2px solid var(--status-warning);background:var(--wash-warning);border-radius:var(--radius-lg)"><b style="color:var(--status-warning)">⚠ This spool changed since you opened it. Nothing was saved.</b><p class="small" style="margin:8px 0">Someone weighed it at 09:39 (version ' + (s.v - 1) + ' → ' + s.v + '). Saving would have put the old remaining weight back.</p>' +
|
||||
'<table class="tbl" style="font-size:12px"><thead><tr><th>Field</th><th class="r">You opened</th><th class="r">Now</th><th class="r">Your edit</th></tr></thead><tbody><tr><td>Remaining</td><td class="r">860 g</td><td class="r strong" style="color:var(--status-warning)">' + g(s.rem) + ' ⚖</td><td class="r muted">—</td></tr><tr><td>Location</td><td class="r">Shelf A3</td><td class="r">Shelf A3</td><td class="r strong accent">' + esc(where(s).short) + '</td></tr></tbody></table>' +
|
||||
'<div class="row" style="margin-top:12px"><button class="btn btn-primary btn-sm" type="button" data-fake="Reloaded version ' + s.v + ' and reapplied your location change. Review, then save.">Reload & reapply my change</button><button class="btn btn-sm" type="button" data-state="normal">Reload, discard mine</button></div></div>' : '';
|
||||
var errsum = st === 'errors' ? '<div role="alert" style="padding:14px 16px;border:2px solid var(--status-error);background:var(--wash-error);border-radius:var(--radius-lg)"><b style="color:var(--status-error)">⚠ 2 fields need fixing. Nothing was saved.</b><ul class="small" style="margin:6px 0 0;padding-left:18px"><li><a href="#e-rem">Remaining is more than the spool holds</a></li><li><a href="#e-name">Color name is empty</a></li></ul></div>' : '';
|
||||
var name = '<div class="nm-inset" style="padding:12px 14px;border-radius:var(--radius-md)"><span class="sub">Reads as</span><div class="strong" style="font-size:17px">' + esc(fullName(s)) + '</div><span class="sub">brand · <i>speed</i> · material · <i>type</i> · finish (italic = optional)</span></div>';
|
||||
var body = demo + conflict + errsum + name +
|
||||
'<div class="fr2">' + sel('Brand', s.brand, 1) + sel('Print speed', s.speed, 0, '<option>Rapid</option><option>High Speed</option>') + sel('Material', s.mat, 1) + sel('Filament type', s.type, 0, '<option>CF</option><option>+ 2.0</option>') + sel('Finish', s.fin, 1) + '</div>' +
|
||||
'<div class="fr2"><div class="stack' + (st === 'errors' ? ' field has-error' : '') + '" id="e-name" style="gap:6px"><label class="small strong" for="f-name">Color name <span class="sub" style="display:inline;color:var(--text-accent)">required</span></label><input class="input" id="f-name" value="' + (st === 'errors' ? '' : esc(s.color)) + '" ' + (st === 'errors' ? 'aria-invalid="true" aria-describedby="e-name-m"' : '') + ' placeholder="e.g. Charcoal">' + (st === 'errors' ? '<p class="field-error" id="e-name-m" style="margin:0">Give the color a name, even "Unknown". <span class="sub" style="display:inline">server: <code>color_name is required</code></span></p>' : '') + '</div>' +
|
||||
'<label class="stack" style="gap:6px"><span class="small strong">Colors <span class="sub" style="display:inline">1–3</span></span><span class="row" style="gap:8px">' + sw(s.c) + '<input class="input" value="' + s.c[0] + '" style="max-width:110px"><button class="btn btn-sm" type="button">+ color</button></span></label></div>' +
|
||||
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Total <span class="sub" style="display:inline">net, as sold</span></span><input class="input num" value="' + g(s.tot) + '"></label>' +
|
||||
'<div class="stack' + (st === 'errors' ? ' field has-error' : '') + '" id="e-rem" style="gap:6px"><label class="small strong" for="f-rem">Remaining <span class="sub" style="display:inline">net</span></label><input class="input num" id="f-rem" value="' + (st === 'errors' ? '1,040 g' : g(s.rem)) + '" ' + (st === 'errors' ? 'aria-invalid="true" aria-describedby="e-rem-m"' : '') + '>' + (st === 'errors' ? '<p class="field-error" id="e-rem-m" style="margin:0">1,040 g is more than this spool holds (' + g(s.tot) + '). <span class="sub" style="display:inline">server: <code>remaining_grams cannot exceed total_grams</code></span></p>' : '<span class="sub">To measure it, use the <b>Weigh</b> tab instead.</span>') + '</div></div>' +
|
||||
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Spool type <span class="sub" style="display:inline">supplies the tare</span></span><select class="select"><option>' + esc(s.stype || 'None chosen') + '</option></select></label>' +
|
||||
'<label class="stack" style="gap:6px"><span class="small strong">Empty spool (tare)</span><input class="input num" value="' + (s.tare == null ? '' : g(s.tare)) + '" placeholder="unknown"><span class="sub">' + (s.tare == null ? '<span class="prov warn"><i>?</i>unknown, never 0</span>' : tareProv(s)) + '</span></label></div>' +
|
||||
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Cost per kg</span><input class="input num" value="' + usd(s.kg) + '"></label><label class="stack" style="gap:6px"><span class="small strong">Storage location <span class="sub" style="display:inline">optional</span></span><input class="input" value="' + esc(s.loc || '') + '"></label></div>' +
|
||||
'<div class="banner" id="discard-bar" hidden><span style="flex:1">Discard unsaved changes?</span><button class="btn btn-sm" type="button" data-discard>Discard</button><button class="btn btn-sm" type="button" data-keep>Keep editing</button></div>';
|
||||
var foot = (st === 'conflict' ? '<span class="small muted" style="margin-right:auto">Save is off until you choose a version.</span>' : '') + '<button class="btn" type="button" data-' + (isNew ? 'close-drawer' : 'tab="overview"') + '>Cancel</button><button class="btn btn-primary" type="button" ' + (st === 'conflict' ? 'disabled' : 'data-save') + '>' + (isNew ? 'Add spool' : 'Save changes') + '</button>';
|
||||
return h + t + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
|
||||
}
|
||||
|
||||
function printDrawer(p) {
|
||||
var pr = printer(p.printer), n = needs(p);
|
||||
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.prints + '</span>', esc(p.job), printSt(p) + ' · ' + esc(pr.name) + ' · print #' + p.id);
|
||||
var fix = '';
|
||||
if (n.length) {
|
||||
fix = '<div class="nm-inset panel" style="border:1px solid var(--status-warning)"><h3 style="color:var(--status-warning)">Needs ' + n.map(function (x) { return x.t; }).join(' + ') + '</h3>' + fixers(p) + '</div>';
|
||||
}
|
||||
var body = fix + '<dl class="kv"><dt>Printer</dt><dd><a href="04-printers.html#printer=' + pr.id + '">' + esc(pr.name) + '</a></dd><dt>Started</dt><dd>' + when(p.start) + '</dd><dt>Ended</dt><dd>' + (p.end ? when(p.end) : p.status === 'running' ? '<span class="muted">still running</span>' : '—') + '</dd>' +
|
||||
'<dt>Duration</dt><dd>' + durCell(p) + '</dd>' +
|
||||
'<dt>Job key</dt><dd><code>' + esc(p.key) + '</code><span class="sub">The identity. Two jobs can share a name, never a path.</span></dd>' +
|
||||
'<dt>Filament</dt><dd>' + (p.grams == null ? '<span class="muted">not known yet</span>' : '<b class="num">' + g(p.grams) + '</b> ' + weightProv(p) + (p.status === 'failed' ? '<span class="sub">Failed prints record the full job weight. Correct by hand if needed.</span>' : '')) + '</dd>' +
|
||||
'<dt>Spool</dt><dd>' + spoolLinks(p) + '</dd><dt>Cost</dt><dd>' + (p.cost == null ? '<span class="muted">pending: needs weight and spool</span>' : usd(p.cost) + (p.filaments && p.filaments.some(function (f) { return f.spool == null; }) ? ' <span class="sub" style="display:inline">so far</span>' : '')) + '</dd>' +
|
||||
'<dt>Use</dt><dd><div class="tabs" role="group" aria-label="Use type"><button type="button" aria-selected="' + (p.use === 'business') + '">Business</button><button type="button" aria-selected="' + (p.use === 'personal') + '">Personal</button></div>' + (p.use == null ? '<span class="sub">Not recorded</span>' : '') + '</dd></dl>' +
|
||||
(p.filaments ? '<div><h3 class="section">Colors</h3><table class="tbl"><thead><tr><th>#</th><th>Color</th><th>Spool</th><th class="r">Grams</th><th class="r">Cost</th></tr></thead><tbody>' + p.filaments.map(function (f) { var s = f.spool && spool(f.spool); return '<tr><td>' + (f.slot + 1) + '</td><td><span class="swatch swatch-sm"><i style="background:' + f.hex + '"></i></span> <code>' + f.hex + '</code></td><td>' + (s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '">' + esc(short(s)) + '</a>' : '<span class="prov warn"><i>!</i>unattributed</span>') + '</td><td class="r">' + g(f.grams) + '</td><td class="r">' + usd(f.cost) + '</td></tr>'; }).join('') + '</tbody></table></div>' : '');
|
||||
var foot = p.status === 'running' ? '<span class="small muted">Name and weight are captured when the print ends.</span>' : '<button class="btn btn-sm" type="button" data-fake="Archived ' + esc(p.job) + '. Accounting unchanged." style="margin-right:auto">Archive</button><button class="btn" type="button" data-close-drawer>Done</button>';
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
|
||||
}
|
||||
function fixers(p) {
|
||||
var out = '';
|
||||
if (p.grams == null) out += '<label class="stack" style="gap:6px;margin-bottom:12px"><span class="small strong">Filament used</span><span class="row" style="gap:8px"><input class="input num" style="max-width:140px" placeholder="grams" inputmode="numeric" autofocus><button class="btn btn-sm" type="button" data-fake="Saved ' + esc(p.job) + ' weight · remembered for ' + esc(p.key) + '">Save weight</button></span><span class="sub">From the slicer. Remembered for <code>' + esc(p.key) + '</code> so later runs fill in by themselves.</span></label>';
|
||||
var slots = p.filaments ? p.filaments.filter(function (f) { return f.spool == null; }) : (p.spool == null ? [{ suggested: p.suggested, slot: null }] : []);
|
||||
slots.forEach(function (f) {
|
||||
var sug = f.suggested && spool(f.suggested);
|
||||
out += '<div class="stack" style="gap:6px;margin-bottom:8px"><span class="small strong">Spool' + (f.slot != null ? ' for color ' + (f.slot + 1) + ' <span class="swatch swatch-sm"><i style="background:' + f.hex + '"></i></span>' : '') + '</span>' +
|
||||
(sug ? '<div class="row nm-raised-sm" style="padding:8px 12px;gap:10px">' + sw(sug.c, 'swatch-sm') + '<span style="flex:1"><b>' + esc(short(sug)) + '</b><span class="sub">Suggested: same color, loaded in ' + esc(where(sug).short) + '. Not charged until you confirm.</span></span><button class="btn btn-sm btn-primary" type="button" data-fake="Attributed to ' + esc(short(sug)) + ' · ' + g(f.grams || p.grams) + ' deducted">Use this</button></div>' : '') +
|
||||
'<select class="select"><option>' + (sug ? 'Or pick another spool…' : 'Pick a spool…') + '</option>' + SPOOLS.slice(0, 8).map(function (s) { return '<option>' + g(s.rem) + ' · ' + esc(short(s)) + ' · ' + esc(s.mat) + '</option>'; }).join('') + '</select></div>';
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function loadDrawer(cid) {
|
||||
var c = container(cid), inIt = contents(cid);
|
||||
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.printers + '</span>', 'Load into ' + esc(c.c.label), esc(c.printer.name) + ' · ' + inIt.length + ' of ' + c.c.cap + ' in use');
|
||||
var cands = SPOOLS.filter(function (s) { return !s.cont; });
|
||||
var body = '<label class="stack" style="gap:6px"><span class="small strong">Find a spool in storage</span><input class="input" id="load-q" placeholder="Brand, color, material…" autofocus></label><div class="stack" style="gap:6px" id="load-list">' +
|
||||
cands.map(function (s) { return '<button type="button" class="slot" style="min-height:0;flex-direction:row;align-items:center;gap:12px;width:100%" data-load="' + s.id + '" aria-pressed="false" data-hay="' + esc((fullName(s) + ' ' + s.color + ' ' + (s.loc || '')).toLowerCase()) + '">' + sw(s.c) + '<span style="flex:1;min-width:0"><span class="nm" style="display:block">' + esc(s.color) + ' · ' + esc(s.brand) + '</span><span class="sub">' + esc(fullName(s)) + ' · ' + esc(s.loc || 'Storage') + '</span></span><span class="g num' + (low(s) ? '" style="color:var(--status-warning)' : '') + '">' + g(s.rem) + '</span></button>'; }).join('') + '</div>' +
|
||||
'<p class="small muted" style="margin:0">Scanning the holder\'s QR label does the same thing from a phone (existing <code>/c/:id</code> scan session).</p>';
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer>Cancel</button><button class="btn btn-primary" type="button" id="l-save" disabled>Load spool</button></div>';
|
||||
}
|
||||
|
||||
/* ── palette ─────────────────────────────────────────────── */
|
||||
var P = { items: [], sel: 0 };
|
||||
function palItems(q) {
|
||||
q = q.trim().toLowerCase();
|
||||
var acts = [
|
||||
{ sec: 'Actions', ico: '+', t: 'Add spool', m: 'new', run: function () { openDrawer('new', 0, 'edit'); } },
|
||||
{ sec: 'Actions', ico: '⚖', t: 'Weigh a spool…', m: 'type its name', run: function () { $('#pal-q').value = 'weigh '; palRender(); }, keep: 1 },
|
||||
{ sec: 'Actions', ico: '!', t: 'Prints needing attention', m: printsNeeding().length + ' open', href: '03-prints.html?tab=needs' },
|
||||
{ sec: 'Actions', ico: '◐', t: 'Switch color theme', m: 'light / dark', run: function () { setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'); } }
|
||||
].concat(PAGES.map(function (p) { return { sec: 'Go to', ico: '→', t: p[1], m: 'page', href: p[2] }; }));
|
||||
var weigh = /^weigh\s*/.test(q); if (weigh) q = q.replace(/^weigh\s*/, '');
|
||||
var sp = SPOOLS.map(function (s) { return { sec: weigh ? 'Weigh which spool?' : 'Spools', html: sw(s.c, 'swatch-sm'), t: s.color + ' · ' + fullName(s), m: '#' + s.id + ' · ' + g(s.rem) + ' · ' + where(s).short, hay: ('#' + s.id + ' ' + s.id + ' ' + fullName(s) + ' ' + s.color + ' ' + where(s).text).toLowerCase(), run: function () { go('spool', s.id, weigh ? 'weigh' : 'overview'); } }; });
|
||||
var pr = PRINTS.map(function (p) { return { sec: 'Prints', ico: '▤', t: p.job, m: PST[p.status][1] + ' · ' + when(p.start), hay: (p.job + ' ' + p.key + ' ' + printer(p.printer).name).toLowerCase(), run: function () { go('print', p.id); } }; });
|
||||
var pt = PRINTERS.map(function (p) { return { sec: 'Printers', ico: '▣', t: p.name, m: p.connected ? (p.state === 'RUNNING' ? 'printing' : 'idle') : 'reconnecting', hay: p.name.toLowerCase(), href: '04-printers.html#printer=' + p.id }; });
|
||||
if (weigh) return sp.filter(function (i) { return !q || i.hay.indexOf(q) >= 0; }).slice(0, 12);
|
||||
if (!q) return acts.concat(sp.filter(function (i, k) { return k < 3; }).map(function (i) { i.sec = 'Recent spools'; return i; }));
|
||||
var match = function (i) { return (i.hay || i.t.toLowerCase()).indexOf(q) >= 0; };
|
||||
return acts.filter(match).concat(sp.filter(match).slice(0, 8), pr.filter(match).slice(0, 5), pt.filter(match));
|
||||
}
|
||||
function go(kind, id, tab) {
|
||||
var pageFor = { spool: null, print: null };
|
||||
if (window.PAGE && PAGE.name) openDrawer(kind, id, tab);
|
||||
}
|
||||
function palRender() {
|
||||
P.items = palItems($('#pal-q').value); if (P.sel >= P.items.length) P.sel = 0;
|
||||
var last = '', html = '';
|
||||
P.items.forEach(function (it, i) {
|
||||
if (it.sec !== last) { html += '<div class="pal-sec">' + esc(it.sec) + '</div>'; last = it.sec; }
|
||||
html += '<div class="pal-item" role="option" id="pi' + i + '" data-i="' + i + '" aria-selected="' + (i === P.sel) + '">' + (it.html || '<span style="width:18px;text-align:center" aria-hidden="true">' + it.ico + '</span>') + '<span>' + esc(it.t) + '</span><span class="meta">' + esc(it.m || '') + '</span></div>';
|
||||
});
|
||||
$('#pal-list').innerHTML = html || '<div class="empty"><h3>No matches</h3><p>Try a color, brand, <code>#47</code>, a job name or a printer.</p></div>';
|
||||
$('#pal-q').setAttribute('aria-activedescendant', P.items.length ? 'pi' + P.sel : '');
|
||||
var cur = $('#pi' + P.sel); if (cur) cur.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
function palOpen(prefill) { $('#pal').classList.add('is-open'); $('#pal-q').value = prefill || ''; P.sel = 0; palRender(); $('#pal-q').focus(); }
|
||||
function palClose() { $('#pal').classList.remove('is-open'); }
|
||||
function palRun(i) { var it = P.items[i]; if (!it) return; if (!it.keep) palClose(); if (it.href) location.href = it.href; else it.run(); }
|
||||
|
||||
/* ── freshness + demos ───────────────────────────────────── */
|
||||
var fresh = { n: 3, stale: false };
|
||||
function tick() {
|
||||
fresh.n = fresh.stale ? fresh.n + 1 : (fresh.n >= 10 ? 0 : fresh.n + 1);
|
||||
var f = $('#fresh'); if (!f) return;
|
||||
f.classList.toggle('is-stale', fresh.stale);
|
||||
$('#fresh-t').innerHTML = fresh.stale ? 'Not updating · data from 09:41:07 <span class="long">· retrying</span>' : 'Live <span class="long">· updated ' + fresh.n + 's ago</span>';
|
||||
}
|
||||
function demo(kind) {
|
||||
var main = $('#page');
|
||||
if (kind === 'loading') {
|
||||
var keep = main.innerHTML;
|
||||
main.innerHTML = '<div class="stack" style="gap:24px" aria-busy="true" aria-label="Loading"><div class="skel" style="width:40%;height:28px"></div><div class="stats">' + [1, 2, 3, 4].map(function () { return '<div class="stat nm-raised"><div class="skel" style="width:50%"></div><div class="skel" style="width:70%;height:24px;margin-top:10px"></div></div>'; }).join('') + '</div><div class="nm-raised card stack">' + [1, 2, 3, 4, 5].map(function () { return '<div class="skel" style="height:18px"></div>'; }).join('') + '</div></div>';
|
||||
setTimeout(function () { main.innerHTML = keep; if (PAGE.after) PAGE.after(); }, 1600);
|
||||
}
|
||||
if (kind === 'offline') { fresh.stale = true; fresh.n = 0; tick(); var b = $('#offline-banner'); if (!b) { b = document.createElement('div'); b.id = 'offline-banner'; b.className = 'banner'; b.setAttribute('role', 'alert'); b.innerHTML = '<b>⚠</b><span style="flex:1">Can\'t reach Extrudex. You are looking at data from <b>09:41:07</b>, and it is not updating. Retrying every 10 s. Saving is paused.</span><button class="btn btn-sm" type="button" data-demo="online">Retry now</button>'; main.parentNode.insertBefore(b, main); } }
|
||||
if (kind === 'online') { fresh.stale = false; fresh.n = 0; tick(); var ob = $('#offline-banner'); if (ob) ob.parentNode.removeChild(ob); }
|
||||
}
|
||||
|
||||
/* ── wiring ──────────────────────────────────────────────── */
|
||||
function parseHash() {
|
||||
var m = /^#(spool|print|load)=([\w-]+)(?:&tab=(\w+))?(?:&state=(\w+))?/.exec(location.hash);
|
||||
if (m) { var id = m[1] === 'load' ? m[2] : +m[2]; openDrawer(m[1], id, m[3], false); if (m[4]) { D.state = m[4]; renderDrawer(); } }
|
||||
else if (D.kind) closeDrawer(false);
|
||||
}
|
||||
function init(page, render) {
|
||||
var s = shell(page.name);
|
||||
document.body.insertAdjacentHTML('afterbegin', '<a class="btn btn-sm" href="#page" style="position:absolute;left:-9999px;top:8px" onfocus="this.style.left=\'8px\'" onblur="this.style.left=\'-9999px\'">Skip to content</a><div class="shell">' + s.rail + '<main id="main">' + s.top + '<div id="page"></div></main></div>' + s.tabbar + s.rest);
|
||||
window.PAGE = page;
|
||||
$('#page').innerHTML = render();
|
||||
if (page.after) page.after();
|
||||
setTheme(document.documentElement.getAttribute('data-theme'));
|
||||
setInterval(tick, 1000);
|
||||
parseHash();
|
||||
var pq = /[?&]palette=([^&]*)/.exec(location.search); if (pq) palOpen(decodeURIComponent(pq[1]).replace(/\+/g, ' '));
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if ((a = t.closest('[data-set-theme]'))) return setTheme(a.getAttribute('data-set-theme'));
|
||||
if (t.closest('[data-toggle-theme]')) return setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
|
||||
if (t.closest('[data-palette]')) return palOpen();
|
||||
if (t.id === 'pal') return palClose();
|
||||
if ((a = t.closest('.pal-item'))) return palRun(+a.getAttribute('data-i'));
|
||||
if ((a = t.closest('[data-menu]'))) { var m = $('#' + a.getAttribute('data-menu')), open = !m.classList.contains('is-open'); $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); }); m.classList.toggle('is-open', open); a.setAttribute('aria-expanded', String(open)); if (open) { var f = m.querySelector('button,a'); if (f) f.focus(); } e.stopPropagation(); return; }
|
||||
if (!t.closest('.menu')) $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); });
|
||||
if ((a = t.closest('[data-demo]'))) { $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); }); return demo(a.getAttribute('data-demo')); }
|
||||
if ((a = t.closest('[data-soon]'))) { e.preventDefault(); return toast('<b>' + a.getAttribute('data-soon') + '</b> keeps its current screen on dev. It isn\'t redesigned in this iteration.'); }
|
||||
if ((a = t.closest('[data-spool]'))) { e.preventDefault(); D.list = null; return openDrawer('spool', +a.getAttribute('data-spool'), a.getAttribute('data-tab-open') || 'overview'); }
|
||||
if ((a = t.closest('[data-print]'))) { e.preventDefault(); return openDrawer('print', +a.getAttribute('data-print')); }
|
||||
if ((a = t.closest('[data-load-into]'))) { e.preventDefault(); return openDrawer('load', a.getAttribute('data-load-into')); }
|
||||
if ((a = t.closest('[data-add-spool]'))) { e.preventDefault(); return openDrawer('new', 0, 'edit'); }
|
||||
if (t.closest('[data-close-drawer]')) return closeDrawer();
|
||||
if ((a = t.closest('#drawer [data-tab]'))) { D.tab = a.getAttribute('data-tab'); D.state = 'normal'; renderDrawer(); history.replaceState({ d: 1 }, '', '#' + D.kind + '=' + D.id + (D.tab !== 'overview' ? '&tab=' + D.tab : '')); var f = $('#drawer [autofocus]'); if (f) f.focus(); return; }
|
||||
if ((a = t.closest('[data-state]'))) { D.state = a.getAttribute('data-state'); return renderDrawer(); }
|
||||
if ((a = t.closest('[data-step]'))) { var i = D.list.indexOf(D.id) + +a.getAttribute('data-step'); if (D.list[i] != null) openDrawer('spool', D.list[i], D.tab, false); history.replaceState({ d: 1 }, '', '#spool=' + D.id + (D.tab !== 'overview' ? '&tab=' + D.tab : '')); return; }
|
||||
if ((a = t.closest('[data-dest]'))) { $$('[data-dest]').forEach(function (b) { b.setAttribute('aria-pressed', String(b === a)); }); $('#m-save').disabled = false; var dc = a.getAttribute('data-dest'), cc = dc !== 'storage' && container(dc); $('#m-save').textContent = cc ? 'Load into ' + cc.printer.model + ' · ' + cc.c.label : 'Move to storage'; return; }
|
||||
if ((a = t.closest('[data-load]'))) { $$('[data-load]').forEach(function (b) { b.setAttribute('aria-pressed', String(b === a)); }); var ls = spool(+a.getAttribute('data-load')); $('#l-save').disabled = false; $('#l-save').textContent = 'Load ' + ls.color + ' · ' + ls.brand; $('#l-save').setAttribute('data-sid', ls.id); return; }
|
||||
if (t.id === 'l-save') { var sid = +t.getAttribute('data-sid'), ss = spool(sid), cc2 = container(D.id); ss.cont = D.id; closeDrawer(); toast('Loaded <b>' + esc(ss.color + ' · ' + ss.brand) + '</b> into ' + esc(cc2.printer.model + ' · ' + cc2.c.label) + ' <a href="#spool=' + sid + '" data-spool="' + sid + '">View</a>'); if (PAGE.refresh) PAGE.refresh(); return; }
|
||||
if (t.id === 'm-save') { var dest = $('[data-dest][aria-pressed="true"]').getAttribute('data-dest'), sp = spool(D.id); sp.cont = dest === 'storage' ? null : dest; sp.v++; D.tab = 'overview'; renderDrawer(); toast('Moved <b>' + esc(sp.color) + '</b> to ' + esc(where(sp).text)); if (PAGE.refresh) PAGE.refresh(); return; }
|
||||
if (t.id === 'w-save' || t.id === 'w-next') { var ws = spool(D.id), before = ws.rem, nv = Math.round(+$('#scale').value.replace(/[^\d.]/g, '') - ws.tare); ws.rem = nv; ws.remSrc = 'weighed'; ws.weighedAt = '18 Sep 09:41'; ws.v++; toast('Weighed <b>' + esc(ws.color + ' · ' + ws.brand) + '</b>: ' + g(before) + ' → ' + g(nv)); if (PAGE.refresh) PAGE.refresh(); if (t.id === 'w-next') { var nx = D.list[D.list.indexOf(D.id) + 1]; openDrawer('spool', nx, 'weigh', false); history.replaceState({ d: 1 }, '', '#spool=' + nx + '&tab=weigh'); } else { D.tab = 'overview'; renderDrawer(); } return; }
|
||||
if (t.closest('[data-save]')) { var sv = D.kind === 'new'; closeDrawer(); D.dirty = false; return toast(sv ? 'Added spool <b>#57</b>. <a href="#">Print its label</a>' : 'Saved changes'); }
|
||||
if (t.closest('[data-discard]')) { D.dirty = false; return closeDrawer(); }
|
||||
if (t.closest('[data-keep]')) { $('#discard-bar').hidden = true; return; }
|
||||
if ((a = t.closest('[data-fake]'))) { toast(a.getAttribute('data-fake')); return; }
|
||||
if ((a = t.closest('[data-delete]'))) { var ds = spool(+a.getAttribute('data-delete')); $('#drawer .drawer-foot').innerHTML = '<div class="stack" style="gap:8px;width:100%"><b>Delete ' + esc(ds.color + ' · ' + fullName(ds)) + '?</b><span class="small">Its print history stays. Prints keep their grams and cost and only lose the link to this spool. This can\'t be undone.</span><div class="row" style="justify-content:flex-end"><button class="btn" type="button" data-tab="overview" autofocus>Keep spool</button><button class="btn btn-danger" type="button" data-fake="Spool #' + ds.id + ' deleted. 0 prints lost their cost.">Delete spool</button></div></div>'; $('#drawer .drawer-foot [autofocus]').focus(); return; }
|
||||
});
|
||||
document.addEventListener('input', function (e) {
|
||||
if (e.target.id === 'pal-q') { P.sel = 0; return palRender(); }
|
||||
if (e.target.closest('#drawer .drawer-body') && D.tab === 'edit') D.dirty = true;
|
||||
if (e.target.id === 'scale') {
|
||||
var s = spool(D.id), v = +e.target.value.replace(/[^\d.]/g, ''), r = Math.round(v - s.tare);
|
||||
var ok = v > 0 && v >= s.tare && r <= s.tot + 1;
|
||||
$('#w-after').textContent = v ? g(r) : '—';
|
||||
$('#w-diff').innerHTML = v && ok ? '<span class="prov">' + (r - s.rem > 0 ? '+' : '') + (r - s.rem).toLocaleString('en-US') + ' g</span>' : '';
|
||||
$('#w-note').innerHTML = !v ? 'Type the reading and the result appears here before you save.' : v < s.tare ? '<span style="color:var(--status-error)">⚠ ' + g(v) + ' is less than the ' + g(s.tare) + ' empty spool. One of them is wrong. Nothing will be saved.</span>' : r > s.tot + 1 ? '<span style="color:var(--status-error)">⚠ That leaves ' + g(r) + ', more than the spool holds full (' + g(s.tot) + '). Check the total or the tare.</span>' : 'Saved with the tare it used, so a later tare correction can re-base it.';
|
||||
$('#w-save').disabled = !ok; if ($('#w-next')) $('#w-next').disabled = !ok;
|
||||
}
|
||||
if (e.target.id === 'load-q') { var q = e.target.value.toLowerCase(); $$('[data-load]').forEach(function (b) { b.hidden = q && b.getAttribute('data-hay').indexOf(q) < 0; }); }
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
var typing = /INPUT|SELECT|TEXTAREA/.test(document.activeElement.tagName);
|
||||
if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || (e.key === '/' && !typing)) { e.preventDefault(); return palOpen(); }
|
||||
if ($('#pal').classList.contains('is-open')) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); return palClose(); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); P.sel = Math.min(P.items.length - 1, P.sel + 1); return palRender(); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); P.sel = Math.max(0, P.sel - 1); return palRender(); }
|
||||
if (e.key === 'Enter') { e.preventDefault(); return palRun(P.sel); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') { if ($('.menu.is-open')) { $$('.menu.is-open').forEach(function (x) { x.classList.remove('is-open'); }); return; } if (D.kind) return closeDrawer(); }
|
||||
if (D.kind === 'spool' && !typing && D.list && (e.key === 'j' || e.key === 'k')) { var b = $('[data-step="' + (e.key === 'j' ? 1 : -1) + '"]'); if (b && !b.disabled) b.click(); }
|
||||
if (e.key === 'Enter' && document.activeElement.matches('tr[data-row-spool],tr[data-row-print]')) document.activeElement.click();
|
||||
if (D.kind && e.key === 'Tab') { var dr = $('#drawer'), f = $$('button:not([disabled]),a[href],input,select,[tabindex="0"]', dr).filter(function (x) { return x.offsetParent; }); if (!f.length) return; if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); } else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); } }
|
||||
});
|
||||
window.addEventListener('popstate', parseHash);
|
||||
|
||||
window.X = { init: init, esc: esc, g: g, kg: kg, usd: usd, dur: dur, when: when, ago: ago, since: since, spool: spool, printer: printer, print: print, container: container, contents: contents, fullName: fullName, short: short, where: where, gross: gross, value: value, low: low, sw: sw, meter: meter, tareProv: tareProv, remProv: remProv, weightProv: weightProv, durCell: durCell, printSt: printSt, printerSt: printerSt, needs: needs, printsNeeding: printsNeeding, spoolLinks: spoolLinks, openDrawer: openDrawer, D: D, toast: toast, ICON: ICON };
|
||||
})();
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Iteration 2: assemble self-contained pages.
|
||||
|
||||
Each screen = v3 tokens (fonts inlined as base64) + iteration-1 app.css
|
||||
utilities + components.css + page CSS, then data.js + app.js + page script.
|
||||
Run: python3 build.py (writes ../screens/*.html)
|
||||
"""
|
||||
import base64, pathlib, re
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent.parent # ~/Design/extrudex-v3
|
||||
OUT = HERE.parent / 'screens'
|
||||
OUT.mkdir(exist_ok=True)
|
||||
|
||||
tokens = (ROOT / 'design-system-v3.css').read_text()
|
||||
tokens = re.sub(r"url\('\./fonts/([^']+)'\)",
|
||||
lambda m: "url('data:font/woff2;base64," + base64.b64encode((ROOT / 'fonts' / m.group(1)).read_bytes()).decode() + "')",
|
||||
tokens)
|
||||
base_css = (ROOT / 'source' / 'app.css').read_text()
|
||||
comp_css = (HERE / 'components.css').read_text()
|
||||
data_js = (HERE / 'data.js').read_text()
|
||||
app_js = (HERE / 'app.js').read_text()
|
||||
|
||||
THEME = """<script>
|
||||
(function () { var t = null; try { t = localStorage.getItem('extrudex-v3-theme'); } catch (e) {}
|
||||
var q = /[?&]theme=(light|dark)/.exec(location.search); if (q) t = q[1];
|
||||
if (t !== 'light' && t !== 'dark') t = window.matchMedia && matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', t); })();
|
||||
</script>"""
|
||||
|
||||
def build(slug, title, desc):
|
||||
src = (HERE / 'pages' / f'{slug}.html').read_text()
|
||||
css = ''.join(re.findall(r'<style>(.*?)</style>', src, re.S))
|
||||
js = ''.join(re.findall(r'<script>(.*?)</script>', src, re.S))
|
||||
html = f"""<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
<meta name="description" content="{desc}">
|
||||
{THEME}
|
||||
<style>
|
||||
{tokens}
|
||||
{base_css}
|
||||
{comp_css}
|
||||
{css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
{data_js}
|
||||
{app_js}
|
||||
</script>
|
||||
<script>
|
||||
{js}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
(OUT / f'{slug}.html').write_text(html)
|
||||
print(slug, len(html) // 1024, 'KB')
|
||||
|
||||
PAGES = [
|
||||
('index', 'Extrudex Flow Map', 'How the iteration-2 screens connect, with guided flows.'),
|
||||
('01-dashboard', 'Extrudex Dashboard', 'Attention-first dashboard, iteration 2.'),
|
||||
('02-filament', 'Extrudex Filament', 'Filament inventory with drawer, filters and weigh session, iteration 2.'),
|
||||
('03-prints', 'Extrudex Prints', 'Print history and attribution queue, iteration 2.'),
|
||||
('04-printers', 'Extrudex Printers', 'Printers with holders, load flow and connection state, iteration 2.'),
|
||||
]
|
||||
for p in PAGES:
|
||||
if (HERE / 'pages' / f'{p[0]}.html').exists():
|
||||
build(*p)
|
||||
@@ -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); }
|
||||
@@ -0,0 +1,58 @@
|
||||
/* Sample data for the iteration-2 mockups. One model shared by every page, so a
|
||||
* spool, print or printer is the same object wherever it appears. Values are
|
||||
* based on the real inventory (import/filament_review.tsv, seed.sql spool
|
||||
* types) and the 2026-08-12 production canary. Nothing here is live. */
|
||||
var NOW = new Date('2026-09-18T09:41:07');
|
||||
|
||||
var CANARY = { spools: 105, brands: 18, kg: 95.72, value: 1721.18 };
|
||||
|
||||
/* tsrc: where this spool's tare came from.
|
||||
* 'spool' weighed for this reel (tare_origin=spool)
|
||||
* 'manufacturer' | 'community' | 'estimated' via the chosen spool type
|
||||
* null no tare known */
|
||||
var SPOOLS = [
|
||||
{ id: 40, c: ['#1f3fbf'], color: 'Klein Blue', brand: 'Sunlu', speed: null, mat: 'PLA', type: '+ 2.0', fin: 'Basic', rem: 64, tot: 250, tare: 60, tsrc: 'community', stype: 'Sunlu — 250 g mini', remSrc: 'typed', kg: 25.20, cont: null, loc: 'Shelf B2', label: true, v: 3 },
|
||||
{ id: 41, c: ['#b87333'], color: 'Copper', brand: 'Sunlu', speed: null, mat: 'PLA', type: '+', fin: 'Silk', rem: 97, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', remSrc: 'typed', kg: 19.99, cont: 'p1-ams1', label: true, v: 7 },
|
||||
{ id: 42, c: ['#141414'], color: 'Black', brand: 'Elegoo', speed: 'Rapid', mat: 'PETG', type: null, fin: 'Basic', rem: 142, tot: 1000, tare: 154, tsrc: 'manufacturer', stype: 'Elegoo — 1 kg cardboard', remSrc: 'typed', kg: 11.74, cont: 'p2-ams1', label: true, v: 9 },
|
||||
{ id: 43, c: ['#ece8df'], color: 'Rock White', brand: 'Overture', speed: null, mat: 'PLA', type: null, fin: 'Marble', rem: 171, tot: 1000, tare: 155, tsrc: 'community', stype: 'Overture — cardboard, PLA lines', remSrc: 'typed', kg: 17.99, cont: null, loc: 'Shelf C', label: true, v: 4 },
|
||||
{ id: 44, c: ['#161616', '#c0232f'], color: 'Black Red', brand: 'TECSONAR', speed: null, mat: 'PLA', type: null, fin: 'Matte', rem: 188, tot: 1000, tare: null, tsrc: null, stype: null, remSrc: 'typed', kg: 15.99, cont: null, loc: 'Shelf C', label: false, v: 2 },
|
||||
{ id: 45, c: ['#101010'], color: 'Black', brand: 'JAYO', speed: 'Rapid', mat: 'PETG', type: null, fin: 'Matte', rem: 655, tot: 1000, tare: null, tsrc: null, stype: null, remSrc: 'typed', kg: 12.72, cont: 'p1-ams1', label: true, v: 5 },
|
||||
{ id: 46, c: ['#8a8f7a', '#e9e4d8'], color: 'Marble', brand: 'iSANMATE', speed: null, mat: 'ASA', type: null, fin: 'Marble', rem: 430, tot: 1000, tare: 180, tsrc: 'estimated', stype: 'Generic — 1 kg spool (assumed)', remSrc: 'weighed', kg: 25.99, cont: null, loc: 'Dry box 1', label: true, v: 6 },
|
||||
{ id: 47, c: ['#1b1b1b'], color: 'Black', brand: 'Sunlu', speed: null, mat: 'PETG', type: 'CF', fin: 'Matte', rem: 812, tot: 1000, tare: 262, tsrc: 'spool', stype: 'Sunlu — 1 kg plastic', remSrc: 'weighed', weighedAt: '18 Sep 09:39', kg: 22.79, cont: 'p3-lite', label: true, v: 5 },
|
||||
{ id: 48, c: ['#b8864b'], color: 'Natural Wood', brand: 'Sunlu', speed: null, mat: 'PLA', type: null, fin: 'Wood', rem: 1000, tot: 1000, tare: 35, tsrc: 'estimated', stype: 'Refill — bare cardboard core', remSrc: 'typed', kg: 16.50, cont: null, loc: 'Shelf A3', label: false, v: 1 },
|
||||
{ id: 49, c: ['#a3402c'], color: 'Brick Red', brand: 'Sunlu', speed: 'High Speed', mat: 'PLA', type: null, fin: 'Marble', rem: 1000, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', remSrc: 'typed', kg: 17.50, cont: null, loc: 'Shelf A2', label: true, v: 1 },
|
||||
{ id: 50, c: ['#e8453c', '#f2c230', '#2a8fd6'], color: 'Gradient Rainbow', brand: 'AMOLEN', speed: null, mat: 'PETG', type: null, fin: 'Gradient', rem: 1000, tot: 1000, tare: 180, tsrc: 'estimated', stype: 'Generic — 1 kg spool (assumed)', remSrc: 'typed', kg: 23.99, cont: null, loc: 'Shelf D', label: false, v: 1 },
|
||||
{ id: 51, c: ['#1d4fa8'], color: 'Blue', brand: 'Elegoo', speed: 'Rapid', mat: 'PETG', type: null, fin: 'Basic', rem: 1000, tot: 1000, tare: 154, tsrc: 'manufacturer', stype: 'Elegoo — 1 kg cardboard', remSrc: 'typed', kg: 17.99, cont: 'p2-ams1', label: true, v: 2 },
|
||||
{ id: 52, c: ['#141414'], color: 'Black', brand: 'Sunlu', speed: null, mat: 'PLA', type: '+ 2.0', fin: 'Basic', rem: 873, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', remSrc: 'typed', kg: 11.47, cont: 'p1-ams1', label: true, v: 11 },
|
||||
{ id: 53, c: ['#b9bcc0'], color: 'Mist Gray', brand: 'Overture', speed: null, mat: 'PLA', type: null, fin: 'Marble', rem: 920, tot: 1000, tare: 155, tsrc: 'community', stype: 'Overture — cardboard, PLA lines', remSrc: 'typed', kg: 16.99, cont: 'p1-ams1', label: true, v: 3 },
|
||||
{ id: 54, c: ['#f4f4f0'], color: 'White', brand: 'Elegoo', speed: null, mat: 'PLA', type: null, fin: 'Matte', rem: 569, tot: 1000, tare: 154, tsrc: 'manufacturer', stype: 'Elegoo — 1 kg cardboard', remSrc: 'typed', kg: 14.99, cont: 'p2-ams1', label: true, v: 6 },
|
||||
{ id: 55, c: ['#e8453c', '#f2c230', '#2a8fd6'], color: 'Red Yellow Blue', brand: 'AMOLEN', speed: null, mat: 'PLA', type: null, fin: 'Matte', rem: 532, tot: 1000, tare: 180, tsrc: 'estimated', stype: 'Generic — 1 kg spool (assumed)', remSrc: 'typed', kg: 19.99, cont: 'p3-lite', label: true, v: 4 },
|
||||
{ id: 56, c: ['#6e1a2a'], color: 'Transparent Wine Red', brand: 'Creality', speed: null, mat: 'PETG', type: null, fin: 'Translucent', rem: 1000, tot: 1000, tare: null, tsrc: null, stype: null, remSrc: 'typed', kg: 17.99, cont: null, loc: 'Shelf D', label: false, v: 1 }
|
||||
];
|
||||
|
||||
var PRINTERS = [
|
||||
{ id: 1, name: 'P1S - Prints Charming', model: 'P1S', ip: '10.60.1.242', serial: '01P09A••••••4821', state: 'RUNNING', connected: true, lifecycle: 'ready', stale: false, age: 2, session: 3, camera: 'snapshot', job: 'Gridfinity_bins_3x2', openPrint: 212,
|
||||
containers: [{ id: 'p1-ams1', kind: 'ams', label: 'AMS 1', cap: 4 }, { id: 'p1-ext', kind: 'external', label: 'External', cap: 1 }] },
|
||||
{ id: 2, name: 'H2C - Constipation Orion', model: 'H2C', ip: '10.60.1.202', serial: '31B••••••••0093', state: 'FINISH', connected: true, lifecycle: 'ready', stale: false, age: 4, session: 2, camera: 'snapshot', job: '', openPrint: null,
|
||||
containers: [{ id: 'p2-ams1', kind: 'ams', label: 'AMS 1', cap: 4 }, { id: 'p2-ext', kind: 'external', label: 'External', cap: 1 }] },
|
||||
{ id: 3, name: 'A1 - Jordyns Layer Slayer', model: 'A1', ip: '10.60.1.x (sample)', serial: '039••••••••7710', state: 'RUNNING', connected: false, lifecycle: 'reconnecting', stale: true, age: 366, session: 14, camera: 'snapshot', job: 'Coaster_set', openPrint: 213,
|
||||
containers: [{ id: 'p3-lite', kind: 'ams_lite', label: 'AMS Lite', cap: 4 }, { id: 'p3-ext', kind: 'external', label: 'External', cap: 1 }] }
|
||||
];
|
||||
|
||||
/* weight source: typed | sliced | recalled | unknown | null (not known yet)
|
||||
* gaps: null = before counting, 0 = asked and none */
|
||||
var PRINTS = [
|
||||
{ id: 213, job: 'Coaster_set', key: '/coasters/plate_1.gcode', printer: 3, status: 'running', start: '2026-09-18T08:55', end: null, dur: null, gaps: 0, grams: null, wsrc: null, cost: null, spool: null, use: 'personal', open: true },
|
||||
{ id: 212, job: 'Gridfinity_bins_3x2', key: '/gridfinity/bins_3x2.gcode', printer: 1, status: 'running', start: '2026-09-18T08:37', end: null, dur: null, gaps: 0, grams: null, wsrc: null, cost: null, spool: null, use: 'business' },
|
||||
{ id: 211, job: 'Benchy_x4', key: '/benchy-batch/plate_1.gcode', printer: 2, status: 'finished', start: '2026-09-18T02:15', end: '2026-09-18T05:27', dur: 11520, gaps: 1, grams: 46, wsrc: 'sliced', cost: 0.54, spool: 42, use: 'business' },
|
||||
{ id: 210, job: 'Sign_CubeCraft', key: '/signs/cubecraft.gcode', printer: 2, status: 'finished', start: '2026-09-17T21:48', end: '2026-09-17T23:53', dur: 7500, gaps: 0, grams: 64, wsrc: 'sliced', cost: 0.61, spool: null, use: 'business',
|
||||
filaments: [{ slot: 0, spool: 54, grams: 41, hex: '#f4f4f0', cost: 0.61 }, { slot: 1, spool: null, suggested: 51, grams: 23, hex: '#1d4fa8', cost: null }] },
|
||||
{ id: 209, job: 'Cable_clips', key: '/misc/cable_clips.gcode', printer: 3, status: 'unknown', start: '2026-09-17T19:30', end: '2026-09-17T21:02', dur: null, gaps: 1, grams: 12, wsrc: 'typed', cost: 0.27, spool: 47, use: 'personal' },
|
||||
{ id: 208, job: 'Lid_v3', key: '/enclosure/lid_v3.gcode', printer: 1, status: 'failed', start: '2026-09-17T16:02', end: '2026-09-17T16:49', dur: 2820, gaps: 0, grams: 118, wsrc: 'typed', cost: 1.35, spool: 52, use: 'business' },
|
||||
{ id: 207, job: 'Planter_small', key: '/garden/planter_small.gcode', printer: 3, status: 'finished', start: '2026-09-16T10:20', end: '2026-09-16T16:00', dur: 20400, gaps: 0, grams: 212, wsrc: 'recalled', cost: 4.24, spool: 55, use: 'personal' },
|
||||
{ id: 206, job: 'Bracket_M3', key: '/brackets/m3.gcode', printer: 1, status: 'finished', start: '2026-09-16T08:05', end: '2026-09-16T09:17', dur: 4320, gaps: 0, grams: null, wsrc: null, cost: null, spool: null, use: null },
|
||||
{ id: 205, job: 'Filament_clip', key: '/misc/filament_clip.gcode', printer: 1, status: 'finished', start: '2026-09-15T14:40', end: '2026-09-15T15:18', dur: 2280, gaps: 0, grams: 7, wsrc: 'sliced', cost: null, spool: null, suggested: 41, use: 'personal' },
|
||||
{ id: 201, job: 'Test_cube', key: '/calibration/test_cube.gcode', printer: 1, status: 'finished', start: '2026-08-07T14:11', end: '2026-08-07T14:42', dur: 1860, gaps: null, grams: 9, wsrc: 'unknown', cost: 0.10, spool: 52, use: null }
|
||||
];
|
||||
|
||||
var LOW = 200;
|
||||
@@ -0,0 +1,76 @@
|
||||
<style>
|
||||
.dash { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(300px, 1fr); gap: var(--space-6); align-items: start; }
|
||||
.stats .stat { gap: 4px; }
|
||||
.stats a.stat { border: 1px solid var(--border-hairline); }
|
||||
.day { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); font-weight: var(--fw-bold); padding: 14px 16px 6px; }
|
||||
.feed { display: flex; flex-direction: column; }
|
||||
.feed-row { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: 110px minmax(0, 1fr) auto auto; gap: 4px var(--space-4); align-items: center; padding: 10px 16px; border-top: 1px solid var(--border-hairline); font-size: 13px; }
|
||||
.feed-row:hover { background: var(--accent-wash); }
|
||||
.feed-row:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
|
||||
.feed-row .job { font-weight: var(--fw-bold); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.feed-row .r { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.side-row { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; padding: 10px 16px; border-top: 1px solid var(--border-hairline); font-size: 13px; width: 100%; }
|
||||
.side-row:hover { background: var(--accent-wash); }
|
||||
.side-row:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
|
||||
.card-h { display: flex; align-items: center; gap: var(--space-3); padding: 14px 16px 10px; }
|
||||
.card-h h2 { margin: 0; font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); }
|
||||
.card-h a { margin-left: auto; font-size: var(--fs-label); }
|
||||
.swrow { display: inline-flex; margin-left: 6px; vertical-align: middle; }
|
||||
.swrow .swatch { margin-left: -6px; width: 18px; height: 18px; }
|
||||
@media (max-width: 1180px) { .dash { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media (max-width: 620px) { .feed-row { grid-template-columns: minmax(0, 1fr) auto; } .feed-row .when { grid-column: 1 / -1; order: -1; } .feed-row .hide-s { display: none; } .attn { grid-template-columns: 36px minmax(0, 1fr); } .attn .ico { width: 36px; height: 36px; } .attn .btn { grid-column: 1 / -1; justify-content: center; } }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
function attention() {
|
||||
var lowS = SPOOLS.filter(x.low), tareS = SPOOLS.filter(function (s) { return s.tare == null; }), need = x.printsNeeding(), down = PRINTERS.filter(function (p) { return !p.connected; });
|
||||
var withSug = need.filter(function (p) { return p.suggested || (p.filaments || []).some(function (f) { return f.suggested; }); }).length;
|
||||
var items = [];
|
||||
down.forEach(function (p) { items.push('<div class="attn err"><span class="ico" aria-hidden="true">⚡</span><div><h3>' + x.esc(p.name) + ' lost its connection</h3><p>Last contact ' + x.ago(p.age) + '. Print capture is paused; <b>' + x.esc(p.job) + '</b> is still open and will be reconciled on reconnect.</p></div><a class="btn btn-sm" href="04-printers.html#printer=' + p.id + '">Open printer</a></div>'); });
|
||||
if (need.length) items.push('<div class="attn warn"><span class="ico" aria-hidden="true">!</span><div><h3>' + need.length + ' prints need a spool or weight</h3><p>Their filament hasn\'t been deducted and their cost is incomplete.' + (withSug ? ' ' + withSug + ' have a same-color suggestion ready.' : '') + '</p></div><a class="btn btn-sm btn-primary" href="03-prints.html?tab=needs">Review ' + need.length + '</a></div>');
|
||||
if (lowS.length) items.push('<div class="attn warn"><span class="ico" aria-hidden="true">▁</span><div><h3>' + lowS.length + ' spools under 200 g <span class="swrow">' + lowS.map(function (s) { return x.sw(s.c); }).join('') + '</span></h3><p>' + lowS.filter(function (s) { return s.cont; }).length + ' of them are loaded in a printer right now.</p></div><a class="btn btn-sm" href="02-filament.html?f=low">See low stock</a></div>');
|
||||
if (tareS.length) items.push('<div class="attn info"><span class="ico" aria-hidden="true">?</span><div><h3>' + tareS.length + ' spools can\'t be weighed yet</h3><p>No empty-spool weight is known, so a scale reading can\'t be turned into filament. Picking a spool type fixes most.</p></div><a class="btn btn-sm" href="02-filament.html?f=tare">Set tares</a></div>');
|
||||
return items.length ? items.join('') : '<div class="attn-done nm-inset">✓ Nothing needs you right now.</div>';
|
||||
}
|
||||
function feed() {
|
||||
var groups = [];
|
||||
PRINTS.slice(0, 7).forEach(function (p) { var w = x.when(p.start), d = /^(Today|Yesterday)/.test(w) ? w.split(' ')[0] : w.split(' ').slice(0, 2).join(' '); var last = groups[groups.length - 1]; if (!last || last.d !== d) groups.push(last = { d: d, list: [] }); last.list.push(p); });
|
||||
return groups.map(function (grp) {
|
||||
return '<div class="day">' + grp.d + '</div>' + grp.list.map(function (p) {
|
||||
var n = x.needs(p), pr = x.printer(p.printer);
|
||||
return '<button class="feed-row" type="button" data-print="' + p.id + '"><span class="when">' + x.printSt(p) + '<span class="sub">' + x.when(p.start).split(' ').pop() + '</span></span>' +
|
||||
'<span style="min-width:0"><span class="job" style="display:block">' + x.esc(p.job) + '</span><span class="sub">' + x.esc(pr.model) + (p.open && !pr.connected ? ' · <span class="prov warn"><i>⚡</i>open across outage</span>' : '') + (n.length ? ' · <span class="prov warn"><i>!</i>needs ' + n.map(function (i) { return i.t; }).join(', ') + '</span>' : '') + '</span></span>' +
|
||||
'<span class="r hide-s">' + x.durCell(p) + '</span><span class="r">' + (p.grams == null ? '<span class="muted">—</span>' : '<b>' + x.g(p.grams) + '</b><span class="sub">' + x.weightProv(p) + '</span>') + '</span></button>';
|
||||
}).join('');
|
||||
}).join('');
|
||||
}
|
||||
function printers() {
|
||||
return PRINTERS.map(function (p) {
|
||||
return '<a class="side-row" href="04-printers.html#printer=' + p.id + '"><span>' + x.printerSt(p) + '</span><span style="min-width:0"><b style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + x.esc(p.name) + '</b><span class="sub">' + (p.connected ? (p.job ? x.esc(p.job) : 'last: Benchy_x4') : 'last known: printing ' + x.esc(p.job)) + '</span></span><span class="sub">' + (p.connected ? x.ago(p.age) : '<span class="prov warn">' + x.ago(p.age) + '</span>') + '</span></a>';
|
||||
}).join('');
|
||||
}
|
||||
function lows() {
|
||||
return SPOOLS.filter(x.low).sort(function (a, b) { return a.rem - b.rem; }).map(function (s) {
|
||||
return '<button class="side-row" type="button" data-spool="' + s.id + '">' + x.sw(s.c) + '<span style="min-width:0"><b>' + x.esc(s.color) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">' + x.esc(x.fullName(s)) + ' · ' + x.esc(x.where(s).short) + '</span></span><span class="num strong" style="color:var(--status-warning)">' + x.g(s.rem) + '</span></button>';
|
||||
}).join('');
|
||||
}
|
||||
function render() {
|
||||
var today = PRINTS.filter(function (p) { return /^Today/.test(x.when(p.start)) && p.grams != null; });
|
||||
var usedG = today.reduce(function (a, p) { return a + p.grams; }, 0), usedC = today.reduce(function (a, p) { return a + (p.cost || 0); }, 0);
|
||||
return '<header class="page-head"><div><h1>Dashboard</h1><p class="sum">Friday 18 September · <b>' + PRINTERS.length + '</b> printers · <b>' + CANARY.spools + '</b> spools</p></div><button class="btn btn-primary primary-desktop" type="button" data-add-spool>+ Add spool</button></header>' +
|
||||
'<section aria-labelledby="needs-h" class="stack" style="gap:12px"><h2 class="section" id="needs-h" style="margin:0">Needs you</h2><div class="attn-list">' + attention() + '</div></section>' +
|
||||
'<section class="stats" aria-label="Stock">' +
|
||||
'<a class="stat nm-raised tile-link" href="02-filament.html"><span class="stat-label">Spools</span><span class="stat-value">' + CANARY.spools + '</span><span class="stat-foot">' + CANARY.brands + ' brands · 6 materials →</span></a>' +
|
||||
'<div class="stat nm-raised"><span class="stat-label">Filament left</span><span class="stat-value">' + CANARY.kg.toFixed(2) + ' kg</span><span class="stat-foot">net: filament only, no spools</span></div>' +
|
||||
'<div class="stat nm-raised"><span class="stat-label">Stock value</span><span class="stat-value">' + x.usd(CANARY.value) + '</span><span class="stat-foot">what\'s left × each $/kg</span></div>' +
|
||||
'<a class="stat nm-raised tile-link" href="03-prints.html"><span class="stat-label">Used today</span><span class="stat-value">' + x.g(usedG) + '</span><span class="stat-foot">' + x.usd(usedC) + ' · ' + today.length + ' done · 2 running →</span></a>' +
|
||||
'</section>' +
|
||||
'<div class="dash"><section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="act-h"><div class="card-h"><h2 id="act-h">Recent prints</h2><a href="03-prints.html">All prints →</a></div><div class="feed">' + feed() + '</div></section>' +
|
||||
'<div class="stack" style="gap:24px"><section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="pr-h"><div class="card-h"><h2 id="pr-h">Printers</h2><a href="04-printers.html">Open →</a></div>' + printers() + '</section>' +
|
||||
'<section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="low-h"><div class="card-h"><h2 id="low-h">Running low</h2><a href="02-filament.html?f=low">All →</a></div>' + lows() + '</section></div></div>' +
|
||||
'<button class="btn btn-primary fab" type="button" data-add-spool aria-label="Add spool">+</button>';
|
||||
}
|
||||
X.init({ name: 'dashboard', refresh: function () { document.getElementById('page').innerHTML = render(); } }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<style>
|
||||
.toolbar { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.toolbar .row1 { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
|
||||
.searchbox { position: relative; flex: 1; min-width: 220px; }
|
||||
.searchbox svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-secondary); }
|
||||
.searchbox .input { padding-left: 36px; }
|
||||
.ctl { display: inline-flex; gap: 8px; align-items: center; font-size: var(--fs-label); color: var(--text-secondary); }
|
||||
.ctl .select { min-height: 36px; padding: 6px 10px; width: auto; font-size: 13px; }
|
||||
.grp td { background: var(--surface-inset); font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); font-weight: var(--fw-bold); color: var(--text-secondary); padding: 8px 12px; }
|
||||
.spcell { display: flex; gap: var(--space-3); align-items: center; min-width: 220px; }
|
||||
.remcell { min-width: 160px; }
|
||||
.remcell .top { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 5px; }
|
||||
.rowact { display: flex; gap: 6px; justify-content: flex-end; }
|
||||
.rowact .btn { min-height: 30px; padding: 4px 10px; }
|
||||
.weigh-hint { display: flex; gap: var(--space-3); align-items: center; padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); background: var(--accent-wash); border: 1px solid var(--text-accent); font-size: 13px; }
|
||||
@media (max-width: 760px) { .hide-m { display: none; } .spcell { min-width: 0; } .remcell { min-width: 110px; } .rowact .menu-wrap .btn { padding: 4px 8px; } }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var q = new URLSearchParams(location.search);
|
||||
var S = { view: q.get('f') || 'all', q: q.get('q') || '', mat: '', sort: 'rem', dir: 1, cols: 'stock', group: 'none' };
|
||||
var VIEWS = [
|
||||
['all', 'All', function () { return true; }],
|
||||
['low', 'Low', x.low],
|
||||
['loaded', 'Loaded', function (s) { return !!s.cont; }],
|
||||
['storage', 'In storage', function (s) { return !s.cont; }],
|
||||
['tare', 'Tare unknown', function (s) { return s.tare == null; }]
|
||||
];
|
||||
function viewFn(k) { for (var i = 0; i < VIEWS.length; i++) if (VIEWS[i][0] === k) return VIEWS[i]; return VIEWS[0]; }
|
||||
function list() {
|
||||
var f = viewFn(S.view)[2], qq = S.q.toLowerCase();
|
||||
var l = SPOOLS.filter(function (s) { return f(s) && (!S.mat || s.mat === S.mat) && (!qq || ('#' + s.id + ' ' + x.fullName(s) + ' ' + s.color + ' ' + x.where(s).text).toLowerCase().indexOf(qq) >= 0); });
|
||||
var key = { rem: function (s) { return s.rem; }, name: function (s) { return (s.color + s.brand).toLowerCase(); }, value: function (s) { return x.value(s); }, gross: function (s) { return x.gross(s) == null ? -1 : x.gross(s); } }[S.sort];
|
||||
l.sort(function (a, b) { var A = key(a), B = key(b); return (A < B ? -1 : A > B ? 1 : 0) * S.dir; });
|
||||
if (S.group !== 'none') { var gk = S.group === 'mat' ? function (s) { return s.mat; } : function (s) { return x.where(s).loaded ? x.where(s).printer.name : 'Storage'; }; l.sort(function (a, b) { return gk(a) < gk(b) ? -1 : gk(a) > gk(b) ? 1 : 0; }); l.gk = gk; }
|
||||
return l;
|
||||
}
|
||||
function th(label, key, cls) { var s = S.sort === key ? (S.dir === 1 ? 'ascending' : 'descending') : 'none'; return '<th class="' + (cls || '') + '" aria-sort="' + s + '"><button class="sort" type="button" data-sort="' + key + '">' + label + '</button></th>'; }
|
||||
function rowMenu(s) {
|
||||
return '<div class="menu-wrap"><button class="btn btn-sm btn-icon" type="button" data-menu="rm' + s.id + '" aria-haspopup="menu" aria-expanded="false" aria-label="Actions for ' + x.esc(s.color + ' ' + s.brand) + '">⋯</button><div class="menu" id="rm' + s.id + '" role="menu">' +
|
||||
'<button role="menuitem" type="button" data-spool="' + s.id + '">Open<span class="hint">↵</span></button><button role="menuitem" type="button" data-spool="' + s.id + '" data-tab-open="weigh">Weigh</button><button role="menuitem" type="button" data-spool="' + s.id + '" data-tab-open="move">' + (s.cont ? 'Move / unload' : 'Load into printer') + '</button><button role="menuitem" type="button" data-spool="' + s.id + '" data-tab-open="edit">Edit</button>' +
|
||||
(s.label ? '' : '<button role="menuitem" type="button" data-fake="Label PDF generated for #' + s.id + '">Print label</button>') + '<hr><button role="menuitem" type="button" class="danger" data-spool="' + s.id + '" data-then-delete>Delete…</button></div></div>';
|
||||
}
|
||||
function rows(l) {
|
||||
var out = '', last = null, cols = S.cols === 'stock' ? 5 : 5;
|
||||
l.forEach(function (s) {
|
||||
if (l.gk) { var gv = l.gk(s); if (gv !== last) { var n = l.filter(function (y) { return l.gk(y) === gv; }), tot = n.reduce(function (a, y) { return a + y.rem; }, 0); out += '<tr class="grp"><td colspan="' + cols + '">' + x.esc(gv) + ' · ' + n.length + ' spools · ' + x.kg(tot) + '</td></tr>'; last = gv; } }
|
||||
var w = x.where(s), gr = x.gross(s);
|
||||
out += '<tr class="clickable" tabindex="0" data-row-spool="' + s.id + '" aria-label="' + x.esc(s.color + ', ' + x.fullName(s) + ', ' + s.rem + ' grams') + '">' +
|
||||
'<td><div class="spcell">' + x.sw(s.c) + '<span style="min-width:0"><span class="strong">' + x.esc(s.color) + '</span> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">' + x.esc(x.fullName(s)) + '</span></span></div></td>' +
|
||||
'<td><div class="remcell"><div class="top"><b class="num"' + (x.low(s) ? ' style="color:var(--status-warning)"' : '') + '>' + x.g(s.rem) + '</b><span class="sub" style="display:inline">of ' + x.g(s.tot) + '</span></div>' + x.meter(s) + '</div></td>' +
|
||||
(S.cols === 'stock'
|
||||
? '<td class="hide-m">' + (w.loaded ? '<span class="st run" style="font-weight:500">' + x.esc(w.short) + '</span>' : '<span class="muted">' + x.esc(w.text) + '</span>') + '</td><td class="r hide-m"><span class="num">' + x.usd(x.value(s)) + '</span><span class="sub">' + x.usd(s.kg) + '/kg</span></td>'
|
||||
: '<td class="r hide-m">' + (s.tare == null ? '<span class="prov warn"><i>?</i>unknown</span>' : '<span class="num">' + x.g(s.tare) + '</span><span class="sub">' + x.tareProv(s) + '</span>') + '</td><td class="r hide-m">' + (gr == null ? '<span class="muted">unknown</span>' : '<b class="num">' + x.g(gr) + '</b><span class="sub">' + x.remProv(s) + '</span>') + '</td>') +
|
||||
'<td><div class="rowact">' + (s.tare != null ? '<button class="btn btn-sm hide-m" type="button" data-spool="' + s.id + '" data-tab-open="weigh">⚖ Weigh</button>' : '') + rowMenu(s) + '</div></td></tr>';
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function render() {
|
||||
var l = list(), all = SPOOLS.length;
|
||||
x.D.list = l.map(function (s) { return s.id; });
|
||||
var chips = [];
|
||||
if (S.view !== 'all') chips.push(['view', viewFn(S.view)[1]]);
|
||||
if (S.mat) chips.push(['mat', S.mat]);
|
||||
if (S.q) chips.push(['q', '“' + S.q + '”']);
|
||||
var weighable = l.filter(function (s) { return s.tare != null; });
|
||||
return '<header class="page-head"><div><h1>Filament</h1><p class="sum"><b>' + CANARY.spools + '</b> spools · <b>' + CANARY.kg.toFixed(2) + ' kg</b> net · <b>' + x.usd(CANARY.value) + '</b> · sample page of ' + all + '</p></div>' +
|
||||
'<div class="actions"><div class="menu-wrap"><button class="btn" type="button" data-menu="pgmenu" aria-haspopup="menu" aria-expanded="false">More ▾</button><div class="menu" id="pgmenu" role="menu"><button role="menuitem" type="button" data-fake="Labels PDF for 4 spools generated">Print ' + SPOOLS.filter(function (s) { return !s.label; }).length + ' missing labels</button><button role="menuitem" type="button" data-soon="Lists">Manage lists (brands, materials…)</button></div></div>' +
|
||||
'<button class="btn btn-primary primary-desktop" type="button" data-add-spool>+ Add spool</button></div></header>' +
|
||||
'<section class="toolbar nm-raised" aria-label="Find spools">' +
|
||||
'<div class="tabs" role="tablist" aria-label="Saved views">' + VIEWS.map(function (v) { var n = SPOOLS.filter(v[2]).length; return '<button role="tab" type="button" data-view="' + v[0] + '" aria-selected="' + (S.view === v[0]) + '">' + v[1] + ' <span class="n' + ((v[0] === 'low' || v[0] === 'tare') && n ? ' warn' : '') + '">' + n + '</span></button>'; }).join('') + '</div>' +
|
||||
'<div class="row1"><label class="searchbox">' + X.ICON.search + '<input class="input" id="fq" type="search" value="' + x.esc(S.q) + '" placeholder="Filter by color, brand, #id, location…" aria-label="Filter spools"></label>' +
|
||||
'<label class="ctl">Material <select class="select" id="fmat"><option value="">Any</option>' + ['PLA', 'PETG', 'ASA', 'TPU', 'PA6', 'ABS'].map(function (m) { return '<option' + (S.mat === m ? ' selected' : '') + '>' + m + '</option>'; }).join('') + '</select></label>' +
|
||||
'<label class="ctl">Group <select class="select" id="fgroup"><option value="none">None</option><option value="mat"' + (S.group === 'mat' ? ' selected' : '') + '>Material</option><option value="loc"' + (S.group === 'loc' ? ' selected' : '') + '>Location</option></select></label>' +
|
||||
'<div class="tabs" role="group" aria-label="Columns"><button type="button" data-cols="stock" aria-selected="' + (S.cols === 'stock') + '">Stock</button><button type="button" data-cols="weights" aria-selected="' + (S.cols === 'weights') + '">Weights</button></div></div>' +
|
||||
(chips.length ? '<div class="fchips" aria-label="Active filters"><span class="sub" style="display:inline">' + l.length + ' of ' + all + ' shown</span>' + chips.map(function (c) { return '<span class="fchip">' + x.esc(c[1]) + '<button type="button" data-unchip="' + c[0] + '" aria-label="Remove filter ' + x.esc(c[1]) + '">✕</button></span>'; }).join('') + '<button class="linkish small" type="button" data-unchip="all">Clear all</button></div>' : '') +
|
||||
'</section>' +
|
||||
(weighable.length > 1 && S.view !== 'all' ? '<div class="weigh-hint"><b>⚖</b><span style="flex:1">Weighing these? Step through all ' + weighable.length + ' with a scale: each result shows before you save, then the next one opens.</span><button class="btn btn-sm btn-primary" type="button" data-weigh-session>Start weighing</button></div>' : '') +
|
||||
'<section class="nm-raised table-card" aria-label="Spools">' + (l.length ? '<div class="table-scroll"><table class="tbl"><thead><tr>' + th('Spool', 'name') + th('Remaining <abbr title="net filament">net</abbr>', 'rem') +
|
||||
(S.cols === 'stock' ? '<th class="hide-m">Location</th>' + th('Value', 'value', 'r hide-m') : '<th class="r hide-m">Tare</th>' + th('Gross (scale)', 'gross', 'r hide-m')) + '<th class="r"><span class="sr-only">Actions</span></th></tr></thead><tbody>' + rows(l) + '</tbody></table></div>' +
|
||||
'<div class="table-foot"><span class="muted">Click a row or press ↵ to open · <span class="kbd">j</span>/<span class="kbd">k</span> step through in the panel</span><span class="muted">1–' + l.length + ' of ' + (S.view === 'all' && !S.q && !S.mat ? CANARY.spools : l.length) + '</span></div>'
|
||||
: '<div class="empty"><h3>No spools match</h3><p>' + (S.q ? 'Nothing matches “' + x.esc(S.q) + '”' : 'Nothing in this view') + (S.mat ? ' in ' + S.mat : '') + '.</p><button class="btn btn-sm" type="button" data-unchip="all">Clear filters</button></div>') + '</section>' +
|
||||
'<button class="btn btn-primary fab" type="button" data-add-spool aria-label="Add spool">+</button>';
|
||||
}
|
||||
function redraw(keepFocus) {
|
||||
var a = document.activeElement, id = a && a.id, pos = a && a.selectionStart;
|
||||
document.getElementById('page').innerHTML = render();
|
||||
if (keepFocus && id && document.getElementById(id)) { var el = document.getElementById(id); el.focus(); if (pos != null && el.setSelectionRange) el.setSelectionRange(pos, pos); }
|
||||
var p = new URLSearchParams(); if (S.view !== 'all') p.set('f', S.view); if (S.q) p.set('q', S.q);
|
||||
history.replaceState(history.state, '', location.pathname + (p.toString() ? '?' + p : '') + location.hash);
|
||||
if (x.D.kind === 'spool') document.querySelectorAll('[data-row-spool="' + x.D.id + '"]').forEach(function (r) { r.classList.add('is-selected'); });
|
||||
}
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if ((a = t.closest('[data-view]'))) { S.view = a.getAttribute('data-view'); return redraw(); }
|
||||
if ((a = t.closest('[data-cols]'))) { S.cols = a.getAttribute('data-cols'); return redraw(); }
|
||||
if ((a = t.closest('[data-sort]'))) { var k = a.getAttribute('data-sort'); S.dir = S.sort === k ? -S.dir : 1; S.sort = k; return redraw(); }
|
||||
if ((a = t.closest('[data-unchip]'))) { var c = a.getAttribute('data-unchip'); if (c === 'view' || c === 'all') S.view = 'all'; if (c === 'mat' || c === 'all') S.mat = ''; if (c === 'q' || c === 'all') S.q = ''; return redraw(); }
|
||||
if (t.closest('[data-weigh-session]')) { var first = list().filter(function (s) { return s.tare != null; }); x.D.list = first.map(function (s) { return s.id; }); x.openDrawer('spool', first[0].id, 'weigh'); return; }
|
||||
if ((a = t.closest('[data-then-delete]'))) { setTimeout(function () { var b = document.querySelector('#drawer [data-delete]'); if (b) b.click(); }, 50); return; }
|
||||
if (t.closest('button, a, .menu')) return;
|
||||
if ((a = t.closest('[data-row-spool]'))) { x.D.list = list().map(function (s) { return s.id; }); x.openDrawer('spool', +a.getAttribute('data-row-spool')); }
|
||||
});
|
||||
document.addEventListener('input', function (e) {
|
||||
if (e.target.id === 'fq') { S.q = e.target.value; redraw(true); }
|
||||
});
|
||||
document.addEventListener('change', function (e) {
|
||||
if (e.target.id === 'fmat') { S.mat = e.target.value; redraw(); }
|
||||
if (e.target.id === 'fgroup') { S.group = e.target.value; redraw(); }
|
||||
});
|
||||
X.init({ name: 'filament', refresh: function () { redraw(); } }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
<style>
|
||||
.qcard { padding: var(--space-4) var(--space-5); display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(260px, 1.1fr); gap: var(--space-4); align-items: start; }
|
||||
.qcard.is-done { opacity: .55; }
|
||||
.qcard .what { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.qcard h3 { margin: 0; font-size: 15px; }
|
||||
.fixes { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.fix { display: flex; flex-direction: column; gap: 6px; }
|
||||
.fix .lab { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps); color: var(--status-warning); font-weight: var(--fw-bold); }
|
||||
.sug { display: flex; gap: 10px; align-items: center; padding: 8px 10px; border-radius: var(--radius-md); border: 1px dashed var(--text-accent); background: var(--accent-wash); font-size: 13px; }
|
||||
.batch { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; padding: var(--space-3) var(--space-4); }
|
||||
.chk { width: 20px; height: 20px; accent-color: var(--text-accent); }
|
||||
.filters { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: center; }
|
||||
.filters .select, .filters .input { width: auto; min-height: 36px; padding: 6px 10px; font-size: 13px; }
|
||||
.jobcell { min-width: 200px; }
|
||||
@media (max-width: 900px) { .qcard { grid-template-columns: auto minmax(0, 1fr); } .qcard .fixes { grid-column: 1 / -1; } }
|
||||
@media (max-width: 760px) { .hide-m { display: none; } .jobcell { min-width: 0; } }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var q = new URLSearchParams(location.search);
|
||||
var S = { tab: q.get('tab') || (x.printsNeeding().length ? 'needs' : 'all'), printer: '', status: '', q: '', sel: {}, done: {} };
|
||||
function fixUI(p) {
|
||||
var out = '';
|
||||
if (p.grams == null) out += '<div class="fix"><span class="lab">! Filament weight</span><span class="row" style="gap:8px"><input class="input num" style="max-width:130px;min-height:36px" placeholder="grams" inputmode="numeric" aria-label="Grams used by ' + x.esc(p.job) + '"><button class="btn btn-sm" type="button" data-done="' + p.id + '" data-msg="Saved weight for ' + x.esc(p.job) + ' · remembered for ' + x.esc(p.key) + '">Save</button></span><span class="sub">From the slicer. Remembered for this gcode path.</span></div>';
|
||||
var slots = p.filaments ? p.filaments.filter(function (f) { return f.spool == null; }) : (p.spool == null ? [{ suggested: p.suggested, slot: null, grams: p.grams }] : []);
|
||||
slots.forEach(function (f) {
|
||||
var s = f.suggested && x.spool(f.suggested);
|
||||
out += '<div class="fix"><span class="lab">! Spool' + (f.slot != null ? ' for color ' + (f.slot + 1) + ' <span class="swatch swatch-sm" style="vertical-align:-3px"><i style="background:' + f.hex + '"></i></span>' : '') + '</span>' +
|
||||
(s ? '<div class="sug">' + x.sw(s.c, 'swatch-sm') + '<span style="flex:1;min-width:0"><b>' + x.esc(x.short(s)) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">Same color, loaded in ' + x.esc(x.where(s).short) + '. Not charged until you confirm.</span></span><button class="btn btn-sm btn-primary" type="button" data-done="' + p.id + '" data-msg="Attributed ' + x.esc(p.job) + ' to ' + x.esc(x.short(s)) + (f.grams ? ' · ' + x.g(f.grams) + ' deducted' : '') + '">Use</button></div>' : '') +
|
||||
'<select class="select" style="min-height:36px" aria-label="Pick spool"><option>' + (s ? 'Or pick another…' : 'Pick a spool…') + '</option>' + SPOOLS.slice(0, 10).map(function (sp) { return '<option>' + x.g(sp.rem) + ' · ' + x.esc(x.short(sp)) + ' · ' + sp.mat + '</option>'; }).join('') + '</select></div>';
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function needsTab() {
|
||||
var l = x.printsNeeding();
|
||||
if (!l.length) return '<div class="nm-raised empty"><h3>✓ Every print is attributed</h3><p>New prints appear here when they end without a spool or weight.</p></div>';
|
||||
var nsel = Object.keys(S.sel).filter(function (k) { return S.sel[k]; }).length;
|
||||
return '<div class="batch nm-inset"><label class="row small" style="gap:8px"><input type="checkbox" class="chk" data-selall ' + (nsel === l.length ? 'checked' : '') + '> Select all ' + l.length + '</label><span class="spacer"></span><span class="small muted">' + nsel + ' selected</span><button class="btn btn-sm" type="button" ' + (nsel ? 'data-fake="Attributed ' + nsel + ' prints to one spool"' : 'disabled') + '>Attribute selected to one spool…</button></div>' +
|
||||
'<p class="small muted" style="margin:0">Oldest first. Until these have a spool and a weight, nothing is deducted and cost stays incomplete.</p>' +
|
||||
'<div class="stack" style="gap:12px">' + l.slice().reverse().map(function (p) {
|
||||
var pr = x.printer(p.printer), done = S.done[p.id];
|
||||
return '<article class="qcard nm-raised' + (done ? ' is-done' : '') + '"><input type="checkbox" class="chk" data-sel="' + p.id + '" aria-label="Select ' + x.esc(p.job) + '" ' + (S.sel[p.id] ? 'checked' : '') + '>' +
|
||||
'<div class="what"><h3><button class="linkish" type="button" data-print="' + p.id + '" style="text-decoration:none;color:var(--text-primary)">' + x.esc(p.job) + '</button></h3><span class="sub">' + x.esc(pr.name) + ' · ' + x.when(p.end || p.start) + '</span><span class="row small" style="gap:12px;margin-top:4px">' + x.printSt(p) + '<span>' + x.durCell(p).replace(/<span class="sub[^"]*">/g, ' <span class="sub" style="display:inline">') + '</span>' + (p.grams != null ? '<span><b>' + x.g(p.grams) + '</b> ' + x.weightProv(p) + '</span>' : '') + '</span>' +
|
||||
(p.filaments ? '<span class="sub">' + p.filaments.length + ' colors · ' + p.filaments.filter(function (f) { return f.spool; }).length + ' attributed</span>' : '') + '</div>' +
|
||||
'<div class="fixes">' + (done ? '<span class="st ok">Done</span>' : fixUI(p)) + '</div></article>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
function allTab(filter) {
|
||||
var l = PRINTS.filter(function (p) { return filter(p) && (!S.printer || p.printer === +S.printer) && (!S.status || p.status === S.status) && (!S.q || (p.job + ' ' + p.key).toLowerCase().indexOf(S.q.toLowerCase()) >= 0); });
|
||||
var head = '<div class="filters"><input class="input" id="pq" type="search" placeholder="Job name or path…" value="' + x.esc(S.q) + '" aria-label="Filter prints"><select class="select" id="pp" aria-label="Printer"><option value="">All printers</option>' + PRINTERS.map(function (p) { return '<option value="' + p.id + '"' + (+S.printer === p.id ? ' selected' : '') + '>' + x.esc(p.name) + '</option>'; }).join('') + '</select>' +
|
||||
'<select class="select" id="ps" aria-label="Status"><option value="">Any status</option>' + ['running', 'finished', 'failed', 'unknown'].map(function (s) { return '<option' + (S.status === s ? ' selected' : '') + '>' + s + '</option>'; }).join('') + '</select><span class="small muted">' + l.length + ' prints</span></div>';
|
||||
if (!l.length) return head + '<div class="nm-raised empty"><h3>No prints match</h3><p>Change the printer, status or search.</p></div>';
|
||||
return head + '<section class="nm-raised table-card"><div class="table-scroll"><table class="tbl"><thead><tr><th>Job</th><th>Status</th><th class="r hide-m">Duration</th><th class="r">Filament</th><th class="hide-m">Spool</th><th class="r hide-m">Cost</th><th class="hide-m">Use</th></tr></thead><tbody>' +
|
||||
l.map(function (p) { var pr = x.printer(p.printer), n = x.needs(p); return '<tr class="clickable" tabindex="0" data-row-print="' + p.id + '"><td><div class="jobcell"><span class="strong">' + x.esc(p.job) + '</span><span class="sub">' + x.esc(pr.model) + ' · ' + x.when(p.start) + '</span></div></td><td>' + x.printSt(p) + (n.length ? '<span class="sub prov warn"><i>!</i>needs ' + n.map(function (i) { return i.k; }).join(' + ') + '</span>' : '') + '</td><td class="r hide-m">' + x.durCell(p) + '</td><td class="r">' + (p.grams == null ? '<span class="muted">—</span>' : '<b class="num">' + x.g(p.grams) + '</b><span class="sub">' + x.weightProv(p) + '</span>') + '</td><td class="hide-m">' + x.spoolLinks(p) + '</td><td class="r hide-m">' + (p.cost == null ? '<span class="muted">pending</span>' : x.usd(p.cost)) + '</td><td class="hide-m">' + (p.use ? '<span class="small">' + p.use + '</span>' : '<span class="muted small">not recorded</span>') + '</td></tr>'; }).join('') +
|
||||
'</tbody></table></div><div class="table-foot"><span class="muted">Durations marked ≤ span a connection gap and are upper bounds. Hover for why.</span><span class="muted">' + l.length + ' of ' + PRINTS.length + '</span></div></section>';
|
||||
}
|
||||
function render() {
|
||||
var n = x.printsNeeding().length, run = PRINTS.filter(function (p) { return p.status === 'running'; }).length;
|
||||
var body = S.tab === 'needs' ? needsTab() : S.tab === 'all' ? allTab(function () { return true; }) : S.tab === 'running' ? allTab(function (p) { return p.status === 'running'; }) : '<div class="nm-raised empty"><h3>No archived prints</h3><p>Archiving hides a print from history. Its grams and cost still count.</p></div>';
|
||||
return '<header class="page-head"><div><h1>Prints</h1><p class="sum"><b>' + PRINTS.length + '</b> captured (sample) · <b>' + run + '</b> running · <b>' + n + '</b> need attention</p></div></header>' +
|
||||
'<div class="tabs" role="tablist" aria-label="Print lists">' + [['needs', 'Needs attention', n, 1], ['all', 'All', PRINTS.length], ['running', 'Running', run], ['archived', 'Archived', 0]].map(function (t) { return '<button role="tab" type="button" data-ptab="' + t[0] + '" aria-selected="' + (S.tab === t[0]) + '">' + t[1] + ' <span class="n' + (t[3] && t[2] ? ' warn' : '') + '">' + t[2] + '</span></button>'; }).join('') + '</div>' +
|
||||
'<div class="stack" style="gap:16px">' + body + '</div>';
|
||||
}
|
||||
function redraw(focusId) {
|
||||
var pos = focusId && document.getElementById(focusId) && document.getElementById(focusId).selectionStart;
|
||||
document.getElementById('page').innerHTML = render();
|
||||
if (focusId && document.getElementById(focusId)) { var el = document.getElementById(focusId); el.focus(); if (pos != null) el.setSelectionRange(pos, pos); }
|
||||
history.replaceState(history.state, '', location.pathname + '?tab=' + S.tab + location.hash);
|
||||
}
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if ((a = t.closest('[data-ptab]'))) { S.tab = a.getAttribute('data-ptab'); return redraw(); }
|
||||
if ((a = t.closest('[data-done]'))) { var id = +a.getAttribute('data-done'), pp = x.print(id); if (/weight/.test(a.getAttribute('data-msg'))) { pp.grams = 18; pp.wsrc = 'typed'; } else if (pp.filaments) { pp.filaments.forEach(function (f) { if (f.spool == null) f.spool = f.suggested || 52; }); } else { pp.spool = pp.suggested || 52; } X.toast(a.getAttribute('data-msg') + ' <a href="#print=' + id + '" data-print="' + id + '">View</a>'); return redraw(); }
|
||||
if (t.matches('[data-selall]')) { x.printsNeeding().forEach(function (p) { S.sel[p.id] = t.checked; }); return redraw(); }
|
||||
if (t.matches('[data-sel]')) { S.sel[t.getAttribute('data-sel')] = t.checked; return redraw(); }
|
||||
if (t.closest('button, a, input, select')) return;
|
||||
if ((a = t.closest('[data-row-print]'))) x.openDrawer('print', +a.getAttribute('data-row-print'));
|
||||
});
|
||||
document.addEventListener('input', function (e) { if (e.target.id === 'pq') { S.q = e.target.value; redraw('pq'); } });
|
||||
document.addEventListener('change', function (e) { if (e.target.id === 'pp') { S.printer = e.target.value; redraw(); } if (e.target.id === 'ps') { S.status = e.target.value; redraw(); } });
|
||||
X.init({ name: 'prints', refresh: function () { redraw(); } }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
<style>
|
||||
.jump { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }
|
||||
.jump a { display: inline-flex; gap: 8px; align-items: center; padding: 6px 12px; border-radius: var(--radius-pill); border: 1px solid var(--border-control); text-decoration: none; color: var(--text-primary); font-size: 13px; background: var(--surface-raised); box-shadow: var(--nm-raised-1); }
|
||||
.jump a:hover { border-color: var(--text-accent); }
|
||||
.pgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: var(--space-6); align-items: start; }
|
||||
.pcard { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); scroll-margin-top: 16px; }
|
||||
.pcard.attn-card { border: 2px solid var(--status-warning); }
|
||||
.phead { display: flex; gap: var(--space-3); align-items: flex-start; }
|
||||
.phead h2 { margin: 0; font-size: var(--fs-h3); line-height: var(--lh-snug); }
|
||||
.now { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px var(--space-3); padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); width: 100%; font-size: 13px; }
|
||||
.now:hover { border-color: var(--text-accent); }
|
||||
.now:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
|
||||
.now.stale { border: 1px dashed var(--status-warning); }
|
||||
.cam { aspect-ratio: 16 / 7; border-radius: var(--radius-md); background: radial-gradient(ellipse at 50% 95%, #5a4b3a 0 30%, transparent 31%), linear-gradient(180deg, #1c1d22 0%, #2b2c33 60%, #3a3326 100%); border: 1px solid var(--border-control); position: relative; overflow: hidden; }
|
||||
.cam::after { content: ""; position: absolute; left: 34%; right: 34%; bottom: 18%; height: 26%; background: repeating-linear-gradient(0deg, #6b8fd6 0 3px, #5a7cc2 3px 6px); border-radius: 4px 4px 0 0; }
|
||||
.cam .stamp { position: absolute; left: 8px; top: 6px; font-size: var(--fs-caption); color: #f4f4f6; background: rgba(0,0,0,.55); padding: 2px 8px; border-radius: 99px; z-index: 1; }
|
||||
.cam-off { padding: var(--space-3); border-radius: var(--radius-md); border: 1px dashed var(--border-control); text-align: center; color: var(--text-secondary); font-size: var(--fs-label); }
|
||||
.holder { padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); display: flex; flex-direction: column; gap: 6px; }
|
||||
.holder-h { display: flex; gap: var(--space-3); align-items: center; font-size: var(--fs-label); }
|
||||
.fill { display: inline-flex; gap: 3px; }
|
||||
.fill i { width: 14px; height: 8px; border-radius: 2px; border: 1px solid var(--border-control); }
|
||||
.fill i.on { background: var(--text-accent); border-color: var(--text-accent); }
|
||||
.hrow { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; padding: 6px 8px; margin: 0 -8px; border-radius: var(--radius-sm); font-size: 13px; }
|
||||
.hrow:hover { background: var(--accent-wash); }
|
||||
.hrow:focus-visible { outline: var(--focus-width) solid var(--focus-ring); }
|
||||
.lost { padding: var(--space-4); border-radius: var(--radius-md); border: 1px solid var(--status-warning); background: var(--wash-warning); font-size: 13px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.lost h3 { margin: 0; font-size: 14px; color: var(--status-warning); }
|
||||
.lost ul { margin: 0; padding-left: 18px; }
|
||||
details.cfg summary { cursor: pointer; font-size: var(--fs-label); font-weight: var(--fw-bold); }
|
||||
.future { padding: var(--space-6); border: 2px dashed var(--border-control); border-radius: var(--radius-lg); }
|
||||
.fgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: var(--space-4); margin-top: var(--space-4); }
|
||||
.fitem { padding: var(--space-4); border: 1px dashed var(--border-control); border-radius: var(--radius-md); font-size: var(--fs-label); display: flex; flex-direction: column; gap: 6px; }
|
||||
.fitem b { font-size: 13px; }
|
||||
body.dev-only [data-feature] { display: none !important; }
|
||||
body:not(.dev-only) [data-devonly] { display: none !important; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X, FEAT = '<span class="prov info" title="Only on the printer-connections feature branch"><i>◈</i>feature</span>';
|
||||
function lastPrint(pr) { return PRINTS.filter(function (p) { return p.printer === pr.id && p.status !== 'running'; })[0]; }
|
||||
function holder(c) {
|
||||
var sp = x.contents(c.id), free = c.cap - sp.length;
|
||||
return '<div class="holder"><div class="holder-h"><b>' + x.esc(c.label) + '</b><span class="fill" aria-label="' + sp.length + ' of ' + c.cap + ' in use">' + Array.apply(null, Array(c.cap)).map(function (_, i) { return '<i class="' + (i < sp.length ? 'on' : '') + '"></i>'; }).join('') + '</span><span class="muted">' + sp.length + ' / ' + c.cap + '</span><span class="spacer"></span>' +
|
||||
(free > 0 ? '<button class="btn btn-sm" type="button" data-load-into="' + c.id + '" style="min-height:28px;padding:3px 10px">+ Load</button>' : '<span class="sub">full</span>') + '</div>' +
|
||||
(sp.length ? sp.map(function (s) { return '<button class="hrow" type="button" data-spool="' + s.id + '">' + x.sw(s.c, 'swatch-sm') + '<span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><b>' + x.esc(s.color) + '</b> <span class="muted">' + x.esc(s.brand + ' ' + s.mat + (s.type ? ' ' + s.type : '')) + '</span></span><span class="num' + (x.low(s) ? '" style="color:var(--status-warning);font-weight:700">⚠ ' : '">') + x.g(s.rem) + '</span></button>'; }).join('') : '<span class="sub">Empty</span>') + '</div>';
|
||||
}
|
||||
function card(pr) {
|
||||
var open = pr.openPrint && x.print(pr.openPrint), last = lastPrint(pr), lostC = !pr.connected;
|
||||
var conn = lostC
|
||||
? '<div class="lost" role="status"><h3>⚡ Connection lost</h3><span data-feature>Reconnecting. Last contact <b>' + x.ago(pr.age) + '</b>. ' + FEAT + '</span><span data-devonly>Extrudex\'s MQTT session dropped. When it dropped isn\'t recorded on dev.</span>' +
|
||||
'<ul class="small"><li>The printer may still be printing. This is Extrudex\'s connection, not the printer\'s power.</li><li><b>Capture is paused.</b> A job that starts and ends during the outage won\'t be recorded.</li><li>On reconnect: if <i>' + x.esc(pr.job) + '</i> is still running, it stays open with <b>+1 gap</b>; otherwise it closes as <b>unknown</b>.</li></ul></div>'
|
||||
: '<div class="row small" style="gap:8px"><span class="fresh"><span class="dot"></span></span><span data-feature>Ready · last report ' + x.ago(pr.age) + ' ' + FEAT + '</span><span data-devonly>Connected</span></div>';
|
||||
var now = open
|
||||
? '<button class="now' + (pr.stale ? ' stale' : '') + '" type="button" data-print="' + open.id + '"><b>' + x.esc(open.job) + '</b><span class="num">' + x.dur(x.since(open.start)) + '</span>' + (pr.stale ? '<span class="sub" style="grid-column:1/-1">open record · started ' + x.when(open.start).split(' ').pop() + ' · watched until the drop</span><span class="sub" data-feature style="grid-column:1/-1"><span class="prov warn" style="white-space:normal">Last known: printing · ' + x.ago(pr.age) + '. Cached from the lost session, not live.</span></span>' : '<span class="sub">started ' + x.when(open.start).split(' ').pop() + ' · weight read from the 3MF when it ends</span><span class="sub">so far</span>') + '</button>'
|
||||
: (last ? '<button class="now" type="button" data-print="' + last.id + '"><span><span class="sub" style="display:inline">Last · </span><b>' + x.esc(last.job) + '</b></span>' + x.printSt(last) + '<span class="sub">ended ' + x.when(last.end) + '</span><span class="sub">' + x.durCell(last).replace(/<span class="sub[^"]*">[^<]*<\/span>/, '') + '</span></button>' : '');
|
||||
var cam = pr.connected ? '<div class="cam" role="img" aria-label="Camera snapshot from ' + x.esc(pr.name) + '"><span class="stamp">snapshot 09:41:0' + pr.id + '</span></div>' : '<div class="cam-off">Camera unavailable while disconnected</div>';
|
||||
return '<article class="pcard nm-raised' + (lostC ? ' attn-card' : '') + '" id="printer-' + pr.id + '" aria-labelledby="ph' + pr.id + '"><div class="phead"><div style="flex:1;min-width:0"><h2 id="ph' + pr.id + '">' + x.esc(pr.name) + '</h2><span class="sub">Bambu Lab · ' + pr.model + '</span></div>' + x.printerSt(pr) +
|
||||
'<div class="menu-wrap"><button class="btn btn-sm btn-icon" type="button" data-menu="pm' + pr.id + '" aria-haspopup="menu" aria-expanded="false" aria-label="Actions for ' + x.esc(pr.name) + '">⋯</button><div class="menu" id="pm' + pr.id + '" role="menu"><button role="menuitem" type="button" data-fake="Edit connection opens here (admin)">Edit connection</button><button role="menuitem" type="button" data-fake="Manage holders opens here (admin)">Manage holders</button><hr><button role="menuitem" type="button" class="danger" data-fake="Remove printer asks for confirmation first">Remove printer…</button></div></div></div>' +
|
||||
conn + now + cam + '<div class="stack" style="gap:8px"><div class="row small"><b>Loaded filament</b><span class="sub" style="display:inline">recorded by Extrudex, not read from the printer</span></div>' + pr.containers.map(holder).join('') + '</div>' +
|
||||
'<details class="cfg"><summary>Connection details</summary><dl class="kv" style="margin-top:10px"><dt>IP</dt><dd>' + x.esc(pr.ip) + '</dd><dt>Serial</dt><dd>' + x.esc(pr.serial) + '</dd><dt>Access code</dt><dd>✓ stored, never shown</dd>' +
|
||||
'<dt data-feature>Lifecycle</dt><dd data-feature>' + pr.lifecycle + (pr.stale ? ' · <code>stale</code>' : '') + ' ' + FEAT + '</dd><dt data-feature>Session</dt><dd data-feature>#' + pr.session + ' (increments on reconnect)</dd></dl></details></article>';
|
||||
}
|
||||
function render() {
|
||||
var order = PRINTERS.slice().sort(function (a, b) { var r = function (p) { return !p.connected ? 0 : p.state === 'RUNNING' ? 1 : 2; }; return r(a) - r(b); });
|
||||
var nRun = PRINTERS.filter(function (p) { return p.connected && p.state === 'RUNNING'; }).length, nDown = PRINTERS.filter(function (p) { return !p.connected; }).length;
|
||||
return '<header class="page-head"><div><h1>Printers</h1><p class="sum"><b>' + PRINTERS.length + '</b> printers · <b>' + nRun + '</b> printing · <b style="color:var(--status-warning)">' + nDown + '</b> disconnected · polled every 10 s, read-only MQTT</p></div>' +
|
||||
'<div class="actions"><div class="tabs" role="group" aria-label="Data shown"><button type="button" data-ds="dev" aria-selected="' + document.body.classList.contains('dev-only') + '">dev today</button><button type="button" data-ds="feat" aria-selected="' + !document.body.classList.contains('dev-only') + '">+ feature branch</button></div><button class="btn btn-primary primary-desktop" type="button" data-fake="Add printer form opens here (admin)">+ Add printer</button></div></header>' +
|
||||
'<nav class="jump" aria-label="Jump to printer"><span class="small muted">Jump to</span>' + order.map(function (p) { return '<a href="#printer=' + p.id + '">' + x.printerSt(p) + ' ' + x.esc(p.model) + '</a>'; }).join('') + '</nav>' +
|
||||
'<div class="pgrid">' + order.map(card).join('') + '</div>' +
|
||||
'<section class="future" aria-labelledby="fut-h"><h2 id="fut-h" style="margin:0;font-size:var(--fs-h3)"><span class="chip chip-neutral chip-dashed">Future, not built</span> Needs data Extrudex doesn\'t store</h2><div class="fgrid">' +
|
||||
[['Progress % and time left', '<code>mc_percent</code> is parsed but not stored or served. ETA isn\'t read.'], ['Tray position in the AMS', 'Holders store which spools are in them, not which tray. The AMS tray index isn\'t recorded.'], ['Which spool is feeding now', '"Loaded" is recorded; "in use" isn\'t. Hence "not confirmed in use".'], ['Nozzle / bed temperature, layer', 'Not parsed from the report.'], ['AMS remaining % and humidity', 'Not read. <code>remain</code> only means something for RFID spools.'], ['Alert when MQTT drops', 'Only in container logs today. Dev doesn\'t store when it dropped.']].map(function (f) { return '<div class="fitem"><b>' + f[0] + '</b><span class="muted">' + f[1] + '</span></div>'; }).join('') + '</div></section>';
|
||||
}
|
||||
function focusPrinter() {
|
||||
var m = /^#printer=(\d+)/.exec(location.hash); if (!m) return;
|
||||
var el = document.getElementById('printer-' + m[1]); if (!el) return;
|
||||
el.scrollIntoView({ block: 'start', behavior: 'smooth' }); el.classList.remove('is-flash'); void el.offsetWidth; el.classList.add('is-flash');
|
||||
}
|
||||
document.addEventListener('click', function (e) {
|
||||
var a = e.target.closest('[data-ds]'); if (a) { document.body.classList.toggle('dev-only', a.getAttribute('data-ds') === 'dev'); document.getElementById('page').innerHTML = render(); }
|
||||
});
|
||||
window.addEventListener('hashchange', focusPrinter);
|
||||
X.init({ name: 'printers', after: function () { setTimeout(focusPrinter, 60); }, refresh: function () { document.getElementById('page').innerHTML = render(); } }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
<style>
|
||||
.map { padding: var(--space-5); }
|
||||
.map svg { width: 100%; height: auto; display: block; }
|
||||
.map .node rect { fill: var(--surface-raised); stroke: var(--border-control); stroke-width: 1.5; }
|
||||
.map .node.hub rect { fill: var(--accent-wash); stroke: var(--text-accent); stroke-width: 2; }
|
||||
.map .node.page rect { stroke: var(--text-primary); }
|
||||
.map text { fill: var(--text-primary); font-family: var(--font-mono); font-size: 13px; }
|
||||
.map text.s { fill: var(--text-secondary); font-size: 11px; }
|
||||
.map .e { stroke: var(--text-secondary); stroke-width: 1.5; fill: none; }
|
||||
.map .e.hot { stroke: var(--text-accent); stroke-width: 2; }
|
||||
.map .lbl { fill: var(--text-secondary); font-size: 10.5px; }
|
||||
.map a:focus-visible rect { stroke: var(--focus-ring); stroke-width: 3; }
|
||||
.flows { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 330px), 1fr)); gap: var(--space-4); }
|
||||
.flow { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.flow h3 { margin: 0; font-size: 15px; }
|
||||
.flow ol { margin: 0; padding-left: 20px; font-size: 13px; color: var(--text-secondary); display: flex; flex-direction: column; gap: 4px; }
|
||||
.flow ol b { color: var(--text-primary); }
|
||||
.flow .btn { align-self: flex-start; }
|
||||
.compare { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: var(--space-4); }
|
||||
.compare > div { padding: var(--space-4); font-size: 13px; }
|
||||
.compare h3 { margin: 0 0 8px; font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); }
|
||||
.compare ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
function node(x, y, w, h, t, s, cls, href) {
|
||||
var inner = '<g class="node ' + cls + '"><rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" rx="14"/><text x="' + (x + 16) + '" y="' + (y + 26) + '" font-weight="700">' + t + '</text>' + s.map(function (l, i) { return '<text class="s" x="' + (x + 16) + '" y="' + (y + 46 + i * 16) + '">' + l + '</text>'; }).join('') + '</g>';
|
||||
return href ? '<a href="' + href + '">' + inner + '</a>' : inner;
|
||||
}
|
||||
function edge(d, hot, lx, ly, label) { return '<path class="e' + (hot ? ' hot' : '') + '" d="' + d + '" marker-end="url(#ar' + (hot ? 'h' : '') + ')"/>' + (label ? '<text class="lbl" x="' + lx + '" y="' + ly + '">' + label + '</text>' : ''); }
|
||||
function svg() {
|
||||
return '<svg viewBox="0 -50 1100 600" role="img" aria-labelledby="map-t map-d"><title id="map-t">Extrudex iteration 2 flow map</title><desc id="map-d">Four pages share one navigation, a command palette and a detail panel. The Dashboard links into each page\'s filtered views; spools, prints and holders open in the panel from any page.</desc>' +
|
||||
'<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 10 5 0 10z" fill="var(--text-secondary)"/></marker><marker id="arh" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 10 5 0 10z" fill="var(--text-accent)"/></marker></defs>' +
|
||||
node(30, 30, 230, 110, 'Dashboard', ['“Needs you” queue', 'stock · recent prints', 'printers · running low'], 'page', '01-dashboard.html') +
|
||||
node(310, 30, 230, 110, 'Filament', ['saved views + counts', 'Stock / Weights columns', 'weigh session'], 'page', '02-filament.html') +
|
||||
node(590, 30, 230, 110, 'Prints', ['Needs attention queue', 'All · Running · Archived', 'batch attribute'], 'page', '03-prints.html') +
|
||||
node(870, 30, 200, 110, 'Printers', ['urgency order', 'holders + Load', 'connection state'], 'page', '04-printers.html') +
|
||||
node(250, 300, 600, 130, 'Detail panel (#spool= · #print= · #load=)', ['Spool: Overview · Weigh · Move · Edit (+ 409 / 400 states)', 'Print: what it needs, fixed in place · provenance explained', 'Load: pick a stored spool for a holder with space', 'Opens over any page · Esc / back closes · ↑↓ / j k step through the list'], 'hub', '02-filament.html#spool=47') +
|
||||
node(30, 450, 300, 90, '⌘K / “/” palette', ['spools · prints · printers', '“weigh copper” jumps straight in'], 'hub', null) +
|
||||
node(770, 450, 300, 90, 'Toasts + badges', ['every save confirms what changed', 'nav counts update immediately'], '', null) +
|
||||
edge('M262 85 L306 85', 1, 262, 76, 'views') +
|
||||
edge('M180 30 C180 -18 705 -18 705 26', 1, 395, -8, 'Review N prints') +
|
||||
edge('M110 30 C110 -38 970 -38 970 26', 1, 520, -30, 'Open printer') +
|
||||
edge('M145 140 C145 230 330 250 400 298', 1, 150, 225, 'spool / print rows') +
|
||||
edge('M425 140 L470 298', 1, 452, 230, 'row / ↵') +
|
||||
edge('M705 140 L640 298', 1, 680, 230, 'row / fix') +
|
||||
edge('M970 140 C970 230 860 270 800 298', 1, 900, 235, 'spool · + Load') +
|
||||
edge('M180 450 C180 400 240 380 248 380', 0, 120, 420, 'open anything') +
|
||||
edge('M850 380 C900 400 920 420 920 448', 0, 930, 415, 'after save') +
|
||||
'</svg>';
|
||||
}
|
||||
var FLOWS = [
|
||||
['Morning check (≈1 min)', ['<b>Dashboard</b> opens on “Needs you”', 'Review 3 → <b>Use</b> the same-color suggestion', 'Badge on <b>Prints</b> drops as each one clears'], '01-dashboard.html', 'Start on the Dashboard'],
|
||||
['Weigh the low spools', ['<b>Filament → Low</b> view', '<b>Start weighing</b>: type the scale reading', 'See before → after, then <b>Save & weigh next</b>'], '02-filament.html?f=low', 'Open low stock'],
|
||||
['Load a spool into a printer', ['<b>Printers</b> → A1 AMS Lite → <b>+ Load</b>', 'Filter stored spools, pick one', 'Toast confirms; the holder fill updates'], '04-printers.html#printer=3', 'Open A1'],
|
||||
['Find anything', ['Press <b>/</b> or <b>⌘K</b> on any page', 'Type <i>copper</i>, <i>#47</i> or <i>weigh black</i>', '↵ opens the panel right there'], '02-filament.html', 'Try it on Filament'],
|
||||
['Someone changed it first (409)', ['Spool #47 → <b>Edit</b>', 'Demo state: <b>409 conflict</b>', 'Side-by-side; reapply your change, or discard it'], '02-filament.html#spool=47&tab=edit', 'Open the editor'],
|
||||
['A spool with no tare', ['<b>Tare unknown</b> view (3)', 'Weigh tab explains why it can\'t weigh yet', 'Pick a spool type, or weigh the empty spool'], '02-filament.html?f=tare#spool=44&tab=weigh', 'Open #44'],
|
||||
['A printer drops off', ['<b>Printers</b>: A1 sits first with a warning border', 'Toggle <b>dev today / + feature branch</b>', 'What happens on reconnect is spelled out'], '04-printers.html#printer=3', 'Open Printers'],
|
||||
['When the data is not live', ['Mockup pill (bottom left) → <b>Simulate polling failure</b>', 'Header turns amber, banner says the data\'s age', '<b>Replay first load</b> shows skeletons'], '01-dashboard.html', 'Open Dashboard']
|
||||
];
|
||||
function render() {
|
||||
return '<header class="page-head"><div><h1>Flow map</h1><p class="sum">Iteration 2 · how the screens connect, and <b>8 flows</b> to click through</p></div><a class="btn" href="../../screens/01-dashboard.html">Compare with iteration 1 →</a></header>' +
|
||||
'<section class="map nm-raised" aria-label="Flow diagram">' + svg() + '</section>' +
|
||||
'<section aria-labelledby="fl-h" class="stack" style="gap:12px"><h2 class="section" id="fl-h" style="margin:0">Try these flows</h2><div class="flows">' + FLOWS.map(function (f) { return '<article class="flow nm-raised"><h3>' + f[0] + '</h3><ol>' + f[1].map(function (s) { return '<li>' + s + '</li>'; }).join('') + '</ol><a class="btn btn-sm" href="' + f[2] + '">' + f[3] + ' →</a></article>'; }).join('') + '</div></section>' +
|
||||
'<section class="compare" aria-label="What changed"><div class="nm-inset"><h3>Iteration 1</h3><ul><li>“Prints” and “All prints” were dead ends</li><li>Edit left the list for a separate page</li><li>A spool in an AMS slot or low-stock list went nowhere</li><li>Every data point was a loud chip, so a legend was needed</li><li>No freshness, loading or error state</li><li>Invented “slot 2” positions the database doesn\'t store</li></ul></div>' +
|
||||
'<div class="nm-raised"><h3>Iteration 2</h3><ul><li>Every spool, print and holder opens one shared panel, deep-linkable</li><li>Dashboard leads with what needs doing, each item one click from the fix</li><li>Chips only for state; provenance is quiet text with a tooltip</li><li>Palette, badges, toasts, j/k stepping, Esc/back to close</li><li>“Live · updated 3s ago”, with an honest offline state</li><li>Holders show fill (“3 / 4”), not tray positions</li></ul></div></section>';
|
||||
}
|
||||
X.init({ name: 'map' }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -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
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 '<span class="swatch ' + (cls || '') + '" aria-hidden="true">' + colors.map(function (h) { return '<i style="background:' + h + '"></i>'; }).join('') + '</span>'; }
|
||||
function meter(s) {
|
||||
var pct = Math.max(0, Math.min(100, s.rem / s.tot * 100)), tick = Math.min(100, LOW / s.tot * 100);
|
||||
return '<div class="meter' + (low(s) ? ' is-low' : '') + '" role="meter" aria-valuemin="0" aria-valuemax="' + s.tot + '" aria-valuenow="' + s.rem + '" aria-label="' + esc(s.color) + ' remaining"><span style="width:' + pct + '%"></span><i class="tick" style="left:' + tick + '%" title="200 g low-stock line"></i></div>';
|
||||
}
|
||||
|
||||
/* provenance: quiet icon + words */
|
||||
var TSRC = { spool: ['⚖', 'weighed bare', ''], manufacturer: ['◆', 'manufacturer', ''], community: ['◇', 'community', ''], estimated: ['≈', 'estimated', 'warn'] };
|
||||
function tareProv(s) {
|
||||
if (s.tare == null) return '<span class="prov warn"><i>?</i>empty spool weight unknown</span>';
|
||||
var t = TSRC[s.tsrc]; return '<span class="prov ' + t[2] + '" title="' + esc(s.tsrc === 'spool' ? 'This reel was weighed empty' : 'From spool type: ' + s.stype) + '"><i>' + t[0] + '</i>' + t[1] + '</span>';
|
||||
}
|
||||
function remProv(s) { return s.remSrc === 'weighed' ? '<span class="prov"><i>⚖</i>weighed' + (s.weighedAt ? ' ' + esc(s.weighedAt) : '') + '</span>' : '<span class="prov"><i>✎</i>typed</span>'; }
|
||||
var WSRC = { typed: ['✎', 'typed', '', 'A measurement someone entered. Survives an identity correction.'], sliced: ['▦', 'sliced', 'info', "Read from this run's 3MF over FTPS."], recalled: ['⎘', 'recalled', 'info', 'Copied from the weight remembered for this gcode path. No longer written.'], unknown: ['?', 'source unknown', 'warn', 'Written before the source was recorded. Neither correction path will move it.'] };
|
||||
function weightProv(p) { if (!p.wsrc) return ''; var w = WSRC[p.wsrc]; return '<span class="prov ' + w[2] + '" title="' + esc(w[3]) + '"><i>' + w[0] + '</i>' + w[1] + '</span>'; }
|
||||
function durCell(p) {
|
||||
if (p.status === 'running') return '<span class="num">' + dur(since(p.start)) + '</span><span class="sub">so far</span>';
|
||||
if (p.status === 'unknown') return '<span class="muted" title="Nobody saw this print end. The only measurable figure is the outage, which is not how long anything printed.">no duration</span><span class="sub">end not seen</span>';
|
||||
if (p.gaps) return '<span class="num strong" style="color:var(--status-warning)" title="The connection dropped ' + (p.gaps === 1 ? 'once' : p.gaps + ' times') + ' during this print. That time is inside the figure and nobody watched it, so it is an upper bound.">≤ ' + dur(p.dur) + '</span><span class="sub prov warn">upper bound · ' + p.gaps + ' gap' + (p.gaps > 1 ? 's' : '') + '</span>';
|
||||
if (p.gaps == null) return '<span class="num">' + dur(p.dur) + '</span><span class="sub" title="Predates gap counting: nobody asked whether the connection dropped.">gaps not counted</span>';
|
||||
return '<span class="num">' + dur(p.dur) + '</span>';
|
||||
}
|
||||
var PST = { running: ['run', 'Running'], finished: ['ok', 'Finished'], failed: ['err', 'Failed'], unknown: ['warn', 'Unknown'] };
|
||||
function printSt(p) { var s = PST[p.status]; return '<span class="st ' + s[0] + '">' + s[1] + '</span>'; }
|
||||
function printerSt(pr) {
|
||||
if (!pr.connected) return '<span class="st warn">' + (pr.lifecycle === 'reconnecting' ? 'Reconnecting' : 'Offline') + '</span>';
|
||||
if (pr.state === 'RUNNING') return '<span class="st run">Printing</span>';
|
||||
if (pr.state === 'PAUSE') return '<span class="st warn">Paused</span>';
|
||||
if (pr.state === 'FAILED') return '<span class="st err">Failed</span>';
|
||||
return '<span class="st idle">Idle</span>';
|
||||
}
|
||||
/* what a print still needs; empty while running */
|
||||
function needs(p) {
|
||||
if (p.status === 'running') return [];
|
||||
var n = [];
|
||||
if (p.grams == null) n.push({ k: 'weight', t: 'filament weight' });
|
||||
if (p.filaments) p.filaments.forEach(function (f) { if (f.spool == null) n.push({ k: 'spool', t: 'spool for color ' + (f.slot + 1), f: f }); });
|
||||
else if (p.spool == null) n.push({ k: 'spool', t: 'spool' });
|
||||
return n;
|
||||
}
|
||||
function printsNeeding() { return PRINTS.filter(function (p) { return needs(p).length && !p.archived; }); }
|
||||
function spoolLinks(p) {
|
||||
if (p.filaments) return p.filaments.map(function (f) { var s = f.spool && spool(f.spool); return s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '">' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '</a>' : '<span class="prov warn"><i>!</i>color ' + (f.slot + 1) + ' unattributed</span>'; }).join('<br>');
|
||||
var s = p.spool && spool(p.spool);
|
||||
return s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '" class="nowrap">' + sw(s.c, 'swatch-sm') + ' ' + esc(short(s)) + '</a>' : (p.status === 'running' ? '<span class="muted">—</span>' : '<span class="prov warn"><i>!</i>no spool</span>');
|
||||
}
|
||||
|
||||
/* ── shell ───────────────────────────────────────────────── */
|
||||
var ICON = {
|
||||
dashboard: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="2"/><rect x="14" y="3" width="7" height="5" rx="2"/><rect x="14" y="12" width="7" height="9" rx="2"/><rect x="3" y="16" width="7" height="5" rx="2"/></svg>',
|
||||
filament: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v6M12 15v6"/></svg>',
|
||||
prints: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 20h14M7 16l3-9h4l3 9M9 12h6"/></svg>',
|
||||
printers: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 17h8M9 7h6v5H9z"/></svg>',
|
||||
lists: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/></svg>',
|
||||
labels: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="7.5" r="1.5"/></svg>',
|
||||
access: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0M17 11l2 2 4-4"/></svg>',
|
||||
system: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h7"/><circle cx="18" cy="16" r="3"/></svg>',
|
||||
more: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>',
|
||||
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></svg>',
|
||||
sun: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>'
|
||||
};
|
||||
var 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 ? '<span class="count count-warn" title="' + unv.length + ' spools with an unverified empty spool weight: ' + unk + ' unknown, ' + (unv.length - unk) + ' estimated">' + unv.length + '</span>' : '',
|
||||
prints: need ? '<span class="count count-warn" title="' + need + ' prints need a spool or weight">' + need + '</span>' : '',
|
||||
printers: down ? '<span class="count count-err" title="' + down + ' printer not connected">' + down + '</span>' : ''
|
||||
};
|
||||
}
|
||||
function shell(page) {
|
||||
var b = badges();
|
||||
var nav = PAGES.map(function (p) { return '<a href="' + p[2] + '"' + (p[0] === page ? ' aria-current="page"' : '') + '>' + ICON[p[0]] + '<span>' + p[1] + '</span>' + (b[p[0]] || '') + '</a>'; }).join('');
|
||||
var unl = SPOOLS.filter(function (s) { return !s.label; }).length;
|
||||
var rail = '<nav class="rail nm-raised" aria-label="Main">' +
|
||||
'<a class="wordmark" href="01-dashboard.html" style="text-decoration:none;color:inherit"><span class="wordmark-mark" aria-hidden="true">X</span>Extrudex</a>' +
|
||||
'<div class="nav nav-group">' + nav + '</div>' +
|
||||
'<div class="nav nav-group"><span class="nav-label">Manage</span>' +
|
||||
MANAGE.map(function (m) { return '<a href="' + m[2] + '"' + (m[0] === page ? ' aria-current="page"' : '') + '>' + ICON[m[0]] + '<span>' + m[1] + '</span>' + (m[0] === 'labels' ? '<span class="count" style="color:var(--text-secondary)" title="' + unl + ' spools without a printed label">' + unl + '</span>' : m[3] ? '<span class="sub" style="margin-left:auto">admin</span>' : '') + '</a>'; }).join('') + '</div>' +
|
||||
'<div class="rail-foot"><div class="seg" role="group" aria-label="Color theme"><button type="button" data-set-theme="light">Light</button><button type="button" data-set-theme="dark">Dark</button></div>' +
|
||||
'<span class="small muted">Signed in · operator</span></div></nav>';
|
||||
var inManage = MANAGE.some(function (m) { return m[0] === page; });
|
||||
var tabbar = '<nav class="tabbar nm-raised" aria-label="Main">' + PAGES.map(function (p) { return '<a href="' + p[2] + '"' + (p[0] === page ? ' aria-current="page"' : '') + '>' + ICON[p[0]] + (b[p[0]] || '') + '<span>' + p[1] + '</span></a>'; }).join('') +
|
||||
'<a href="#" data-menu="moremenu" aria-haspopup="menu" aria-expanded="false"' + (inManage ? ' aria-current="page"' : '') + '>' + ICON.more + '<span>More</span></a></nav>' +
|
||||
'<div class="menu more-sheet nm-dialog" id="moremenu" role="menu" aria-label="More">' + MANAGE.map(function (m) { return '<a role="menuitem" href="' + m[2] + '">' + ICON[m[0]] + ' ' + m[1] + (m[3] ? '<span class="hint">admin</span>' : '') + '</a>'; }).join('') + '<hr><button role="menuitem" type="button" data-toggle-theme>' + ICON.sun + ' Switch light / dark</button></div>';
|
||||
var top = '<div class="topbar"><button class="search-trigger" type="button" data-palette>' + ICON.search + '<span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"><span class="long">Search spools, prints, printers…</span><span class="short">Search…</span></span><span class="kbd long">⌘K</span></button>' +
|
||||
'<span class="fresh" id="fresh" role="status"><span class="dot" aria-hidden="true"></span><span id="fresh-t">Live · updated 3s ago</span></span>' +
|
||||
'<button class="btn btn-icon btn-sm" type="button" data-toggle-theme aria-label="Switch color theme">' + ICON.sun + '</button></div>';
|
||||
var mock = '<div class="mock"><button type="button" data-menu="mockmenu" aria-expanded="false">Mockup<span class="long"> · v3 iteration 3</span></button><div class="menu" id="mockmenu" role="dialog" aria-label="About this mockup">' +
|
||||
'<p><b>Sample data</b>, based on the real inventory. Nothing is wired to the API.</p>' +
|
||||
'<p><span class="prov info"><i>◈</i>feature</span> fields exist only on the printer-connections branch. <span class="prov warn"><i>⚠</i>API gap</span> means stored but not served.</p><hr>' +
|
||||
'<button type="button" data-demo="loading">Replay first load (skeletons)</button>' +
|
||||
'<button type="button" data-demo="offline">Simulate polling failure</button>' +
|
||||
'<button type="button" data-demo="online">Back to live</button><hr>' +
|
||||
'<a href="index.html">Flow map of all screens →</a></div></div>';
|
||||
var drawer = '<div class="drawer-scrim" data-close-drawer></div><aside class="drawer nm-dialog" id="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title" hidden></aside>';
|
||||
var pal = '<div class="pal-scrim" id="pal" role="presentation"><div class="pal nm-dialog" role="dialog" aria-modal="true" aria-label="Search and commands"><div class="pal-input">' + ICON.search +
|
||||
'<input id="pal-q" role="combobox" aria-expanded="true" aria-controls="pal-list" aria-autocomplete="list" placeholder="Type a spool, #id, job, printer or action…" autocomplete="off"><span class="kbd">esc</span></div>' +
|
||||
'<div class="pal-list" id="pal-list" role="listbox"></div><div class="pal-foot"><span><span class="kbd">↑</span> <span class="kbd">↓</span> move</span><span><span class="kbd">↵</span> open</span><span><span class="kbd">/</span> or <span class="kbd">⌘K</span> anywhere</span></div></div></div>';
|
||||
return { rail: rail, tabbar: tabbar, top: top, rest: mock + drawer + pal + '<div class="toasts" id="toasts" aria-live="polite"></div>' };
|
||||
}
|
||||
|
||||
/* ── theme ───────────────────────────────────────────────── */
|
||||
function setTheme(v) {
|
||||
document.documentElement.setAttribute('data-theme', v);
|
||||
$$('[data-set-theme]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.getAttribute('data-set-theme') === v)); });
|
||||
try { localStorage.setItem('extrudex-v3-theme', v); } catch (e) {}
|
||||
}
|
||||
|
||||
/* ── toasts ──────────────────────────────────────────────── */
|
||||
function updateBadges() {
|
||||
var b = badges();
|
||||
$$('.nav a, .tabbar a').forEach(function (a) {
|
||||
var key = { '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 = '<span>' + html + '</span><button class="btn btn-sm btn-icon" type="button" aria-label="Dismiss">✕</button>';
|
||||
$('#toasts').appendChild(t);
|
||||
var kill = function () { if (t.parentNode) t.parentNode.removeChild(t); };
|
||||
t.querySelector('button').onclick = kill; setTimeout(kill, 6000);
|
||||
}
|
||||
|
||||
/* ── drawer ──────────────────────────────────────────────── */
|
||||
var D = { kind: null, id: null, tab: 'overview', state: 'normal', list: null, lastFocus: null, dirty: false };
|
||||
function openDrawer(kind, id, tab, push) {
|
||||
D.kind = kind; D.id = id; D.tab = tab || 'overview'; D.state = 'normal'; D.dirty = false;
|
||||
if (!document.body.classList.contains('drawer-open')) D.lastFocus = document.activeElement;
|
||||
renderDrawer();
|
||||
var el = $('#drawer'); el.hidden = false;
|
||||
requestAnimationFrame(function () { document.body.classList.add('drawer-open'); var f = el.querySelector('[autofocus]') || el.querySelector('h2'); if (f) { f.setAttribute('tabindex', f.tagName === 'H2' ? '-1' : f.getAttribute('tabindex')); f.focus(); } });
|
||||
var h = '#' + kind + '=' + id + (D.tab !== 'overview' ? '&tab=' + D.tab : '');
|
||||
if (push !== false && location.hash !== h) history.pushState({ d: 1 }, '', h);
|
||||
$$('[data-row-spool],[data-row-print]').forEach(function (r) { r.classList.toggle('is-selected', (kind === 'spool' && +r.getAttribute('data-row-spool') === id) || (kind === 'print' && +r.getAttribute('data-row-print') === id)); });
|
||||
}
|
||||
function closeDrawer(push) {
|
||||
if (D.dirty && !confirmDiscard()) return;
|
||||
document.body.classList.remove('drawer-open'); D.kind = null; 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) ? '<div class="drawer-nav"><button class="btn btn-sm btn-icon" type="button" data-step="-1" aria-label="Previous spool in list" ' + (i === 0 ? 'disabled' : '') + '>↑</button><button class="btn btn-sm btn-icon" type="button" data-step="1" aria-label="Next spool in list" ' + (i === list.length - 1 ? 'disabled' : '') + '>↓</button></div>' : '';
|
||||
return '<div class="drawer-head">' + icon + '<div style="flex:1;min-width:0"><h2 id="drawer-title">' + title + '</h2><span class="sub">' + sub + '</span>' + (extra || '') + '</div>' + nav + '<button class="btn btn-sm btn-icon" type="button" data-close-drawer aria-label="Close panel (Esc)">✕</button></div>';
|
||||
}
|
||||
function tabs(list) {
|
||||
return '<div class="drawer-tabs"><div class="tabs" role="tablist" aria-label="Spool panel">' + list.map(function (t) { return '<button type="button" role="tab" data-tab="' + t[0] + '" aria-selected="' + (D.tab === t[0]) + '">' + t[1] + '</button>'; }).join('') + '</div></div>';
|
||||
}
|
||||
|
||||
function spoolDrawer(s) {
|
||||
var w = where(s);
|
||||
var h = head(sw(s.c, '" style="width:44px;height:44px'), esc(s.color) + ' <span class="muted" style="font-weight:400">#' + s.id + '</span>', esc(fullName(s)) + ' · ' + esc(w.text));
|
||||
var t = tabs([['overview', 'Overview'], ['weigh', 'Weigh'], ['move', w.loaded ? 'Move' : 'Load / move'], ['edit', 'Edit']]);
|
||||
var body = '', foot = '';
|
||||
if (D.tab === 'overview') {
|
||||
var used = PRINTS.filter(function (p) { return p.spool === s.id || (p.filaments || []).some(function (f) { return f.spool === s.id; }); });
|
||||
var gr = gross(s);
|
||||
body = '<div class="nm-inset panel"><div class="row" style="align-items:baseline"><span style="font-size:34px;font-weight:800;line-height:1" class="num' + (low(s) ? '" style="color:var(--status-warning)' : '') + '">' + Math.round(s.rem).toLocaleString('en-US') + '<span style="font-size:15px"> g</span></span><span class="muted">of ' + g(s.tot) + ' net</span><span class="spacer"></span>' + remProv(s) + '</div>' +
|
||||
'<div style="margin-top:12px">' + meter(s) + '</div>' + (low(s) ? '<p class="small" style="margin:10px 0 0;color:var(--status-warning)"><b>⚠ Below the 200 g line.</b> Reorder or finish it on a small job.</p>' : '') + '</div>' +
|
||||
'<div class="panel nm-raised-sm"><h3>On the scale</h3>' + (gr == null
|
||||
? '<p class="small" style="margin:0">Gross is <b>unknown</b> because no empty spool weight is known. It is never treated as 0.</p><div class="row" style="margin-top:12px"><button class="btn btn-sm" type="button" data-tab="weigh">Set empty spool weight</button></div>'
|
||||
: '<dl class="kv"><dt>Empty spool</dt><dd>' + g(s.tare) + ' ' + tareProv(s) + '</dd><dt>+ Filament (net)</dt><dd>' + g(s.rem) + '</dd><dt class="strong" style="color:var(--text-primary)">= Gross, on a scale</dt><dd class="strong">' + g(gr) + '</dd></dl>') + '</div>' +
|
||||
'<dl class="kv"><dt>Location</dt><dd>' + (w.loaded ? '<a href="04-printers.html#printer=' + w.printer.id + '">' + esc(w.text) + '</a>' : esc(w.text)) + ' · <button class="btn btn-sm" type="button" data-tab="move" style="min-height:26px;padding:2px 8px">Move</button></dd>' +
|
||||
'<dt>Value left</dt><dd>' + usd(value(s)) + ' <span class="sub" style="display:inline">at ' + usd(s.kg) + '/kg</span></dd>' +
|
||||
'<dt>Spool type</dt><dd>' + (s.stype ? esc(s.stype) : '<span class="muted">none chosen</span>') + '</dd>' +
|
||||
'<dt>Label</dt><dd>' + (s.label ? 'printed' : '<span class="prov warn"><i>!</i>not printed</span> <button class="btn btn-sm" type="button" data-fake="Label PDF generated for #' + s.id + '" style="min-height:26px;padding:2px 8px">Print label</button>') + '</dd>' +
|
||||
'<dt>Record</dt><dd class="muted">version ' + s.v + '</dd></dl>' +
|
||||
'<div><h3 class="section" style="margin-bottom:8px">Prints from this spool <span class="muted">· ' + used.length + '</span></h3>' + (used.length ? '<div class="stack" style="gap:6px">' + used.map(function (p) { var f = (p.filaments || []).filter(function (f) { return f.spool === s.id; })[0]; return '<a class="row nm-raised-sm" style="padding:8px 12px;text-decoration:none;color:inherit;gap:10px" href="#print=' + p.id + '" data-print="' + p.id + '">' + printSt(p) + '<span class="strong" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis">' + esc(p.job) + '</span><span class="num">' + g(f ? f.grams : p.grams) + '</span><span class="sub" style="display:inline">' + when(p.start) + '</span></a>'; }).join('') + '</div>' : '<p class="small muted" style="margin:0">No prints drew from this spool yet.</p>') + '</div>' +
|
||||
'<p class="small muted" style="margin:0"><span class="prov warn"><i>⚠</i>API gap</span> Location history is stored in <code>spool_location_event</code> but there is no endpoint to read it.</p>';
|
||||
foot = '<button class="btn btn-danger btn-sm" type="button" data-delete="' + s.id + '" style="margin-right:auto">Delete…</button><button class="btn" type="button" data-tab="edit">Edit</button><button class="btn btn-primary" type="button" data-tab="weigh" ' + (s.tare == null ? 'title="Needs an empty spool weight first"' : '') + '>Weigh</button>';
|
||||
}
|
||||
if (D.tab === 'weigh') {
|
||||
if (s.tare == null) {
|
||||
body = '<div class="banner"><b>?</b><span>This spool has <b>no empty spool weight</b>, so a scale reading can\'t be turned into filament. Set one first. It is never assumed to be 0.</span></div>' +
|
||||
'<label class="field stack" style="gap:6px"><span class="strong small">Pick a spool type</span><select class="select" autofocus><option>Choose…</option><option>Generic — 1 kg spool (assumed) · 180 g · estimated</option><option>Creality — Hyper cardboard · 176 g · community</option><option>Creality — legacy plastic · 138 g · community</option></select><span class="sub">Fastest. The figure\'s trust level is shown next to it.</span></label>' +
|
||||
'<div class="row"><hr style="flex:1;border:0;border-top:1px solid var(--border-hairline)"><span class="sub">or, more accurate</span><hr style="flex:1;border:0;border-top:1px solid var(--border-hairline)"></div>' +
|
||||
'<label class="field stack" style="gap:6px"><span class="strong small">Weigh the empty spool</span><input class="input num" inputmode="numeric" placeholder="e.g. 210 g"><span class="sub">Only possible once the spool is used up, or from an identical empty.</span></label>';
|
||||
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" data-fake="Empty spool weight saved for #' + s.id + '. Weigh-in unlocked.">Save empty spool weight</button>';
|
||||
} else {
|
||||
var wl = where(s), li = D.list ? D.list.indexOf(s.id) : -1;
|
||||
body = (D.session && li >= 0 ? '<div class="row small" style="gap:10px"><span class="st run">Weigh session</span><span class="muted">spool ' + (li + 1) + ' of ' + D.list.length + ' · lightest first</span></div>' : '') +
|
||||
'<div class="findit"><span class="sub">Find it</span><b>' + esc(wl.text) + '</b>' + sw(s.c, 'swatch-sm') + '<span class="muted small">' + esc(s.color) + ' · #' + s.id + '</span></div>' +
|
||||
'<div class="panel nm-inset"><label class="stack" style="gap:8px"><span class="strong">Whole spool on the scale</span><input class="input num" id="scale" inputmode="numeric" autofocus placeholder="full reads ' + g(s.tare + s.tot) + '" style="font-size:22px;min-height:56px"><span class="sub">Remaining = reading − ' + g(s.tare) + ' empty spool (' + tareProv(s) + ')</span></label></div>' +
|
||||
'<div class="panel nm-raised-sm" id="weigh-out" aria-live="polite"><h3>Result</h3><div class="row"><span class="muted">Now</span><b class="num">' + g(s.rem) + '</b><span class="muted">→</span><b class="num" id="w-after">—</b><span class="spacer"></span><span id="w-diff" class="num"></span></div><p class="small muted" id="w-note" style="margin:8px 0 0">Type the reading and the result appears here before you save.</p></div>';
|
||||
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" id="w-save" disabled>Save weigh-in</button>' + (D.list && D.list.indexOf(s.id) < D.list.length - 1 ? '<button class="btn" type="button" id="w-next" disabled>Save & weigh next ↓</button>' : '');
|
||||
}
|
||||
}
|
||||
if (D.tab === 'move') {
|
||||
var dests = PRINTERS.map(function (pr) {
|
||||
return '<div class="stack" style="gap:8px"><div class="row"><b class="small">' + esc(pr.name) + '</b>' + (pr.connected ? '' : '<span class="prov warn"><i>⚡</i>disconnected (loading still recorded)</span>') + '</div><div class="row" style="gap:8px;align-items:stretch">' +
|
||||
pr.containers.map(function (c) { var n = contents(c.id).length, full = n >= c.cap && s.cont !== c.id, here = s.cont === c.id; return '<button type="button" class="slot" style="min-height:64px;flex:1" data-dest="' + c.id + '" ' + (full ? 'disabled title="Full: unload a spool first"' : '') + ' aria-pressed="' + here + '"><span class="sn">' + esc(c.label) + '</span><span class="g">' + n + ' / ' + c.cap + (here ? ' · here now' : full ? ' · full' : '') + '</span></button>'; }).join('') + '</div></div>';
|
||||
}).join('');
|
||||
body = '<p class="small muted" style="margin:0">Where is the spool going? Full holders are disabled. Extrudex records which holder a spool is in, not its tray position.</p>' +
|
||||
'<div class="stack" style="gap:8px"><b class="small">Storage</b><div class="row" style="gap:8px;align-items:stretch"><button type="button" class="slot" style="min-height:64px;flex:1" data-dest="storage" aria-pressed="' + !s.cont + '"><span class="sn">Shelf / storage</span><span class="g">' + (s.cont ? 'unload' : 'here now') + '</span></button></div>' +
|
||||
'<label class="stack" style="gap:6px"><span class="sub">Where in storage (optional)</span><input class="input" value="' + esc(s.loc || '') + '" placeholder="Shelf A2, Dry box 1…"></label></div>' + dests;
|
||||
foot = '<button class="btn" type="button" data-tab="overview">Cancel</button><button class="btn btn-primary" type="button" id="m-save" disabled>Move spool</button>';
|
||||
}
|
||||
if (D.tab === 'edit') { return editDrawer(s); }
|
||||
return h + t + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
|
||||
}
|
||||
|
||||
function editDrawer(s) {
|
||||
var isNew = !s; s = s || { id: null, c: ['#888888'], color: '', brand: 'Sunlu', speed: null, mat: 'PLA', type: null, fin: 'Basic', rem: 1000, tot: 1000, tare: 140, tsrc: 'community', stype: 'Sunlu — 1 kg plastic', kg: 0, loc: '', v: null };
|
||||
var st = D.state;
|
||||
var h = isNew ? head('<span class="swatch" style="width:44px;height:44px"><i style="background:#888"></i></span>', 'Add spool', 'New spools carry no version') : head(sw(s.c, '" style="width:44px;height:44px'), 'Edit ' + esc(s.color) + ' <span class="muted" style="font-weight:400">#' + s.id + '</span>', 'Opened 09:32 at version ' + (st === 'conflict' ? s.v - 1 : s.v));
|
||||
var t = isNew ? '' : tabs([['overview', 'Overview'], ['weigh', 'Weigh'], ['move', where(s).loaded ? 'Move' : 'Load / move'], ['edit', 'Edit']]);
|
||||
function sel(label, val, req, opts) { return '<label class="stack" style="gap:6px"><span class="small strong">' + label + ' ' + (req ? '<span class="sub" style="display:inline;color:var(--text-accent)">required</span>' : '<span class="sub" style="display:inline">optional</span>') + '</span><select class="select"><option>' + (val || 'None') + '</option>' + (opts || '') + '</select></label>'; }
|
||||
var demo = '<div class="row small" style="gap:8px"><span class="muted">Demo state</span><div class="tabs" role="group" aria-label="Demo state">' + [['normal', 'Normal'], ['errors', '400 errors'], ['conflict', '409 conflict']].map(function (x) { return '<button type="button" data-state="' + x[0] + '" aria-selected="' + (st === x[0]) + '">' + x[1] + '</button>'; }).join('') + '</div></div>';
|
||||
var conflict = st === 'conflict' ? '<div class="nm-inset" role="alert" style="padding:16px;border:2px solid var(--status-warning);background:var(--wash-warning);border-radius:var(--radius-lg)"><b style="color:var(--status-warning)">⚠ This spool changed since you opened it. Nothing was saved.</b><p class="small" style="margin:8px 0">Someone weighed it at 09:39 (version ' + (s.v - 1) + ' → ' + s.v + '). Saving would have put the old remaining weight back.</p>' +
|
||||
'<table class="tbl" style="font-size:12px"><thead><tr><th>Field</th><th class="r">You opened</th><th class="r">Now</th><th class="r">Your edit</th></tr></thead><tbody><tr><td>Remaining</td><td class="r">860 g</td><td class="r strong" style="color:var(--status-warning)">' + g(s.rem) + ' ⚖</td><td class="r muted">—</td></tr><tr><td>Location</td><td class="r">Shelf A3</td><td class="r">Shelf A3</td><td class="r strong accent">' + esc(where(s).short) + '</td></tr></tbody></table>' +
|
||||
'<div class="row" style="margin-top:12px"><button class="btn btn-primary btn-sm" type="button" data-fake="Reloaded version ' + s.v + ' and reapplied your location change. Review, then save.">Reload & reapply my change</button><button class="btn btn-sm" type="button" data-state="normal">Reload, discard mine</button></div></div>' : '';
|
||||
var errsum = st === 'errors' ? '<div role="alert" style="padding:14px 16px;border:2px solid var(--status-error);background:var(--wash-error);border-radius:var(--radius-lg)"><b style="color:var(--status-error)">⚠ 2 fields need fixing. Nothing was saved.</b><ul class="small" style="margin:6px 0 0;padding-left:18px"><li><a href="#e-rem">Remaining is more than the spool holds</a></li><li><a href="#e-name">Color name is empty</a></li></ul></div>' : '';
|
||||
var name = '<div class="nm-inset" style="padding:12px 14px;border-radius:var(--radius-md)"><span class="sub">Reads as</span><div class="strong" style="font-size:17px">' + esc(fullName(s)) + '</div><span class="sub">brand · <i>speed</i> · material · <i>type</i> · finish (italic = optional)</span></div>';
|
||||
var body = demo + conflict + errsum + name +
|
||||
'<div class="fr2">' + sel('Brand', s.brand, 1) + sel('Print speed', s.speed, 0, '<option>Rapid</option><option>High Speed</option>') + sel('Material', s.mat, 1) + sel('Filament type', s.type, 0, '<option>CF</option><option>+ 2.0</option>') + sel('Finish', s.fin, 1) + '</div>' +
|
||||
'<div class="fr2"><div class="stack' + (st === 'errors' ? ' field has-error' : '') + '" id="e-name" style="gap:6px"><label class="small strong" for="f-name">Color name <span class="sub" style="display:inline;color:var(--text-accent)">required</span></label><input class="input" id="f-name" value="' + (st === 'errors' ? '' : esc(s.color)) + '" ' + (st === 'errors' ? 'aria-invalid="true" aria-describedby="e-name-m"' : '') + ' placeholder="e.g. Charcoal">' + (st === 'errors' ? '<p class="field-error" id="e-name-m" style="margin:0">Give the color a name, even "Unknown". <span class="sub" style="display:inline">server: <code>color_name is required</code></span></p>' : '') + '</div>' +
|
||||
'<label class="stack" style="gap:6px"><span class="small strong">Colors <span class="sub" style="display:inline">1–3</span></span><span class="row" style="gap:8px">' + sw(s.c) + '<input class="input" value="' + s.c[0] + '" style="max-width:110px"><button class="btn btn-sm" type="button">+ color</button></span></label></div>' +
|
||||
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Total <span class="sub" style="display:inline">net, as sold</span></span><input class="input num" value="' + g(s.tot) + '"></label>' +
|
||||
'<div class="stack' + (st === 'errors' ? ' field has-error' : '') + '" id="e-rem" style="gap:6px"><label class="small strong" for="f-rem">Remaining <span class="sub" style="display:inline">net</span></label><input class="input num" id="f-rem" value="' + (st === 'errors' ? '1,040 g' : g(s.rem)) + '" ' + (st === 'errors' ? 'aria-invalid="true" aria-describedby="e-rem-m"' : '') + '>' + (st === 'errors' ? '<p class="field-error" id="e-rem-m" style="margin:0">1,040 g is more than this spool holds (' + g(s.tot) + '). <span class="sub" style="display:inline">server: <code>remaining_grams cannot exceed total_grams</code></span></p>' : '<span class="sub">To measure it, use the <b>Weigh</b> tab instead.</span>') + '</div></div>' +
|
||||
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Spool type <span class="sub" style="display:inline">supplies the empty spool weight</span></span><select class="select"><option>' + esc(s.stype || 'None chosen') + '</option></select></label>' +
|
||||
'<label class="stack" style="gap:6px"><span class="small strong">Empty spool weight</span><input class="input num" value="' + (s.tare == null ? '' : g(s.tare)) + '" placeholder="unknown"><span class="sub">' + (s.tare == null ? '<span class="prov warn"><i>?</i>unknown, never 0</span>' : tareProv(s)) + '</span></label></div>' +
|
||||
'<div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Cost per kg</span><input class="input num" value="' + usd(s.kg) + '"></label><label class="stack" style="gap:6px"><span class="small strong">Storage location <span class="sub" style="display:inline">optional</span></span><input class="input" value="' + esc(s.loc || '') + '"></label></div>' +
|
||||
'<div class="banner" id="discard-bar" hidden><span style="flex:1">Discard unsaved changes?</span><button class="btn btn-sm" type="button" data-discard>Discard</button><button class="btn btn-sm" type="button" data-keep>Keep editing</button></div>';
|
||||
var foot = (st === 'conflict' ? '<span class="small muted" style="margin-right:auto">Save is off until you choose a version.</span>' : '') + '<button class="btn" type="button" data-' + (isNew ? 'close-drawer' : 'tab="overview"') + '>Cancel</button><button class="btn btn-primary" type="button" ' + (st === 'conflict' ? 'disabled' : 'data-save') + '>' + (isNew ? 'Add spool' : 'Save changes') + '</button>';
|
||||
return h + t + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
|
||||
}
|
||||
|
||||
function printDrawer(p) {
|
||||
var pr = printer(p.printer), n = needs(p);
|
||||
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.prints + '</span>', esc(p.job), printSt(p) + ' · ' + esc(pr.name) + ' · print #' + p.id);
|
||||
var fix = '';
|
||||
if (n.length) {
|
||||
fix = '<div class="nm-inset panel" style="border:1px solid var(--status-warning)"><h3 style="color:var(--status-warning)">Needs ' + n.map(function (x) { return x.t; }).join(' + ') + '</h3>' + fixers(p) + '</div>';
|
||||
}
|
||||
var body = fix + '<dl class="kv"><dt>Printer</dt><dd><a href="04-printers.html#printer=' + pr.id + '">' + esc(pr.name) + '</a></dd><dt>Started</dt><dd>' + when(p.start) + '</dd><dt>Ended</dt><dd>' + (p.end ? when(p.end) : p.status === 'running' ? '<span class="muted">still running</span>' : '—') + '</dd>' +
|
||||
'<dt>Duration</dt><dd>' + durCell(p) + '</dd>' +
|
||||
'<dt>Job key</dt><dd><code>' + esc(p.key) + '</code><span class="sub">The identity. Two jobs can share a name, never a path.</span></dd>' +
|
||||
'<dt>Filament</dt><dd>' + (p.grams == null ? '<span class="muted">not known yet</span>' : '<b class="num">' + g(p.grams) + '</b> ' + weightProv(p) + (p.status === 'failed' ? '<span class="sub">Failed prints record the full job weight. Correct by hand if needed.</span>' : '')) + '</dd>' +
|
||||
'<dt>Spool</dt><dd>' + spoolLinks(p) + changeSpool(p) + '</dd><dt>Cost</dt><dd>' + (p.cost == null ? '<span class="muted">pending: needs weight and spool</span>' : usd(p.cost) + (p.filaments && p.filaments.some(function (f) { return f.spool == null; }) ? ' <span class="sub" style="display:inline">so far</span>' : '')) + '</dd>' +
|
||||
'<dt>Use</dt><dd><div class="tabs" role="group" aria-label="Use type"><button type="button" aria-selected="' + (p.use === 'business') + '">Business</button><button type="button" aria-selected="' + (p.use === 'personal') + '">Personal</button></div>' + (p.use == null ? '<span class="sub">Not recorded</span>' : '') + '</dd></dl>' +
|
||||
(p.filaments ? '<div><h3 class="section">Colors</h3><table class="tbl"><thead><tr><th>#</th><th>Color</th><th>Spool</th><th class="r">Grams</th><th class="r">Cost</th></tr></thead><tbody>' + p.filaments.map(function (f) { var s = f.spool && spool(f.spool); return '<tr><td>' + (f.slot + 1) + '</td><td><span class="swatch swatch-sm"><i style="background:' + f.hex + '"></i></span> <code>' + f.hex + '</code></td><td>' + (s ? '<a href="#spool=' + s.id + '" data-spool="' + s.id + '">' + esc(short(s)) + '</a>' : '<span class="prov warn"><i>!</i>unattributed</span>') + '</td><td class="r">' + g(f.grams) + '</td><td class="r">' + usd(f.cost) + '</td></tr>'; }).join('') + '</tbody></table></div>' : '');
|
||||
var foot = p.status === 'running' ? '<span class="small muted">Name and weight are captured when the print ends.</span>' : '<button class="btn btn-sm" type="button" data-fake="Archived ' + esc(p.job) + '. Accounting unchanged." style="margin-right:auto">Archive</button><button class="btn" type="button" data-close-drawer>Done</button>';
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot">' + foot + '</div>';
|
||||
}
|
||||
function 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 '<div class="banner err" role="alert" style="margin-top:8px;font-size:12px"><span><b>Can\'t move this one yet.</b> It was attributed to spool #' + p.spool + ' before Extrudex tracked deductions, so nothing records how much came off. Check that spool\'s remaining grams, then re-attribute this print to <b>the same spool</b> to reconcile it. After that it can move.</span></div>';
|
||||
return attributed.map(function (f) {
|
||||
var from = spool(f.spool);
|
||||
return '<details class="chg"><summary>Change spool' + (f.slot != null ? ' for color ' + (f.slot + 1) : '') + '</summary><div class="stack" style="gap:8px;margin-top:8px"><select class="select" style="min-height:36px" aria-label="New spool">' + SPOOLS.filter(function (x) { return x.id !== f.spool; }).slice(0, 10).map(function (x) { return '<option>' + g(x.rem) + ' · ' + esc(short(x)) + ' · ' + x.mat + ' · #' + x.id + '</option>'; }).join('') + '</select>' +
|
||||
'<span class="sub">' + (f.grams != null ? '<b>' + g(f.grams) + ' goes back</b> 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.') + '</span>' +
|
||||
'<button class="btn btn-sm" type="button" data-fake="Moved ' + esc(p.job) + (f.grams != null ? ': ' + g(f.grams) + ' returned to ' + esc(short(from)) : '') + '">Move to this spool</button></div></details>';
|
||||
}).join('');
|
||||
}
|
||||
function fixers(p) {
|
||||
var out = '';
|
||||
if (p.grams == null) out += '<label class="stack" style="gap:6px;margin-bottom:12px"><span class="small strong">Filament used</span><span class="row" style="gap:8px"><input class="input num" style="max-width:140px" placeholder="grams" inputmode="numeric" autofocus><button class="btn btn-sm" type="button" data-fake="Saved ' + esc(p.job) + ' weight · remembered for ' + esc(p.key) + '">Save weight</button></span><span class="sub">From the slicer. Remembered for <code>' + esc(p.key) + '</code> so later runs fill in by themselves.</span></label>';
|
||||
var slots = p.filaments ? p.filaments.filter(function (f) { return f.spool == null; }) : (p.spool == null ? [{ suggested: p.suggested, slot: null }] : []);
|
||||
slots.forEach(function (f) {
|
||||
var sug = f.suggested && spool(f.suggested);
|
||||
out += '<div class="stack" style="gap:6px;margin-bottom:8px"><span class="small strong">Spool' + (f.slot != null ? ' for color ' + (f.slot + 1) + ' <span class="swatch swatch-sm"><i style="background:' + f.hex + '"></i></span>' : '') + '</span>' +
|
||||
(sug ? '<div class="row nm-raised-sm" style="padding:8px 12px;gap:10px">' + sw(sug.c, 'swatch-sm') + '<span style="flex:1"><b>' + esc(short(sug)) + '</b><span class="sub">Suggested: same color, loaded in ' + esc(where(sug).short) + '. Not charged until you confirm.</span></span><button class="btn btn-sm btn-primary" type="button" data-fake="Attributed to ' + esc(short(sug)) + ' · ' + g(f.grams || p.grams) + ' deducted">Use this</button></div>' : '') +
|
||||
'<select class="select"><option>' + (sug ? 'Or pick another spool…' : 'Pick a spool…') + '</option>' + SPOOLS.slice(0, 8).map(function (s) { return '<option>' + g(s.rem) + ' · ' + esc(short(s)) + ' · ' + esc(s.mat) + '</option>'; }).join('') + '</select></div>';
|
||||
});
|
||||
return out;
|
||||
}
|
||||
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('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.printers + '</span>', 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 '<div class="stack' + (err ? ' field has-error' : '') + '" style="gap:6px"><label class="small strong">' + label + (hint ? ' <span class="sub" style="display:inline">' + hint + '</span>' : '') + '</label>' + input + (err ? '<p class="field-error" style="margin:0">' + err + '</p>' : '') + '</div>'; }
|
||||
var body = '<div class="row small" style="gap:8px"><span class="muted">Demo state</span><div class="tabs" role="group" aria-label="Demo state">' + [['normal', 'Normal'], ['errors', '400 errors']].map(function (x) { return '<button type="button" data-state="' + x[0] + '" aria-selected="' + (st === x[0]) + '">' + x[1] + '</button>'; }).join('') + '</div></div>' +
|
||||
'<div class="fr2">' + f('Brand', '', '<select class="select" id="pe-brand"><option value="bambu"' + (brand === 'bambu' ? ' selected' : '') + '>Bambu Lab</option><option value="elegoo"' + (brand === 'elegoo' ? ' selected' : '') + '>Elegoo</option></select>') +
|
||||
f('Model', '', '<select class="select">' + MODELS[brand].map(function (m) { return '<option' + (pr && pr.model === m ? ' selected' : '') + '>' + m + '</option>'; }).join('') + '</select>') + '</div>' +
|
||||
f('Name', 'shown everywhere; must be unique', '<input class="input" value="' + (pr ? esc(pr.name) : '') + '" placeholder="X1C — Bench">', st === 'errors' && !pr ? 'A printer called “P1S - Prints Charming” already exists. Names must be unique.' : '') +
|
||||
'<div class="fr2">' + f('IP address', 'same network as Extrudex', '<input class="input" value="' + (st === 'errors' ? '10.60.1.300' : pr ? esc(pr.ip) : '') + '" placeholder="10.60.1.10" inputmode="decimal">', st === 'errors' ? '10.60.1.300 isn\'t a valid IPv4 address.' : '') +
|
||||
f('Serial', 'on the printer\'s label', '<input class="input" value="' + (pr ? esc(pr.serial) : '') + '">') + '</div>' +
|
||||
(brand === 'bambu' ? f('Access code', pr ? 'leave blank to keep the stored code' : 'from the printer screen: Settings → LAN', '<input class="input" type="password" placeholder="' + (pr ? 'unchanged' : '8 characters') + '" autocomplete="off">') + '<p class="sub" style="margin:0">Stored, never shown again. Read-only MQTT needs only IP, serial and access code, no Developer Mode.</p>'
|
||||
: '<p class="small muted" style="margin:0">Elegoo Centauri uses unauthenticated SDCP, so there is no access code.</p>') +
|
||||
(pr ? '' : '<div class="nm-inset panel small"><b>After you add it</b><ul style="margin:6px 0 0;padding-left:18px"><li>An <b>External</b> holder is created automatically.</li><li>Add its AMS units under <b>Manage holders</b>.</li><li>Capture starts on the first status report.</li></ul></div>');
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer>Cancel</button><button class="btn btn-primary" type="button" ' + (st === 'errors' ? 'disabled' : 'data-fake="' + (pr ? 'Saved ' + esc(pr.name) + '. The monitor reconnects with the new details.' : 'Added printer. Waiting for its first status report.') + '"') + '>' + (pr ? 'Save' : 'Add printer') + '</button></div>';
|
||||
}
|
||||
function holdersDrawer(id) {
|
||||
var pr = printer(+id);
|
||||
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.printers + '</span>', '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 '<div class="holder"><div class="holder-h"><b>' + esc(c.label) + '</b><span class="muted">' + { ams: 'AMS', ams_lite: 'AMS Lite', external: 'External' }[c.kind] + ' · holds ' + c.cap + ' · #' + HOLDER_IDS[c.id] + '</span><span class="spacer"></span><span class="small">' + n + ' loaded</span></div>' +
|
||||
'<div class="row" style="gap:8px"><button class="btn btn-sm" type="button" data-fake="Holder label PDF #' + HOLDER_IDS[c.id] + ' generated">Print label</button>' +
|
||||
(ext ? '<span class="sub">Every printer keeps exactly one external holder.</span>' : n ? '<button class="btn btn-sm" type="button" disabled title="Unload its spools first">Remove</button><span class="sub">Unload its ' + n + ' spool' + (n > 1 ? 's' : '') + ' first.</span>' : '<button class="btn btn-sm btn-danger" type="button" data-fake="Removed ' + esc(c.label) + '">Remove</button>') + '</div></div>';
|
||||
}).join('') +
|
||||
'<div class="panel nm-raised-sm stack" style="gap:10px"><h3 style="margin:0">Add an AMS unit</h3><div class="fr2"><label class="stack" style="gap:6px"><span class="small strong">Type</span><select class="select"><option>AMS (4 spools)</option><option>AMS Lite (4 spools)</option></select></label><label class="stack" style="gap:6px"><span class="small strong">Label</span><input class="input" value="AMS 2"><span class="sub">Unique on this printer</span></label></div><button class="btn btn-sm" type="button" data-fake="Added AMS 2 to ' + esc(pr.model) + '" style="align-self:flex-start">Add holder</button></div>' +
|
||||
'<p class="small muted" style="margin:0">Capacity comes from the type, so a holder can\'t disagree with its hardware. Tray positions aren\'t recorded.</p>';
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer>Done</button></div>';
|
||||
}
|
||||
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('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--wash-error);color:var(--status-error);border-color:var(--status-error)">!</span>', 'Remove ' + esc(pr.name) + '?', esc(pr.model) + ' · ' + esc(pr.ip));
|
||||
var body = '<ul class="small" style="margin:0;padding-left:18px;display:flex;flex-direction:column;gap:6px"><li>Capture stops for this printer.</li><li><b>Print history is kept</b>, including cost records.</li>' + (open ? '<li><b>' + esc(open.job) + '</b> is open, so it will be <b>closed as unknown</b> with no duration, because nobody will watch it end.</li>' : '') + '</ul>' +
|
||||
(loaded ? '<div class="banner err" role="alert"><span><b>Can\'t remove yet:</b> ' + loaded + ' spool' + (loaded > 1 ? 's are' : ' is') + ' still loaded in its holders. Unload them first, so they don\'t lose their place. <a href="04-printers.html#printer=' + pr.id + '" data-close-drawer>Show holders</a></span></div>' : '') +
|
||||
'<label class="stack" style="gap:6px"><span class="small strong">Type <i>' + esc(pr.name) + '</i> to confirm</span><input class="input" id="rm-name" autocomplete="off" ' + (loaded ? 'disabled' : '') + '></label>';
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer autofocus>Keep printer</button><button class="btn btn-danger" type="button" id="rm-go" disabled data-name="' + esc(pr.name) + '">Remove printer</button></div>';
|
||||
}
|
||||
function loadDrawer(cid) {
|
||||
var c = container(cid), inIt = contents(cid);
|
||||
var h = head('<span class="wordmark-mark" style="width:44px;height:44px;background:var(--surface-inset);color:var(--text-primary)">' + ICON.printers + '</span>', 'Load into ' + esc(c.c.label), esc(c.printer.name) + ' · ' + inIt.length + ' of ' + c.c.cap + ' in use');
|
||||
var cands = SPOOLS.filter(function (s) { return !s.cont; });
|
||||
var body = '<label class="stack" style="gap:6px"><span class="small strong">Find a spool in storage</span><input class="input" id="load-q" placeholder="Brand, color, material…" autofocus></label><div class="stack" style="gap:6px" id="load-list">' +
|
||||
cands.map(function (s) { return '<button type="button" class="slot" style="min-height:0;flex-direction:row;align-items:center;gap:12px;width:100%" data-load="' + s.id + '" aria-pressed="false" data-hay="' + esc((fullName(s) + ' ' + s.color + ' ' + (s.loc || '')).toLowerCase()) + '">' + sw(s.c) + '<span style="flex:1;min-width:0"><span class="nm" style="display:block">' + esc(s.color) + ' · ' + esc(s.brand) + '</span><span class="sub">' + esc(fullName(s)) + ' · ' + esc(s.loc || 'Storage') + '</span></span><span class="g num' + (low(s) ? '" style="color:var(--status-warning)' : '') + '">' + g(s.rem) + '</span></button>'; }).join('') + '</div>' +
|
||||
'<p class="small muted" style="margin:0">Scanning the holder\'s QR label does the same thing from a phone (existing <code>/c/:id</code> scan session).</p>';
|
||||
return h + '<div class="drawer-body">' + body + '</div><div class="drawer-foot"><button class="btn" type="button" data-close-drawer>Cancel</button><button class="btn btn-primary" type="button" id="l-save" disabled>Load spool</button></div>';
|
||||
}
|
||||
|
||||
/* ── palette ─────────────────────────────────────────────── */
|
||||
var P = { items: [], sel: 0 };
|
||||
function palItems(q) {
|
||||
q = q.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 <b>' + esc(s1.color + ' · ' + s1.brand) + '</b> 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 <b>' + esc(p1.job) + '</b> 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 += '<div class="pal-sec">' + esc(it.sec) + '</div>'; last = it.sec; }
|
||||
html += '<div class="pal-item" role="option" id="pi' + i + '" data-i="' + i + '" aria-selected="' + (i === P.sel) + '">' + (it.html || '<span style="width:18px;text-align:center" aria-hidden="true">' + it.ico + '</span>') + '<span>' + esc(it.t) + '</span><span class="meta">' + esc(it.m || '') + '</span></div>';
|
||||
});
|
||||
$('#pal-list').innerHTML = html || '<div class="empty"><h3>No matches</h3><p>Try a color, brand, <code>#47</code>, a job name or a printer.</p></div>';
|
||||
$('#pal-q').setAttribute('aria-activedescendant', P.items.length ? 'pi' + P.sel : '');
|
||||
var cur = $('#pi' + P.sel); if (cur) cur.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
function palOpen(prefill) { $('#pal').classList.add('is-open'); $('#pal-q').value = prefill || ''; P.sel = 0; palRender(); $('#pal-q').focus(); }
|
||||
function palClose() { $('#pal').classList.remove('is-open'); }
|
||||
function palRun(i) { var it = P.items[i]; if (!it) return; if (!it.keep) palClose(); if (it.href) location.href = it.href; else it.run(); }
|
||||
|
||||
/* ── freshness + demos ───────────────────────────────────── */
|
||||
var fresh = { n: 3, stale: false };
|
||||
function tick() {
|
||||
fresh.n = fresh.stale ? fresh.n + 1 : (fresh.n >= 10 ? 0 : fresh.n + 1);
|
||||
var f = $('#fresh'); if (!f) return;
|
||||
f.classList.toggle('is-stale', fresh.stale);
|
||||
$('#fresh-t').innerHTML = fresh.stale ? 'Not updating · data from 09:41:07 <span class="long">· retrying</span>' : 'Live <span class="long">· updated ' + fresh.n + 's ago</span>';
|
||||
}
|
||||
function demo(kind) {
|
||||
var main = $('#page');
|
||||
if (kind === 'loading') {
|
||||
var keep = main.innerHTML;
|
||||
main.innerHTML = '<div class="stack" style="gap:24px" aria-busy="true" aria-label="Loading"><div class="skel" style="width:40%;height:28px"></div><div class="stats">' + [1, 2, 3, 4].map(function () { return '<div class="stat nm-raised"><div class="skel" style="width:50%"></div><div class="skel" style="width:70%;height:24px;margin-top:10px"></div></div>'; }).join('') + '</div><div class="nm-raised card stack">' + [1, 2, 3, 4, 5].map(function () { return '<div class="skel" style="height:18px"></div>'; }).join('') + '</div></div>';
|
||||
setTimeout(function () { main.innerHTML = keep; if (PAGE.after) PAGE.after(); }, 1600);
|
||||
}
|
||||
if (kind === 'offline') { fresh.stale = true; fresh.n = 0; tick(); var b = $('#offline-banner'); if (!b) { b = document.createElement('div'); b.id = 'offline-banner'; b.className = 'banner'; b.setAttribute('role', 'alert'); b.innerHTML = '<b>⚠</b><span style="flex:1">Can\'t reach Extrudex. You are looking at data from <b>09:41:07</b>, and it is not updating. Retrying every 10 s. Saving is paused.</span><button class="btn btn-sm" type="button" data-demo="online">Retry now</button>'; main.parentNode.insertBefore(b, main); } }
|
||||
if (kind === 'online') { fresh.stale = false; fresh.n = 0; tick(); var ob = $('#offline-banner'); if (ob) ob.parentNode.removeChild(ob); }
|
||||
}
|
||||
|
||||
/* ── wiring ──────────────────────────────────────────────── */
|
||||
function parseHash() {
|
||||
var m = /^#(spool|print|load|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', '<a class="btn btn-sm" href="#page" style="position:absolute;left:-9999px;top:8px" onfocus="this.style.left=\'8px\'" onblur="this.style.left=\'-9999px\'">Skip to content</a><div class="shell">' + s.rail + '<main id="main">' + s.top + '<div id="page"></div></main></div>' + s.tabbar + s.rest);
|
||||
window.PAGE = page;
|
||||
$('#page').innerHTML = render();
|
||||
if (page.after) page.after();
|
||||
setTheme(document.documentElement.getAttribute('data-theme'));
|
||||
setInterval(tick, 1000);
|
||||
parseHash();
|
||||
var pq = /[?&]palette=([^&]*)/.exec(location.search); if (pq) palOpen(decodeURIComponent(pq[1]).replace(/\+/g, ' '));
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if ((a = t.closest('[data-set-theme]'))) return setTheme(a.getAttribute('data-set-theme'));
|
||||
if (t.closest('[data-toggle-theme]')) return setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
|
||||
if (t.closest('[data-palette]')) return palOpen();
|
||||
if (t.id === 'pal') return palClose();
|
||||
if ((a = t.closest('.pal-item'))) return palRun(+a.getAttribute('data-i'));
|
||||
if ((a = t.closest('[data-menu]'))) { 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('<b>' + a.getAttribute('data-soon') + '</b> keeps its current screen on dev. It isn\'t redesigned in this iteration.'); }
|
||||
if ((a = t.closest('[data-spool]'))) { e.preventDefault(); D.list = null; return openDrawer('spool', +a.getAttribute('data-spool'), a.getAttribute('data-tab-open') || 'overview'); }
|
||||
if ((a = t.closest('[data-print]'))) { e.preventDefault(); return openDrawer('print', +a.getAttribute('data-print')); }
|
||||
if ((a = t.closest('[data-load-into]'))) { e.preventDefault(); return openDrawer('load', a.getAttribute('data-load-into')); }
|
||||
if ((a = t.closest('[data-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 <b>' + esc(t.getAttribute('data-name')) + '</b>. 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 <b>' + esc(ss.color + ' · ' + ss.brand) + '</b> into ' + esc(cc2.printer.model + ' · ' + cc2.c.label) + ' <a href="#spool=' + sid + '" data-spool="' + sid + '">View</a>'); if (PAGE.refresh) PAGE.refresh(); return; }
|
||||
if (t.id === 'm-save') { var dest = $('[data-dest][aria-pressed="true"]').getAttribute('data-dest'), sp = spool(D.id); sp.cont = dest === 'storage' ? null : dest; sp.v++; D.tab = 'overview'; renderDrawer(); toast('Moved <b>' + esc(sp.color) + '</b> to ' + esc(where(sp).text)); if (PAGE.refresh) PAGE.refresh(); return; }
|
||||
if (t.id === 'w-save' || t.id === 'w-next') { var ws = spool(D.id), before = ws.rem, nv = Math.round(+$('#scale').value.replace(/[^\d.]/g, '') - ws.tare); ws.rem = nv; ws.remSrc = 'weighed'; ws.weighedAt = '18 Sep 09:41'; ws.v++; toast('Weighed <b>' + esc(ws.color + ' · ' + ws.brand) + '</b>: ' + g(before) + ' → ' + g(nv)); if (PAGE.refresh) PAGE.refresh(); if (t.id === 'w-next') { var nx = D.list[D.list.indexOf(D.id) + 1]; openDrawer('spool', nx, 'weigh', false); history.replaceState({ d: 1 }, '', '#spool=' + nx + '&tab=weigh'); } else { D.tab = 'overview'; renderDrawer(); } return; }
|
||||
if (t.closest('[data-save]')) { var sv = D.kind === 'new'; closeDrawer(); D.dirty = false; return toast(sv ? 'Added spool <b>#57</b>. <a href="#">Print its label</a>' : 'Saved changes'); }
|
||||
if (t.closest('[data-discard]')) { D.dirty = false; return closeDrawer(); }
|
||||
if (t.closest('[data-keep]')) { $('#discard-bar').hidden = true; return; }
|
||||
if ((a = t.closest('[data-fake]'))) { toast(a.getAttribute('data-fake')); return; }
|
||||
if ((a = t.closest('[data-delete]'))) { var ds = spool(+a.getAttribute('data-delete')); $('#drawer .drawer-foot').innerHTML = '<div class="stack" style="gap:8px;width:100%"><b>Delete ' + esc(ds.color + ' · ' + fullName(ds)) + '?</b><span class="small">Its print history stays. Prints keep their grams and cost and only lose the link to this spool. This can\'t be undone.</span><div class="row" style="justify-content:flex-end"><button class="btn" type="button" data-tab="overview" autofocus>Keep spool</button><button class="btn btn-danger" type="button" data-fake="Spool #' + ds.id + ' deleted. 0 prints lost their cost.">Delete spool</button></div></div>'; $('#drawer .drawer-foot [autofocus]').focus(); return; }
|
||||
});
|
||||
document.addEventListener('input', function (e) {
|
||||
if (e.target.id === 'pal-q') { P.sel = 0; return palRender(); }
|
||||
if (e.target.closest('#drawer .drawer-body') && D.tab === 'edit') D.dirty = true;
|
||||
if (e.target.id === 'scale') {
|
||||
var s = spool(D.id), v = +e.target.value.replace(/[^\d.]/g, ''), r = Math.round(v - s.tare);
|
||||
var ok = v > 0 && v >= s.tare && r <= s.tot + 1;
|
||||
$('#w-after').textContent = v ? g(r) : '—';
|
||||
$('#w-diff').innerHTML = v && ok ? '<span class="prov">' + (r - s.rem > 0 ? '+' : '') + (r - s.rem).toLocaleString('en-US') + ' g</span>' : '';
|
||||
$('#w-note').innerHTML = !v ? 'Type the reading and the result appears here before you save.' : v < s.tare ? '<span style="color:var(--status-error)">⚠ ' + g(v) + ' is less than the ' + g(s.tare) + ' empty spool. One of them is wrong. Nothing will be saved.</span>' : r > s.tot + 1 ? '<span style="color:var(--status-error)">⚠ That leaves ' + g(r) + ', more than the spool holds full (' + g(s.tot) + '). Check the total or the empty spool weight.</span>' : '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 };
|
||||
})();
|
||||
@@ -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 = """<script>
|
||||
(function () { var t = null; try { t = localStorage.getItem('extrudex-v3-theme'); } catch (e) {}
|
||||
var q = /[?&]theme=(light|dark)/.exec(location.search); if (q) t = q[1];
|
||||
if (t !== 'light' && t !== 'dark') t = window.matchMedia && matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', t); })();
|
||||
</script>"""
|
||||
|
||||
def build(slug, title, desc):
|
||||
src = (HERE / 'pages' / f'{slug}.html').read_text()
|
||||
css = ''.join(re.findall(r'<style>(.*?)</style>', src, re.S))
|
||||
js = ''.join(re.findall(r'<script>(.*?)</script>', src, re.S))
|
||||
html = f"""<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
<meta name="description" content="{desc}">
|
||||
{THEME}
|
||||
<style>
|
||||
{tokens}
|
||||
{base_css}
|
||||
{comp_css}
|
||||
{css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
{data_js}
|
||||
{app_js}
|
||||
</script>
|
||||
<script>
|
||||
{js}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
(OUT / f'{slug}.html').write_text(html)
|
||||
print(slug, len(html) // 1024, 'KB')
|
||||
|
||||
PAGES = [
|
||||
('index', 'Extrudex Flow Map', 'How the iteration-2 screens connect, with guided flows.'),
|
||||
('01-dashboard', 'Extrudex Dashboard', 'Attention-first dashboard, iteration 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)
|
||||
@@ -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; }
|
||||
@@ -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 }] };
|
||||
@@ -0,0 +1,76 @@
|
||||
<style>
|
||||
.dash { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(300px, 1fr); gap: var(--space-6); align-items: start; }
|
||||
.stats .stat { gap: 4px; }
|
||||
.stats a.stat { border: 1px solid var(--border-hairline); }
|
||||
.day { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); font-weight: var(--fw-bold); padding: 14px 16px 6px; }
|
||||
.feed { display: flex; flex-direction: column; }
|
||||
.feed-row { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: 110px minmax(0, 1fr) auto auto; gap: 4px var(--space-4); align-items: center; padding: 10px 16px; border-top: 1px solid var(--border-hairline); font-size: 13px; }
|
||||
.feed-row:hover { background: var(--accent-wash); }
|
||||
.feed-row:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
|
||||
.feed-row .job { font-weight: var(--fw-bold); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.feed-row .r { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.side-row { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; padding: 10px 16px; border-top: 1px solid var(--border-hairline); font-size: 13px; width: 100%; }
|
||||
.side-row:hover { background: var(--accent-wash); }
|
||||
.side-row:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
|
||||
.card-h { display: flex; align-items: center; gap: var(--space-3); padding: 14px 16px 10px; }
|
||||
.card-h h2 { margin: 0; font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); }
|
||||
.card-h a { margin-left: auto; font-size: var(--fs-label); }
|
||||
.swrow { display: inline-flex; margin-left: 6px; vertical-align: middle; }
|
||||
.swrow .swatch { margin-left: -6px; width: 18px; height: 18px; }
|
||||
@media (max-width: 980px) { .dash { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media (max-width: 620px) { .feed-row { grid-template-columns: minmax(0, 1fr) auto; } .feed-row .when { grid-column: 1 / -1; order: -1; } .feed-row .hide-s { display: none; } .attn { grid-template-columns: 36px minmax(0, 1fr); } .attn .ico { width: 36px; height: 36px; } .attn .btn { grid-column: 1 / -1; justify-content: center; } }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
function attention() {
|
||||
var lowS = SPOOLS.filter(x.low), tareS = SPOOLS.filter(x.unverified), tareU = tareS.filter(function (s) { return s.tare == null; }).length, need = x.printsNeeding(), down = PRINTERS.filter(function (p) { return !p.connected; });
|
||||
var withSug = need.filter(function (p) { return p.suggested || (p.filaments || []).some(function (f) { return f.suggested; }); }).length;
|
||||
var items = [];
|
||||
down.forEach(function (p) { items.push('<div class="attn err"><span class="ico" aria-hidden="true">⚡</span><div><h3>' + x.esc(p.name) + ' lost its connection</h3><p>Last contact ' + x.ago(p.age) + '. Print capture is paused; <b>' + x.esc(p.job) + '</b> is still open and will be reconciled on reconnect.</p></div><a class="btn btn-sm" href="04-printers.html#printer=' + p.id + '">Open printer</a></div>'); });
|
||||
if (need.length) items.push('<div class="attn warn"><span class="ico" aria-hidden="true">!</span><div><h3>' + need.length + ' prints need a spool or weight</h3><p>Their filament hasn\'t been deducted and their cost is incomplete.' + (withSug ? ' ' + withSug + ' have a same-color suggestion ready.' : '') + '</p></div><a class="btn btn-sm btn-primary" href="03-prints.html?tab=needs">Review ' + need.length + '</a></div>');
|
||||
if (lowS.length) items.push('<div class="attn warn"><span class="ico" aria-hidden="true">▁</span><div><h3>' + lowS.length + ' spools under 200 g <span class="swrow">' + lowS.map(function (s) { return x.sw(s.c); }).join('') + '</span></h3><p>' + lowS.filter(function (s) { return s.cont; }).length + ' of them are loaded in a printer right now.</p></div><a class="btn btn-sm" href="02-filament.html?f=low">See low stock</a></div>');
|
||||
if (tareS.length) items.push('<div class="attn info"><span class="ico" aria-hidden="true">?</span><div><h3>' + tareS.length + ' spools have an unverified empty spool weight</h3><p>' + tareU + ' unknown, so they can\'t be weighed yet. ' + (tareS.length - tareU) + ' use an estimated figure, so their weigh-ins could be off. Weighing an empty spool settles it.</p></div><a class="btn btn-sm" href="02-filament.html?f=unverified">Review empty spool weights</a></div>');
|
||||
return items.length ? items.join('') : '<div class="attn-done nm-inset">✓ Nothing needs you right now.</div>';
|
||||
}
|
||||
function feed() {
|
||||
var groups = [];
|
||||
PRINTS.slice(0, 7).forEach(function (p) { var w = x.when(p.start), d = /^(Today|Yesterday)/.test(w) ? w.split(' ')[0] : w.split(' ').slice(0, 2).join(' '); var last = groups[groups.length - 1]; if (!last || last.d !== d) groups.push(last = { d: d, list: [] }); last.list.push(p); });
|
||||
return groups.map(function (grp) {
|
||||
return '<div class="day">' + grp.d + '</div>' + grp.list.map(function (p) {
|
||||
var n = x.needs(p), pr = x.printer(p.printer);
|
||||
return '<button class="feed-row" type="button" data-print="' + p.id + '"><span class="when">' + x.printSt(p) + '<span class="sub">' + x.when(p.start).split(' ').pop() + '</span></span>' +
|
||||
'<span style="min-width:0"><span class="job" style="display:block">' + x.esc(p.job) + '</span><span class="sub">' + x.esc(pr.model) + (p.open && !pr.connected ? ' · <span class="prov warn"><i>⚡</i>open across outage</span>' : '') + (n.length ? ' · <span class="prov warn"><i>!</i>needs ' + n.map(function (i) { return i.t; }).join(', ') + '</span>' : '') + '</span></span>' +
|
||||
'<span class="r hide-s">' + x.durCell(p) + '</span><span class="r">' + (p.grams == null ? '<span class="muted">—</span>' : '<b>' + x.g(p.grams) + '</b><span class="sub">' + x.weightProv(p) + '</span>') + '</span></button>';
|
||||
}).join('');
|
||||
}).join('');
|
||||
}
|
||||
function printers() {
|
||||
return PRINTERS.map(function (p) {
|
||||
return '<a class="side-row" href="04-printers.html#printer=' + p.id + '"><span>' + x.printerSt(p) + '</span><span style="min-width:0"><b style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + x.esc(p.name) + '</b><span class="sub">' + (p.connected ? (p.job ? x.esc(p.job) : 'last: Benchy_x4') : 'last known: printing ' + x.esc(p.job)) + '</span></span><span class="sub">' + (p.connected ? x.ago(p.age) : '<span class="prov warn">' + x.ago(p.age) + '</span>') + '</span></a>';
|
||||
}).join('');
|
||||
}
|
||||
function lows() {
|
||||
return SPOOLS.filter(x.low).sort(function (a, b) { return a.rem - b.rem; }).map(function (s) {
|
||||
return '<button class="side-row" type="button" data-spool="' + s.id + '">' + x.sw(s.c) + '<span style="min-width:0"><b>' + x.esc(s.color) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">' + x.esc(x.fullName(s)) + ' · ' + x.esc(x.where(s).short) + '</span></span><span class="num strong" style="color:var(--status-warning)">' + x.g(s.rem) + '</span></button>';
|
||||
}).join('');
|
||||
}
|
||||
function render() {
|
||||
var today = PRINTS.filter(function (p) { return /^Today/.test(x.when(p.start)) && p.grams != null; });
|
||||
var usedG = today.reduce(function (a, p) { return a + p.grams; }, 0), usedC = today.reduce(function (a, p) { return a + (p.cost || 0); }, 0);
|
||||
return '<header class="page-head"><div><h1>Dashboard</h1><p class="sum">Friday 18 September · <b>' + PRINTERS.length + '</b> printers · <b>' + CANARY.spools + '</b> spools</p></div><button class="btn btn-primary primary-desktop" type="button" data-add-spool>+ Add spool</button></header>' +
|
||||
'<section aria-labelledby="needs-h" class="stack" style="gap:12px"><h2 class="section" id="needs-h" style="margin:0">Needs you</h2><div class="attn-list">' + attention() + '</div></section>' +
|
||||
'<section class="stats" aria-label="Stock">' +
|
||||
'<a class="stat nm-raised tile-link" href="02-filament.html"><span class="stat-label">Spools</span><span class="stat-value">' + CANARY.spools + '</span><span class="stat-foot">' + CANARY.brands + ' brands · 6 materials →</span></a>' +
|
||||
'<div class="stat nm-raised"><span class="stat-label">Filament left</span><span class="stat-value">' + CANARY.kg.toFixed(2) + ' kg</span><span class="stat-foot">net: filament only, no spools</span></div>' +
|
||||
'<div class="stat nm-raised"><span class="stat-label">Stock value</span><span class="stat-value">' + x.usd(CANARY.value) + '</span><span class="stat-foot">what\'s left × each $/kg</span></div>' +
|
||||
'<a class="stat nm-raised tile-link" href="03-prints.html"><span class="stat-label">Used today</span><span class="stat-value">' + x.g(usedG) + '</span><span class="stat-foot">' + x.usd(usedC) + ' · ' + today.length + ' done · 2 running →</span></a>' +
|
||||
'</section>' +
|
||||
'<div class="dash"><section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="act-h"><div class="card-h"><h2 id="act-h">Recent prints</h2><a href="03-prints.html">All prints →</a></div><div class="feed">' + feed() + '</div></section>' +
|
||||
'<div class="stack" style="gap:24px"><section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="pr-h"><div class="card-h"><h2 id="pr-h">Printers</h2><a href="04-printers.html">Open →</a></div>' + printers() + '</section>' +
|
||||
'<section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="low-h"><div class="card-h"><h2 id="low-h">Running low</h2><a href="02-filament.html?f=low">All →</a></div>' + lows() + '</section></div></div>' +
|
||||
'<button class="btn btn-primary fab" type="button" data-add-spool aria-label="Add spool">+</button>';
|
||||
}
|
||||
X.init({ name: 'dashboard', refresh: function () { document.getElementById('page').innerHTML = render(); } }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,115 @@
|
||||
<style>
|
||||
.toolbar { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.toolbar .row1 { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
|
||||
.searchbox { position: relative; flex: 1; min-width: 220px; }
|
||||
.searchbox svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-secondary); }
|
||||
.searchbox .input { padding-left: 36px; }
|
||||
.ctl { display: inline-flex; gap: 8px; align-items: center; font-size: var(--fs-label); color: var(--text-secondary); }
|
||||
.ctl .select { min-height: 36px; padding: 6px 10px; width: auto; font-size: 13px; }
|
||||
.grp td { background: var(--surface-inset); font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); font-weight: var(--fw-bold); color: var(--text-secondary); padding: 8px 12px; }
|
||||
.spcell { display: flex; gap: var(--space-3); align-items: center; min-width: 220px; }
|
||||
.remcell { min-width: 160px; }
|
||||
.remcell .top { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 5px; }
|
||||
.rowact { display: flex; gap: 6px; justify-content: flex-end; }
|
||||
.rowact .btn { min-height: 30px; padding: 4px 10px; }
|
||||
.weigh-hint { display: flex; gap: var(--space-3); align-items: center; padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); background: var(--accent-wash); border: 1px solid var(--text-accent); font-size: 13px; }
|
||||
@media (max-width: 1180px) { .wl { display: none; } .rowact .btn { white-space: nowrap; } }
|
||||
@media (max-width: 760px) { .hide-m { display: none; } .spcell { min-width: 0; } .remcell { min-width: 110px; } .rowact .menu-wrap .btn { padding: 4px 8px; } }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var q = new URLSearchParams(location.search);
|
||||
var S = { view: ({ tare: 'unverified' })[q.get('f')] || q.get('f') || 'all', q: q.get('q') || '', mat: '', sort: 'rem', dir: 1, cols: 'stock', group: 'none' };
|
||||
var VIEWS = [
|
||||
['all', 'All', function () { return true; }],
|
||||
['low', 'Low', x.low],
|
||||
['loaded', 'Loaded', function (s) { return !!s.cont; }],
|
||||
['storage', 'In storage', function (s) { return !s.cont; }],
|
||||
['unverified', 'Empty spool weight unverified', x.unverified]
|
||||
];
|
||||
function viewFn(k) { for (var i = 0; i < VIEWS.length; i++) if (VIEWS[i][0] === k) return VIEWS[i]; return VIEWS[0]; }
|
||||
function list() {
|
||||
var f = viewFn(S.view)[2], qq = S.q.toLowerCase();
|
||||
var l = SPOOLS.filter(function (s) { return f(s) && (!S.mat || s.mat === S.mat) && (!qq || ('#' + s.id + ' ' + x.fullName(s) + ' ' + s.color + ' ' + x.where(s).text).toLowerCase().indexOf(qq) >= 0); });
|
||||
var key = { rem: function (s) { return s.rem; }, name: function (s) { return (s.color + s.brand).toLowerCase(); }, value: function (s) { return x.value(s); }, gross: function (s) { return x.gross(s) == null ? -1 : x.gross(s); } }[S.sort];
|
||||
l.sort(function (a, b) { var A = key(a), B = key(b); return (A < B ? -1 : A > B ? 1 : 0) * S.dir; });
|
||||
if (S.group !== 'none') { var gk = S.group === 'mat' ? function (s) { return s.mat; } : function (s) { return x.where(s).loaded ? x.where(s).printer.name : 'Storage'; }; l.sort(function (a, b) { return gk(a) < gk(b) ? -1 : gk(a) > gk(b) ? 1 : 0; }); l.gk = gk; }
|
||||
return l;
|
||||
}
|
||||
function th(label, key, cls) { var s = S.sort === key ? (S.dir === 1 ? 'ascending' : 'descending') : 'none'; return '<th class="' + (cls || '') + '" aria-sort="' + s + '"><button class="sort" type="button" data-sort="' + key + '">' + label + '</button></th>'; }
|
||||
function rowMenu(s) {
|
||||
return '<div class="menu-wrap"><button class="btn btn-sm btn-icon" type="button" data-menu="rm' + s.id + '" aria-haspopup="menu" aria-expanded="false" aria-label="Actions for ' + x.esc(s.color + ' ' + s.brand) + '">⋯</button><div class="menu" id="rm' + s.id + '" role="menu">' +
|
||||
'<button role="menuitem" type="button" data-spool="' + s.id + '">Open<span class="hint">↵</span></button><button role="menuitem" type="button" data-spool="' + s.id + '" data-tab-open="weigh">Weigh</button><button role="menuitem" type="button" data-spool="' + s.id + '" data-tab-open="move">' + (s.cont ? 'Move / unload' : 'Load into printer') + '</button><button role="menuitem" type="button" data-spool="' + s.id + '" data-tab-open="edit">Edit</button>' +
|
||||
(s.label ? '' : '<button role="menuitem" type="button" data-fake="Label PDF generated for #' + s.id + '">Print label</button>') + '<hr><button role="menuitem" type="button" class="danger" data-spool="' + s.id + '" data-then-delete>Delete…</button></div></div>';
|
||||
}
|
||||
function rows(l) {
|
||||
var out = '', last = null, cols = S.cols === 'stock' ? 5 : 5;
|
||||
l.forEach(function (s) {
|
||||
if (l.gk) { var gv = l.gk(s); if (gv !== last) { var n = l.filter(function (y) { return l.gk(y) === gv; }), tot = n.reduce(function (a, y) { return a + y.rem; }, 0); out += '<tr class="grp"><td colspan="' + cols + '">' + x.esc(gv) + ' · ' + n.length + ' spools · ' + x.kg(tot) + '</td></tr>'; last = gv; } }
|
||||
var w = x.where(s), gr = x.gross(s);
|
||||
out += '<tr class="clickable" tabindex="0" data-row-spool="' + s.id + '" aria-label="' + x.esc(s.color + ', ' + x.fullName(s) + ', ' + s.rem + ' grams') + '">' +
|
||||
'<td><div class="spcell">' + x.sw(s.c) + '<span style="min-width:0"><span class="strong">' + x.esc(s.color) + '</span> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">' + x.esc(x.fullName(s)) + '</span></span></div></td>' +
|
||||
'<td><div class="remcell"><div class="top"><b class="num"' + (x.low(s) ? ' style="color:var(--status-warning)"' : '') + '>' + x.g(s.rem) + '</b><span class="sub" style="display:inline">of ' + x.g(s.tot) + '</span></div>' + x.meter(s) + '</div></td>' +
|
||||
(S.cols === 'stock'
|
||||
? '<td class="hide-m">' + (w.loaded ? '<span class="st run" style="font-weight:500">' + x.esc(w.short) + '</span>' : '<span class="muted">' + x.esc(w.text) + '</span>') + '</td><td class="r hide-m hide-t"><span class="num">' + x.usd(x.value(s)) + '</span><span class="sub">' + x.usd(s.kg) + '/kg</span></td>'
|
||||
: '<td class="r hide-m">' + (s.tare == null ? '<span class="prov warn"><i>?</i>unknown</span>' : '<span class="num">' + x.g(s.tare) + '</span><span class="sub">' + x.tareProv(s) + '</span>') + '</td><td class="r hide-m">' + (gr == null ? '<span class="muted">unknown</span>' : '<b class="num">' + x.g(gr) + '</b><span class="sub">' + x.remProv(s) + '</span>') + '</td>') +
|
||||
'<td><div class="rowact">' + (s.tare != null ? '<button class="btn btn-sm hide-m" type="button" data-spool="' + s.id + '" data-tab-open="weigh" aria-label="Weigh ' + x.esc(s.color) + ' #' + s.id + '">⚖<span class="wl"> Weigh</span></button>' : '') + rowMenu(s) + '</div></td></tr>';
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function render() {
|
||||
var l = list(), all = SPOOLS.length;
|
||||
x.D.list = l.map(function (s) { return s.id; });
|
||||
var chips = [];
|
||||
if (S.view !== 'all') chips.push(['view', viewFn(S.view)[1]]);
|
||||
if (S.mat) chips.push(['mat', S.mat]);
|
||||
if (S.q) chips.push(['q', '“' + S.q + '”']);
|
||||
var weighable = l.filter(function (s) { return s.tare != null; });
|
||||
return '<header class="page-head"><div><h1>Filament</h1><p class="sum"><b>' + CANARY.spools + '</b> spools · <b>' + CANARY.kg.toFixed(2) + ' kg</b> net · <b>' + x.usd(CANARY.value) + '</b> · sample page of ' + all + '</p></div>' +
|
||||
'<div class="actions"><div class="menu-wrap"><button class="btn" type="button" data-menu="pgmenu" aria-haspopup="menu" aria-expanded="false">More ▾</button><div class="menu" id="pgmenu" role="menu"><button role="menuitem" type="button" data-fake="Labels PDF for 4 spools generated">Print ' + SPOOLS.filter(function (s) { return !s.label; }).length + ' missing labels</button><button role="menuitem" type="button" data-soon="Lists">Manage lists (brands, materials…)</button></div></div>' +
|
||||
'<button class="btn btn-primary primary-desktop" type="button" data-add-spool>+ Add spool</button></div></header>' +
|
||||
'<section class="toolbar nm-raised" aria-label="Find spools">' +
|
||||
'<div class="tabs" role="tablist" aria-label="Saved views">' + VIEWS.map(function (v) { var n = SPOOLS.filter(v[2]).length; return '<button role="tab" type="button" data-view="' + v[0] + '" aria-selected="' + (S.view === v[0]) + '">' + v[1] + ' <span class="n' + ((v[0] === 'low' || v[0] === 'unverified') && n ? ' warn' : '') + '">' + n + '</span></button>'; }).join('') + '</div>' +
|
||||
'<div class="row1"><label class="searchbox">' + X.ICON.search + '<input class="input" id="fq" type="search" value="' + x.esc(S.q) + '" placeholder="Filter by color, brand, #id, location…" aria-label="Filter spools"></label>' +
|
||||
'<label class="ctl">Material <select class="select" id="fmat"><option value="">Any</option>' + ['PLA', 'PETG', 'ASA', 'TPU', 'PA6', 'ABS'].map(function (m) { return '<option' + (S.mat === m ? ' selected' : '') + '>' + m + '</option>'; }).join('') + '</select></label>' +
|
||||
'<label class="ctl">Group <select class="select" id="fgroup"><option value="none">None</option><option value="mat"' + (S.group === 'mat' ? ' selected' : '') + '>Material</option><option value="loc"' + (S.group === 'loc' ? ' selected' : '') + '>Location</option></select></label>' +
|
||||
'<div class="tabs" role="group" aria-label="Columns"><button type="button" data-cols="stock" aria-selected="' + (S.cols === 'stock') + '">Stock</button><button type="button" data-cols="weights" aria-selected="' + (S.cols === 'weights') + '">Weights</button></div></div>' +
|
||||
(chips.length ? '<div class="fchips" aria-label="Active filters"><span class="sub" style="display:inline">' + l.length + ' of ' + all + ' shown</span>' + chips.map(function (c) { return '<span class="fchip">' + x.esc(c[1]) + '<button type="button" data-unchip="' + c[0] + '" aria-label="Remove filter ' + x.esc(c[1]) + '">✕</button></span>'; }).join('') + '<button class="linkish small" type="button" data-unchip="all">Clear all</button></div>' : '') +
|
||||
'</section>' +
|
||||
(weighable.length > 1 && S.view !== 'all' ? '<div class="weigh-hint"><b>⚖</b><span style="flex:1">Weighing these? Weigh all ' + weighable.length + ', lightest first. Each step shows where the spool lives and the result before you save.</span><button class="btn btn-sm btn-primary" type="button" data-weigh-session>Start weighing</button></div>' : '') +
|
||||
'<section class="nm-raised table-card" aria-label="Spools">' + (l.length ? '<div class="table-scroll"><table class="tbl"><thead><tr>' + th('Spool', 'name') + th('Remaining <abbr title="net filament">net</abbr>', 'rem') +
|
||||
(S.cols === 'stock' ? '<th class="hide-m">Location</th>' + th('Value', 'value', 'r hide-m hide-t') : '<th class="r hide-m">Empty spool</th>' + th('Gross (scale)', 'gross', 'r hide-m')) + '<th class="r"><span class="sr-only">Actions</span></th></tr></thead><tbody>' + rows(l) + '</tbody></table></div>' +
|
||||
'<div class="table-foot"><span class="muted">Click a row or press ↵ to open · <span class="kbd">j</span>/<span class="kbd">k</span> step through in the panel</span><span class="muted">1–' + l.length + ' of ' + (S.view === 'all' && !S.q && !S.mat ? CANARY.spools : l.length) + '</span></div>'
|
||||
: '<div class="empty"><h3>No spools match</h3><p>' + (S.q ? 'Nothing matches “' + x.esc(S.q) + '”' : 'Nothing in this view') + (S.mat ? ' in ' + S.mat : '') + '.</p><button class="btn btn-sm" type="button" data-unchip="all">Clear filters</button></div>') + '</section>' +
|
||||
'<button class="btn btn-primary fab" type="button" data-add-spool aria-label="Add spool">+</button>';
|
||||
}
|
||||
function redraw(keepFocus) {
|
||||
var a = document.activeElement, id = a && a.id, pos = a && a.selectionStart;
|
||||
document.getElementById('page').innerHTML = render();
|
||||
if (keepFocus && id && document.getElementById(id)) { var el = document.getElementById(id); el.focus(); if (pos != null && el.setSelectionRange) el.setSelectionRange(pos, pos); }
|
||||
var p = new URLSearchParams(); if (S.view !== 'all') p.set('f', S.view); if (S.q) p.set('q', S.q);
|
||||
history.replaceState(history.state, '', location.pathname + (p.toString() ? '?' + p : '') + location.hash);
|
||||
if (x.D.kind === 'spool') document.querySelectorAll('[data-row-spool="' + x.D.id + '"]').forEach(function (r) { r.classList.add('is-selected'); });
|
||||
}
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if ((a = t.closest('[data-view]'))) { S.view = a.getAttribute('data-view'); return redraw(); }
|
||||
if ((a = t.closest('[data-cols]'))) { S.cols = a.getAttribute('data-cols'); return redraw(); }
|
||||
if ((a = t.closest('[data-sort]'))) { var k = a.getAttribute('data-sort'); S.dir = S.sort === k ? -S.dir : 1; S.sort = k; return redraw(); }
|
||||
if ((a = t.closest('[data-unchip]'))) { var c = a.getAttribute('data-unchip'); if (c === 'view' || c === 'all') S.view = 'all'; if (c === 'mat' || c === 'all') S.mat = ''; if (c === 'q' || c === 'all') S.q = ''; return redraw(); }
|
||||
if (t.closest('[data-weigh-session]')) { var first = list().filter(function (s) { return s.tare != null; }).sort(function (a, b) { return a.rem - b.rem; }); x.D.list = first.map(function (s) { return s.id; }); x.D.session = true; x.openDrawer('spool', first[0].id, 'weigh'); x.D.session = true; x.D.list = first.map(function (s) { return s.id; }); return; }
|
||||
if ((a = t.closest('[data-then-delete]'))) { setTimeout(function () { var b = document.querySelector('#drawer [data-delete]'); if (b) b.click(); }, 50); return; }
|
||||
if (t.closest('button, a, .menu')) return;
|
||||
if ((a = t.closest('[data-row-spool]'))) { x.D.list = list().map(function (s) { return s.id; }); x.openDrawer('spool', +a.getAttribute('data-row-spool')); }
|
||||
});
|
||||
document.addEventListener('input', function (e) {
|
||||
if (e.target.id === 'fq') { S.q = e.target.value; redraw(true); }
|
||||
});
|
||||
document.addEventListener('change', function (e) {
|
||||
if (e.target.id === 'fmat') { S.mat = e.target.value; redraw(); }
|
||||
if (e.target.id === 'fgroup') { S.group = e.target.value; redraw(); }
|
||||
});
|
||||
X.init({ name: 'filament', refresh: function () { redraw(); } }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
<style>
|
||||
.qcard { padding: var(--space-4) var(--space-5); display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(260px, 1.1fr); gap: var(--space-4); align-items: start; }
|
||||
.qcard.is-done { opacity: .55; }
|
||||
.qcard .what { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.qcard h3 { margin: 0; font-size: 15px; }
|
||||
.fixes { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.fix { display: flex; flex-direction: column; gap: 6px; }
|
||||
.fix .lab { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps); color: var(--status-warning); font-weight: var(--fw-bold); }
|
||||
.sug { display: flex; gap: 10px; align-items: center; padding: 8px 10px; border-radius: var(--radius-md); border: 1px dashed var(--text-accent); background: var(--accent-wash); font-size: 13px; }
|
||||
.batch { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; padding: var(--space-3) var(--space-4); }
|
||||
.chk { width: 20px; height: 20px; accent-color: var(--text-accent); }
|
||||
.filters { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: center; }
|
||||
.filters .select, .filters .input { width: auto; min-height: 36px; padding: 6px 10px; font-size: 13px; }
|
||||
.jobcell { min-width: 200px; }
|
||||
@media (max-width: 900px) { .qcard { grid-template-columns: auto minmax(0, 1fr); } .qcard .fixes { grid-column: 1 / -1; } }
|
||||
@media (max-width: 760px) { .hide-m { display: none; } .jobcell { min-width: 0; } }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var q = new URLSearchParams(location.search);
|
||||
var S = { tab: q.get('tab') || (x.printsNeeding().length ? 'needs' : 'all'), printer: '', status: '', q: '', sel: {}, done: {} };
|
||||
function fixUI(p) {
|
||||
var out = '';
|
||||
if (p.grams == null) out += '<div class="fix"><span class="lab">! Filament weight</span><span class="row" style="gap:8px"><input class="input num" style="max-width:130px;min-height:36px" placeholder="grams" inputmode="numeric" aria-label="Grams used by ' + x.esc(p.job) + '"><button class="btn btn-sm" type="button" data-done="' + p.id + '" data-msg="Saved weight for ' + x.esc(p.job) + ' · remembered for ' + x.esc(p.key) + '">Save</button></span><span class="sub">From the slicer. Remembered for this gcode path.</span></div>';
|
||||
var slots = p.filaments ? p.filaments.filter(function (f) { return f.spool == null; }) : (p.spool == null ? [{ suggested: p.suggested, slot: null, grams: p.grams }] : []);
|
||||
slots.forEach(function (f) {
|
||||
var s = f.suggested && x.spool(f.suggested);
|
||||
out += '<div class="fix"><span class="lab">! Spool' + (f.slot != null ? ' for color ' + (f.slot + 1) + ' <span class="swatch swatch-sm" style="vertical-align:-3px"><i style="background:' + f.hex + '"></i></span>' : '') + '</span>' +
|
||||
(s ? '<div class="sug">' + x.sw(s.c, 'swatch-sm') + '<span style="flex:1;min-width:0"><b>' + x.esc(x.short(s)) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">Same color, loaded in ' + x.esc(x.where(s).short) + '. Not charged until you confirm.</span></span><button class="btn btn-sm btn-primary" type="button" data-done="' + p.id + '" data-msg="Attributed ' + x.esc(p.job) + ' to ' + x.esc(x.short(s)) + (f.grams ? ' · ' + x.g(f.grams) + ' deducted' : '') + '">Use</button></div>' : '') +
|
||||
'<select class="select" style="min-height:36px" aria-label="Pick spool"><option>' + (s ? 'Or pick another…' : 'Pick a spool…') + '</option>' + SPOOLS.slice(0, 10).map(function (sp) { return '<option>' + x.g(sp.rem) + ' · ' + x.esc(x.short(sp)) + ' · ' + sp.mat + '</option>'; }).join('') + '</select></div>';
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function needsTab() {
|
||||
var l = x.printsNeeding();
|
||||
if (!l.length) return '<div class="nm-raised empty"><h3>✓ Every print is attributed</h3><p>New prints appear here when they end without a spool or weight.</p></div>';
|
||||
var nsel = Object.keys(S.sel).filter(function (k) { return S.sel[k]; }).length;
|
||||
return '<div class="batch nm-inset"><label class="row small" style="gap:8px"><input type="checkbox" class="chk" data-selall ' + (nsel === l.length ? 'checked' : '') + '> Select all ' + l.length + '</label><span class="spacer"></span><span class="small muted">' + nsel + ' selected</span><button class="btn btn-sm" type="button" ' + (nsel ? 'data-fake="Attributed ' + nsel + ' prints to one spool"' : 'disabled') + '>Attribute selected to one spool…</button></div>' +
|
||||
'<p class="small muted" style="margin:0">Oldest first. Until these have a spool and a weight, nothing is deducted and cost stays incomplete.</p>' +
|
||||
'<div class="stack" style="gap:12px">' + l.slice().reverse().map(function (p) {
|
||||
var pr = x.printer(p.printer), done = S.done[p.id];
|
||||
return '<article class="qcard nm-raised' + (done ? ' is-done' : '') + '"><input type="checkbox" class="chk" data-sel="' + p.id + '" aria-label="Select ' + x.esc(p.job) + '" ' + (S.sel[p.id] ? 'checked' : '') + '>' +
|
||||
'<div class="what"><h3><button class="linkish" type="button" data-print="' + p.id + '" style="text-decoration:none;color:var(--text-primary)">' + x.esc(p.job) + '</button></h3><span class="sub">' + x.esc(pr.name) + ' · ' + x.when(p.end || p.start) + '</span><span class="row small" style="gap:12px;margin-top:4px">' + x.printSt(p) + '<span>' + x.durCell(p).replace(/<span class="sub[^"]*">/g, ' <span class="sub" style="display:inline">') + '</span>' + (p.grams != null ? '<span><b>' + x.g(p.grams) + '</b> ' + x.weightProv(p) + '</span>' : '') + '</span>' +
|
||||
(p.filaments ? '<span class="sub">' + p.filaments.length + ' colors · ' + p.filaments.filter(function (f) { return f.spool; }).length + ' attributed</span>' : '') + '</div>' +
|
||||
'<div class="fixes">' + (done ? '<span class="st ok">Done</span>' : fixUI(p)) + '</div></article>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
function allTab(filter) {
|
||||
var l = PRINTS.filter(function (p) { return filter(p) && (!S.printer || p.printer === +S.printer) && (!S.status || p.status === S.status) && (!S.q || (p.job + ' ' + p.key).toLowerCase().indexOf(S.q.toLowerCase()) >= 0); });
|
||||
var head = '<div class="filters"><input class="input" id="pq" type="search" placeholder="Job name or path…" value="' + x.esc(S.q) + '" aria-label="Filter prints"><select class="select" id="pp" aria-label="Printer"><option value="">All printers</option>' + PRINTERS.map(function (p) { return '<option value="' + p.id + '"' + (+S.printer === p.id ? ' selected' : '') + '>' + x.esc(p.name) + '</option>'; }).join('') + '</select>' +
|
||||
'<select class="select" id="ps" aria-label="Status"><option value="">Any status</option>' + ['running', 'finished', 'failed', 'unknown'].map(function (s) { return '<option' + (S.status === s ? ' selected' : '') + '>' + s + '</option>'; }).join('') + '</select><span class="small muted">' + l.length + ' prints</span></div>';
|
||||
if (!l.length) return head + '<div class="nm-raised empty"><h3>No prints match</h3><p>Change the printer, status or search.</p></div>';
|
||||
return head + '<section class="nm-raised table-card"><div class="table-scroll"><table class="tbl"><thead><tr><th>Job</th><th>Status</th><th class="r hide-m">Duration</th><th class="r">Filament</th><th class="hide-m">Spool</th><th class="r hide-m hide-t">Cost</th><th class="hide-m hide-t">Use</th></tr></thead><tbody>' +
|
||||
l.map(function (p) { var pr = x.printer(p.printer), n = x.needs(p); return '<tr class="clickable" tabindex="0" data-row-print="' + p.id + '"><td><div class="jobcell"><span class="strong">' + x.esc(p.job) + '</span><span class="sub">' + x.esc(pr.model) + ' · ' + x.when(p.start) + '</span></div></td><td>' + x.printSt(p) + (n.length ? '<span class="sub prov warn"><i>!</i>needs ' + n.map(function (i) { return i.k; }).join(' + ') + '</span>' : '') + '</td><td class="r hide-m">' + x.durCell(p) + '</td><td class="r">' + (p.grams == null ? '<span class="muted">—</span>' : '<b class="num">' + x.g(p.grams) + '</b><span class="sub">' + x.weightProv(p) + '</span>') + '</td><td class="hide-m">' + x.spoolLinks(p) + '</td><td class="r hide-m hide-t">' + (p.cost == null ? '<span class="muted">pending</span>' : x.usd(p.cost)) + '</td><td class="hide-m hide-t">' + (p.use ? '<span class="small">' + p.use + '</span>' : '<span class="muted small">not recorded</span>') + '</td></tr>'; }).join('') +
|
||||
'</tbody></table></div><div class="table-foot"><span class="muted">Durations marked ≤ span a connection gap and are upper bounds. Hover for why.</span><span class="muted">' + l.length + ' of ' + PRINTS.length + '</span></div></section>';
|
||||
}
|
||||
function render() {
|
||||
var n = x.printsNeeding().length, run = PRINTS.filter(function (p) { return p.status === 'running'; }).length;
|
||||
var body = S.tab === 'needs' ? needsTab() : S.tab === 'all' ? allTab(function () { return true; }) : S.tab === 'running' ? allTab(function (p) { return p.status === 'running'; }) : '<div class="nm-raised empty"><h3>No archived prints</h3><p>Archiving hides a print from history. Its grams and cost still count.</p></div>';
|
||||
return '<header class="page-head"><div><h1>Prints</h1><p class="sum"><b>' + PRINTS.length + '</b> captured (sample) · <b>' + run + '</b> running · <b>' + n + '</b> need attention</p></div></header>' +
|
||||
'<div class="tabs" role="tablist" aria-label="Print lists">' + [['needs', 'Needs attention', n, 1], ['all', 'All', PRINTS.length], ['running', 'Running', run], ['archived', 'Archived', 0]].map(function (t) { return '<button role="tab" type="button" data-ptab="' + t[0] + '" aria-selected="' + (S.tab === t[0]) + '">' + t[1] + ' <span class="n' + (t[3] && t[2] ? ' warn' : '') + '">' + t[2] + '</span></button>'; }).join('') + '</div>' +
|
||||
'<div class="stack" style="gap:16px">' + body + '</div>';
|
||||
}
|
||||
function redraw(focusId) {
|
||||
var pos = focusId && document.getElementById(focusId) && document.getElementById(focusId).selectionStart;
|
||||
document.getElementById('page').innerHTML = render();
|
||||
if (focusId && document.getElementById(focusId)) { var el = document.getElementById(focusId); el.focus(); if (pos != null) el.setSelectionRange(pos, pos); }
|
||||
history.replaceState(history.state, '', location.pathname + '?tab=' + S.tab + location.hash);
|
||||
}
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if ((a = t.closest('[data-ptab]'))) { S.tab = a.getAttribute('data-ptab'); return redraw(); }
|
||||
if ((a = t.closest('[data-done]'))) { var id = +a.getAttribute('data-done'), pp = x.print(id); if (/weight/.test(a.getAttribute('data-msg'))) { pp.grams = 18; pp.wsrc = 'typed'; } else if (pp.filaments) { pp.filaments.forEach(function (f) { if (f.spool == null) f.spool = f.suggested || 52; }); } else { pp.spool = pp.suggested || 52; } X.toast(a.getAttribute('data-msg') + ' <a href="#print=' + id + '" data-print="' + id + '">View</a>'); return redraw(); }
|
||||
if (t.matches('[data-selall]')) { x.printsNeeding().forEach(function (p) { S.sel[p.id] = t.checked; }); return redraw(); }
|
||||
if (t.matches('[data-sel]')) { S.sel[t.getAttribute('data-sel')] = t.checked; return redraw(); }
|
||||
if (t.closest('button, a, input, select')) return;
|
||||
if ((a = t.closest('[data-row-print]'))) x.openDrawer('print', +a.getAttribute('data-row-print'));
|
||||
});
|
||||
document.addEventListener('input', function (e) { if (e.target.id === 'pq') { S.q = e.target.value; redraw('pq'); } });
|
||||
document.addEventListener('change', function (e) { if (e.target.id === 'pp') { S.printer = e.target.value; redraw(); } if (e.target.id === 'ps') { S.status = e.target.value; redraw(); } });
|
||||
X.init({ name: 'prints', refresh: function () { redraw(); } }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
<style>
|
||||
.jump { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }
|
||||
.jump a { display: inline-flex; gap: 8px; align-items: center; padding: 6px 12px; border-radius: var(--radius-pill); border: 1px solid var(--border-control); text-decoration: none; color: var(--text-primary); font-size: 13px; background: var(--surface-raised); box-shadow: var(--nm-raised-1); }
|
||||
.jump a:hover { border-color: var(--text-accent); }
|
||||
.pgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: var(--space-6); align-items: start; }
|
||||
.pcard { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); scroll-margin-top: 16px; }
|
||||
.pcard.attn-card { border: 2px solid var(--status-warning); }
|
||||
.phead { display: flex; gap: var(--space-3); align-items: flex-start; }
|
||||
.phead h2 { margin: 0; font-size: var(--fs-h3); line-height: var(--lh-snug); }
|
||||
.now { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px var(--space-3); padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); width: 100%; font-size: 13px; }
|
||||
.now:hover { border-color: var(--text-accent); }
|
||||
.now:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
|
||||
.now.stale { border: 1px dashed var(--status-warning); }
|
||||
.cam { aspect-ratio: 16 / 7; border-radius: var(--radius-md); background: radial-gradient(ellipse at 50% 95%, #5a4b3a 0 30%, transparent 31%), linear-gradient(180deg, #1c1d22 0%, #2b2c33 60%, #3a3326 100%); border: 1px solid var(--border-control); position: relative; overflow: hidden; }
|
||||
.cam::after { content: ""; position: absolute; left: 34%; right: 34%; bottom: 18%; height: 26%; background: repeating-linear-gradient(0deg, #6b8fd6 0 3px, #5a7cc2 3px 6px); border-radius: 4px 4px 0 0; }
|
||||
.cam .stamp { position: absolute; left: 8px; top: 6px; font-size: var(--fs-caption); color: #f4f4f6; background: rgba(0,0,0,.55); padding: 2px 8px; border-radius: 99px; z-index: 1; }
|
||||
.cam-off { padding: var(--space-3); border-radius: var(--radius-md); border: 1px dashed var(--border-control); text-align: center; color: var(--text-secondary); font-size: var(--fs-label); }
|
||||
.holder { padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); display: flex; flex-direction: column; gap: 6px; }
|
||||
.holder-h { display: flex; gap: var(--space-3); align-items: center; font-size: var(--fs-label); }
|
||||
.fill { display: inline-flex; gap: 3px; }
|
||||
.fill i { width: 14px; height: 8px; border-radius: 2px; border: 1px solid var(--border-control); }
|
||||
.fill i.on { background: var(--text-accent); border-color: var(--text-accent); }
|
||||
.hrow { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; padding: 6px 8px; margin: 0 -8px; border-radius: var(--radius-sm); font-size: 13px; }
|
||||
.hrow:hover { background: var(--accent-wash); }
|
||||
.hrow:focus-visible { outline: var(--focus-width) solid var(--focus-ring); }
|
||||
.lost { padding: var(--space-4); border-radius: var(--radius-md); border: 1px solid var(--status-warning); background: var(--wash-warning); font-size: 13px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.lost h3 { margin: 0; font-size: 14px; color: var(--status-warning); }
|
||||
.lost ul { margin: 0; padding-left: 18px; }
|
||||
details.cfg summary { cursor: pointer; font-size: var(--fs-label); font-weight: var(--fw-bold); }
|
||||
.future { padding: var(--space-6); border: 2px dashed var(--border-control); border-radius: var(--radius-lg); }
|
||||
.fgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: var(--space-4); margin-top: var(--space-4); }
|
||||
.fitem { padding: var(--space-4); border: 1px dashed var(--border-control); border-radius: var(--radius-md); font-size: var(--fs-label); display: flex; flex-direction: column; gap: 6px; }
|
||||
.fitem b { font-size: 13px; }
|
||||
body.dev-only [data-feature] { display: none !important; }
|
||||
body:not(.dev-only) [data-devonly] { display: none !important; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X, FEAT = '<span class="prov info" title="Only on the printer-connections feature branch"><i>◈</i>feature</span>';
|
||||
function lastPrint(pr) { return PRINTS.filter(function (p) { return p.printer === pr.id && p.status !== 'running'; })[0]; }
|
||||
function holder(c) {
|
||||
var sp = x.contents(c.id), free = c.cap - sp.length;
|
||||
return '<div class="holder"><div class="holder-h"><b>' + x.esc(c.label) + '</b><span class="fill" aria-label="' + sp.length + ' of ' + c.cap + ' in use">' + Array.apply(null, Array(c.cap)).map(function (_, i) { return '<i class="' + (i < sp.length ? 'on' : '') + '"></i>'; }).join('') + '</span><span class="muted">' + sp.length + ' / ' + c.cap + '</span><span class="spacer"></span>' +
|
||||
(free > 0 ? '<button class="btn btn-sm" type="button" data-load-into="' + c.id + '" style="min-height:28px;padding:3px 10px">+ Load</button>' : '<span class="sub">full</span>') + '</div>' +
|
||||
(sp.length ? sp.map(function (s) { return '<button class="hrow" type="button" data-spool="' + s.id + '">' + x.sw(s.c, 'swatch-sm') + '<span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><b>' + x.esc(s.color) + '</b> <span class="muted">' + x.esc(s.brand + ' ' + s.mat + (s.type ? ' ' + s.type : '')) + '</span></span><span class="num' + (x.low(s) ? '" style="color:var(--status-warning);font-weight:700">⚠ ' : '">') + x.g(s.rem) + '</span></button>'; }).join('') : '<span class="sub">Empty</span>') + '</div>';
|
||||
}
|
||||
function card(pr) {
|
||||
var open = pr.openPrint && x.print(pr.openPrint), last = lastPrint(pr), lostC = !pr.connected;
|
||||
var conn = lostC
|
||||
? '<div class="lost" role="status"><h3>⚡ Connection lost</h3><span data-feature>Reconnecting. Last contact <b>' + x.ago(pr.age) + '</b>. ' + FEAT + '</span><span data-devonly>Extrudex\'s MQTT session dropped. When it dropped isn\'t recorded on dev.</span>' +
|
||||
'<ul class="small"><li>The printer may still be printing. This is Extrudex\'s connection, not the printer\'s power.</li><li><b>Capture is paused.</b> A job that starts and ends during the outage won\'t be recorded.</li><li>On reconnect: if <i>' + x.esc(pr.job) + '</i> is still running, it stays open with <b>+1 gap</b>; otherwise it closes as <b>unknown</b>.</li></ul></div>'
|
||||
: '<div class="row small" style="gap:8px"><span class="fresh"><span class="dot"></span></span><span data-feature>Ready · last report ' + x.ago(pr.age) + ' ' + FEAT + '</span><span data-devonly>Connected</span></div>';
|
||||
var now = open
|
||||
? '<button class="now' + (pr.stale ? ' stale' : '') + '" type="button" data-print="' + open.id + '"><b>' + x.esc(open.job) + '</b><span class="num">' + x.dur(x.since(open.start)) + '</span>' + (pr.stale ? '<span class="sub" style="grid-column:1/-1">open record · started ' + x.when(open.start).split(' ').pop() + ' · watched until the drop</span><span class="sub" data-feature style="grid-column:1/-1"><span class="prov warn" style="white-space:normal">Last known: printing · ' + x.ago(pr.age) + '. Cached from the lost session, not live.</span></span>' : '<span class="sub">started ' + x.when(open.start).split(' ').pop() + ' · weight read from the 3MF when it ends</span><span class="sub">so far</span>') + '</button>'
|
||||
: (last ? '<button class="now" type="button" data-print="' + last.id + '"><span><span class="sub" style="display:inline">Last · </span><b>' + x.esc(last.job) + '</b></span>' + x.printSt(last) + '<span class="sub">ended ' + x.when(last.end) + '</span><span class="sub">' + x.durCell(last).replace(/<span class="sub[^"]*">[^<]*<\/span>/, '') + '</span></button>' : '');
|
||||
var cam = pr.connected ? '<div class="cam" role="img" aria-label="Camera snapshot from ' + x.esc(pr.name) + '"><span class="stamp">snapshot 09:41:0' + pr.id + '</span></div>' : '<div class="cam-off">Camera unavailable while disconnected</div>';
|
||||
return '<article class="pcard nm-raised' + (lostC ? ' attn-card' : '') + '" id="printer-' + pr.id + '" aria-labelledby="ph' + pr.id + '"><div class="phead"><div style="flex:1;min-width:0"><h2 id="ph' + pr.id + '">' + x.esc(pr.name) + '</h2><span class="sub">Bambu Lab · ' + pr.model + '</span></div>' + x.printerSt(pr) +
|
||||
'<div class="menu-wrap"><button class="btn btn-sm btn-icon" type="button" data-menu="pm' + pr.id + '" aria-haspopup="menu" aria-expanded="false" aria-label="Actions for ' + x.esc(pr.name) + '">⋯</button><div class="menu" id="pm' + pr.id + '" role="menu"><button role="menuitem" type="button" data-pedit="' + pr.id + '">Edit connection</button><button role="menuitem" type="button" data-holders="' + pr.id + '">Manage holders</button><hr><button role="menuitem" type="button" class="danger" data-premove="' + pr.id + '">Remove printer…</button></div></div></div>' +
|
||||
conn + now + cam + '<div class="stack" style="gap:8px"><div class="row small"><b>Loaded filament</b><span class="sub" style="display:inline">recorded by Extrudex, not read from the printer</span></div>' + pr.containers.map(holder).join('') + '</div>' +
|
||||
'<details class="cfg"><summary>Connection details</summary><dl class="kv" style="margin-top:10px"><dt>IP</dt><dd>' + x.esc(pr.ip) + '</dd><dt>Serial</dt><dd>' + x.esc(pr.serial) + '</dd><dt>Access code</dt><dd>✓ stored, never shown</dd>' +
|
||||
'<dt data-feature>Lifecycle</dt><dd data-feature>' + pr.lifecycle + (pr.stale ? ' · <code>stale</code>' : '') + ' ' + FEAT + '</dd><dt data-feature>Session</dt><dd data-feature>#' + pr.session + ' (increments on reconnect)</dd></dl></details></article>';
|
||||
}
|
||||
function render() {
|
||||
var order = PRINTERS.slice().sort(function (a, b) { var r = function (p) { return !p.connected ? 0 : p.state === 'RUNNING' ? 1 : 2; }; return r(a) - r(b); });
|
||||
var nRun = PRINTERS.filter(function (p) { return p.connected && p.state === 'RUNNING'; }).length, nDown = PRINTERS.filter(function (p) { return !p.connected; }).length;
|
||||
return '<header class="page-head"><div><h1>Printers</h1><p class="sum"><b>' + PRINTERS.length + '</b> printers · <b>' + nRun + '</b> printing · <b style="color:var(--status-warning)">' + nDown + '</b> disconnected · polled every 10 s, read-only MQTT</p></div>' +
|
||||
'<div class="actions"><div class="tabs" role="group" aria-label="Data shown"><button type="button" data-ds="dev" aria-selected="' + document.body.classList.contains('dev-only') + '">dev today</button><button type="button" data-ds="feat" aria-selected="' + !document.body.classList.contains('dev-only') + '">+ feature branch</button></div><button class="btn btn-primary primary-desktop" type="button" data-pedit="new">+ Add printer</button></div></header>' +
|
||||
'<nav class="jump" aria-label="Jump to printer"><span class="small muted">Jump to</span>' + order.map(function (p) { return '<a href="#printer=' + p.id + '">' + x.printerSt(p) + ' ' + x.esc(p.model) + '</a>'; }).join('') + '</nav>' +
|
||||
'<div class="pgrid">' + order.map(card).join('') + '</div>' +
|
||||
'<section class="future" aria-labelledby="fut-h"><h2 id="fut-h" style="margin:0;font-size:var(--fs-h3)"><span class="chip chip-neutral chip-dashed">Future, not built</span> Needs data Extrudex doesn\'t store</h2><div class="fgrid">' +
|
||||
[['Progress % and time left', '<code>mc_percent</code> is parsed but not stored or served. ETA isn\'t read.'], ['Tray position in the AMS', 'Holders store which spools are in them, not which tray. The AMS tray index isn\'t recorded.'], ['Which spool is feeding now', '"Loaded" is recorded; "in use" isn\'t. Hence "not confirmed in use".'], ['Nozzle / bed temperature, layer', 'Not parsed from the report.'], ['AMS remaining % and humidity', 'Not read. <code>remain</code> only means something for RFID spools.'], ['Alert when MQTT drops', 'Only in container logs today. Dev doesn\'t store when it dropped.']].map(function (f) { return '<div class="fitem"><b>' + f[0] + '</b><span class="muted">' + f[1] + '</span></div>'; }).join('') + '</div></section>';
|
||||
}
|
||||
function focusPrinter() {
|
||||
var m = /^#printer=(\d+)/.exec(location.hash); if (!m) return;
|
||||
var el = document.getElementById('printer-' + m[1]); if (!el) return;
|
||||
el.scrollIntoView({ block: 'start', behavior: 'smooth' }); el.classList.remove('is-flash'); void el.offsetWidth; el.classList.add('is-flash');
|
||||
}
|
||||
document.addEventListener('click', function (e) {
|
||||
var a = e.target.closest('[data-ds]'); if (a) { document.body.classList.toggle('dev-only', a.getAttribute('data-ds') === 'dev'); document.getElementById('page').innerHTML = render(); }
|
||||
});
|
||||
window.addEventListener('hashchange', focusPrinter);
|
||||
X.init({ name: 'printers', after: function () { setTimeout(focusPrinter, 60); }, refresh: function () { document.getElementById('page').innerHTML = render(); } }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<style>
|
||||
.namebar { padding: var(--space-4) var(--space-5); display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
|
||||
.part { padding: 6px 12px; border-radius: var(--radius-sm); border: 1px dashed var(--border-control); font-weight: 700; color: var(--text-secondary); }
|
||||
.part.on { border: 2px solid var(--text-accent); background: var(--accent-wash); color: var(--text-accent); }
|
||||
.part small { display: block; font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.ltool { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: center; padding: var(--space-4); }
|
||||
.ltool .input { width: auto; flex: 1; min-width: 180px; min-height: 38px; }
|
||||
.rename { display: flex; gap: 6px; align-items: center; }
|
||||
.rename .input { min-height: 34px; padding: 5px 10px; max-width: 280px; }
|
||||
.used a { font-variant-numeric: tabular-nums; }
|
||||
.addrow td { background: var(--surface-inset); }
|
||||
@media (max-width: 699px) {
|
||||
.tbl.stackable thead { display: none; }
|
||||
.tbl.stackable tr { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; padding: 12px 14px; border-bottom: 1px solid var(--border-hairline); }
|
||||
.tbl.stackable td { border: 0; padding: 0; }
|
||||
.tbl.stackable td.acts { grid-column: 1 / -1; }
|
||||
.tbl.stackable tr.addrow { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var KINDS = [['brands', 'Brands', 0], ['speeds', 'Print speeds', 1], ['materials', 'Materials', 2], ['types', 'Filament types', 3], ['finishes', 'Finishes', 4], ['spool_types', 'Spool types', -1]];
|
||||
var q = new URLSearchParams(location.search);
|
||||
var S = { kind: q.get('list') || 'brands', f: '', unused: false, editing: null, conflict: false, addErr: false };
|
||||
var SRC = { manufacturer: '<span class="prov"><i>◆</i>manufacturer</span>', community: '<span class="prov"><i>◇</i>community</span>', estimated: '<span class="prov warn"><i>≈</i>estimated</span>' };
|
||||
function kindRow(k) { for (var i = 0; i < KINDS.length; i++) if (KINDS[i][0] === k) return KINDS[i]; }
|
||||
function namebar() {
|
||||
var k = kindRow(S.kind), parts = [['Sunlu', 'brand', 1], ['Rapid', 'speed', 0], ['PETG', 'material', 1], ['CF', 'type', 0], ['Silk', 'finish', 1]];
|
||||
if (k[2] < 0) return '<section class="namebar nm-inset" aria-label="What this list does"><span class="small"><b>Spool types</b> are physical spool designs and what they weigh empty. A spool\'s empty spool weight comes from its type, never its brand, because one brand ships spools that differ by 100 g or more.</span></section>';
|
||||
return '<section class="namebar nm-inset" aria-label="Where this list appears in a spool name"><span class="small muted" style="margin-right:6px">A spool name is built from</span>' + parts.map(function (p, i) { return '<span class="part' + (i === k[2] ? ' on' : '') + '">' + p[0] + '<small>' + p[1] + (p[2] ? '' : ' · opt') + '</small></span>'; }).join('') + '</section>';
|
||||
}
|
||||
function rows() {
|
||||
var list = LOOKUPS[S.kind].filter(function (r) { return (!S.f || r[0].toLowerCase().indexOf(S.f.toLowerCase()) >= 0) && (!S.unused || !r[1]); });
|
||||
var st = S.kind === 'spool_types', br = S.kind === 'brands';
|
||||
var head = '<thead><tr><th>Name</th><th class="r">Used by</th>' + (st ? '<th class="r">Empty weight</th><th>Source</th>' : br ? '<th class="r hide-m">Brand empty spool weight <span class="sub" style="display:inline;text-transform:none">last resort</span></th>' : '') + '<th class="r"><span class="sr-only">Actions</span></th></tr></thead>';
|
||||
var body = list.map(function (r, i) {
|
||||
var name = r[0], used = r[1], editing = S.editing === name;
|
||||
var nameCell = editing ? '<div class="rename"><input class="input" id="rn" value="' + x.esc(name) + '" aria-label="New name for ' + x.esc(name) + '"><button class="btn btn-sm btn-primary" type="button" data-rn-save>Save</button><button class="btn btn-sm" type="button" data-rn-cancel>Cancel</button></div>' +
|
||||
(S.conflict ? '<p class="field-error" style="margin:6px 0 0">Someone renamed this to “' + x.esc(name) + ' 3D” after you loaded it. Nothing was saved. <button class="linkish" type="button" data-rn-reload>Reload and reapply</button></p>' : '') : '<b>' + x.esc(name) + '</b>';
|
||||
return '<tr><td>' + nameCell + '</td><td class="r used">' + (used ? '<a href="02-filament.html?q=' + encodeURIComponent(name) + '">' + used + ' spool' + (used > 1 ? 's' : '') + '</a>' : '<span class="muted">unused</span>') + '</td>' +
|
||||
(st ? '<td class="r num">' + x.g(r[2]) + '</td><td>' + SRC[r[3]] + '</td>' : br ? '<td class="r hide-m">' + (r[2] ? x.g(r[2]) : '<span class="muted">none</span>') + '</td>' : '') +
|
||||
'<td class="r acts"><div class="row" style="gap:6px;justify-content:flex-end">' + (editing ? '' : '<button class="btn btn-sm" type="button" data-rn="' + x.esc(name) + '">Rename</button>') +
|
||||
(used ? '<button class="btn btn-sm" type="button" disabled title="In use by ' + used + ' spools. Change those spools first; the database refuses to remove a value that is still used.">Remove</button>' : '<button class="btn btn-sm btn-danger" type="button" data-fake="Removed “' + x.esc(name) + '” from ' + x.esc(kindRow(S.kind)[1]) + '">Remove</button>') + '</div></td></tr>';
|
||||
}).join('');
|
||||
var add = '<tr class="addrow"><td colspan="' + (st ? 5 : br ? 4 : 3) + '"><div class="row" style="gap:8px"><input class="input" id="addv" style="max-width:280px;min-height:36px" placeholder="Add a ' + (st ? 'spool type, e.g. eSun — 1 kg cardboard' : 'value') + '" aria-label="New value">' +
|
||||
(st ? '<input class="input num" style="max-width:110px;min-height:36px" placeholder="Empty g" aria-label="Empty spool weight in grams"><select class="select" style="width:auto;min-height:36px" aria-label="Where the empty spool weight came from"><option>Source…</option><option>manufacturer</option><option>community</option><option>estimated</option></select>' : '') +
|
||||
'<button class="btn btn-sm btn-primary" type="button" data-add>Add</button></div>' +
|
||||
(S.addErr ? '<p class="field-error" style="margin:8px 0 0">' + (st ? 'An empty spool weight and its source go together, or neither. 0 g is refused: leave it blank if nobody has weighed one.' : '“petg” looks like <b>PETG</b>, which already exists. Use PETG so spools group together. <span class="sub" style="display:inline">Proposed check: the database compares names case-sensitively today, so it would accept this.</span>') + '</p>' : '') + '</td></tr>';
|
||||
return '<table class="tbl stackable">' + head + '<tbody>' + (body || '<tr><td colspan="5"><div class="empty"><h3>Nothing matches</h3><p>Try another filter.</p></div></td></tr>') + add + '</tbody></table>';
|
||||
}
|
||||
function render() {
|
||||
var k = kindRow(S.kind);
|
||||
return '<header class="page-head"><div><h1>Lists</h1><p class="sum">The values a spool\'s name is built from. <b>Operator</b> or admin can change them; values still in use can\'t be removed.</p></div></header>' +
|
||||
'<div class="tabs" role="tablist" aria-label="Lists">' + KINDS.map(function (kk) { return '<button role="tab" type="button" data-kind="' + kk[0] + '" aria-selected="' + (S.kind === kk[0]) + '">' + kk[1] + ' <span class="n">' + LOOKUPS[kk[0]].length + '</span></button>'; }).join('') + '</div>' +
|
||||
namebar() +
|
||||
'<section class="nm-raised table-card"><div class="ltool"><input class="input" id="lf" type="search" value="' + x.esc(S.f) + '" placeholder="Filter ' + k[1].toLowerCase() + '…" aria-label="Filter"><button class="btn btn-sm" type="button" aria-pressed="' + S.unused + '" data-unused>Unused only</button>' +
|
||||
'<span class="spacer"></span><span class="small muted">Demo</span><button class="btn btn-sm" type="button" data-demo-add>Show add error</button><button class="btn btn-sm" type="button" data-demo-conflict>Show rename conflict</button></div><div class="table-scroll">' + rows() + '</div></section>';
|
||||
}
|
||||
function redraw(fid) { var pos = fid && document.getElementById(fid) && document.getElementById(fid).selectionStart; document.getElementById('page').innerHTML = render(); if (fid && document.getElementById(fid)) { var el = document.getElementById(fid); el.focus(); if (pos != null) el.setSelectionRange(pos, pos); } history.replaceState(null, '', location.pathname + '?list=' + S.kind); }
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if ((a = t.closest('[data-kind]'))) { S.kind = a.getAttribute('data-kind'); S.editing = null; S.conflict = S.addErr = false; return redraw(); }
|
||||
if (t.closest('[data-unused]')) { S.unused = !S.unused; return redraw(); }
|
||||
if ((a = t.closest('[data-rn]'))) { S.editing = a.getAttribute('data-rn'); S.conflict = false; redraw('rn'); return; }
|
||||
if (t.closest('[data-rn-cancel]')) { S.editing = null; S.conflict = false; return redraw(); }
|
||||
if (t.closest('[data-rn-save]')) { var v = document.getElementById('rn').value; S.editing = null; X.toast('Renamed to <b>' + x.esc(v) + '</b>. Every spool using it now reads the new name.'); return redraw(); }
|
||||
if (t.closest('[data-rn-reload]')) { S.conflict = false; return redraw('rn'); }
|
||||
if (t.closest('[data-demo-conflict]')) { S.editing = LOOKUPS[S.kind][0][0]; S.conflict = true; return redraw(); }
|
||||
if (t.closest('[data-demo-add]')) { S.addErr = true; return redraw('addv'); }
|
||||
if (t.closest('[data-add]')) { var nv = document.getElementById('addv').value.trim(); if (!nv) { S.addErr = true; return redraw('addv'); } S.addErr = false; X.toast('Added <b>' + x.esc(nv) + '</b> to ' + kindRow(S.kind)[1]); return redraw(); }
|
||||
});
|
||||
document.addEventListener('input', function (e) { if (e.target.id === 'lf') { S.f = e.target.value; redraw('lf'); } });
|
||||
X.init({ name: 'lists' }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<style>
|
||||
.lab { aspect-ratio: 51 / 21; width: 100%; max-width: 340px; background: #fff; color: #111; border-radius: 6px; display: grid; grid-template-columns: 37.25% 1fr; align-items: center; padding: 2% 3% 2% 2%; box-shadow: 0 1px 0 rgba(0,0,0,.2), var(--nm-raised-1); border: 1px solid var(--border-control); font-family: var(--font-mono); overflow: hidden; }
|
||||
.lab svg { width: 92%; height: auto; display: block; }
|
||||
.lab .t { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.lab .h { font-weight: 800; font-size: clamp(10px, 3.6cqw, 15px); line-height: 1.1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lab .d { font-size: clamp(8px, 2.6cqw, 11px); line-height: 1.2; }
|
||||
.lab .id { font-size: clamp(9px, 3cqw, 12px); font-weight: 700; }
|
||||
.labwrap { container-type: inline-size; }
|
||||
.lgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: var(--space-4); }
|
||||
.lcard { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.lcard.off { opacity: .55; }
|
||||
.lcard .top { display: flex; gap: var(--space-3); align-items: center; }
|
||||
.chk { width: 20px; height: 20px; accent-color: var(--text-accent); }
|
||||
.bar { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; padding: var(--space-3) var(--space-4); }
|
||||
.spec { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: var(--fs-label); margin: 0; }
|
||||
.spec dt { color: var(--text-secondary); }
|
||||
.spec dd { margin: 0; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var q = new URLSearchParams(location.search);
|
||||
var S = { tab: q.get('tab') || 'needed', sel: {} };
|
||||
SPOOLS.filter(function (s) { return !s.label; }).forEach(function (s) { S.sel[s.id] = true; });
|
||||
/* A deterministic QR-looking pattern (not a real code) with the three finder squares. */
|
||||
function qr(seed) {
|
||||
var n = 25, cells = '', r = seed * 9301 + 49297;
|
||||
function rnd() { r = (r * 9301 + 49297) % 233280; return r / 233280; }
|
||||
function finder(ox, oy) { return '<rect x="' + ox + '" y="' + oy + '" width="7" height="7" fill="#111"/><rect x="' + (ox + 1) + '" y="' + (oy + 1) + '" width="5" height="5" fill="#fff"/><rect x="' + (ox + 2) + '" y="' + (oy + 2) + '" width="3" height="3" fill="#111"/>'; }
|
||||
for (var yy = 0; yy < n; yy++) for (var xx = 0; xx < n; xx++) {
|
||||
var inF = (xx < 8 && yy < 8) || (xx > n - 9 && yy < 8) || (xx < 8 && yy > n - 9);
|
||||
if (!inF && rnd() > 0.52) cells += '<rect x="' + xx + '" y="' + yy + '" width="1" height="1"/>';
|
||||
}
|
||||
return '<svg viewBox="-1 -1 ' + (n + 2) + ' ' + (n + 2) + '" aria-hidden="true" shape-rendering="crispEdges"><rect x="-1" y="-1" width="' + (n + 2) + '" height="' + (n + 2) + '" fill="#fff"/><g fill="#111">' + cells + '</g>' + finder(0, 0) + finder(n - 7, 0) + finder(0, n - 7) + '</svg>';
|
||||
}
|
||||
function spoolLabel(s) {
|
||||
var d = [s.mat, s.type, s.speed, s.fin].filter(Boolean).join(' ');
|
||||
return '<div class="labwrap"><div class="lab" role="img" aria-label="Label preview: ' + x.esc(s.brand + ' / ' + s.color + ', ' + d + ', #' + s.id) + '">' + qr(s.id) + '<div class="t"><span class="h">' + x.esc(s.brand + ' / ' + s.color) + '</span><span class="d">' + x.esc(d) + '</span><span class="id">#' + s.id + '</span></div></div></div>';
|
||||
}
|
||||
function holderLabel(pr, c) {
|
||||
var kind = { ams: 'AMS', ams_lite: 'AMS Lite', external: 'External' }[c.kind], id = X.holderIds[c.id];
|
||||
return '<div class="labwrap"><div class="lab" role="img" aria-label="Holder label preview">' + qr(100 + id) + '<div class="t"><span class="h" style="font-size:clamp(9px,3cqw,13px)">Printer: ' + x.esc(pr.name) + '</span><span class="d">Container: ' + x.esc(c.label + ' / ' + kind) + '</span><span class="id">#' + id + '</span></div></div></div>';
|
||||
}
|
||||
function needed() {
|
||||
var l = SPOOLS.filter(function (s) { return !s.label; }), n = l.filter(function (s) { return S.sel[s.id]; }).length;
|
||||
if (!l.length) return '<div class="nm-raised empty"><h3>✓ Every spool has a label</h3><p>New spools land here until their label is generated.</p></div>';
|
||||
return '<div class="bar nm-inset"><label class="row small" style="gap:8px"><input type="checkbox" class="chk" data-all ' + (n === l.length ? 'checked' : '') + '> All ' + l.length + '</label><span class="spacer"></span><span class="small muted">One PDF, one 51 × 21 mm page per label</span><button class="btn btn-primary" type="button" ' + (n ? 'data-gen="' + n + '"' : 'disabled') + '>Download ' + n + ' label' + (n === 1 ? '' : 's') + ' (PDF)</button></div>' +
|
||||
'<div class="lgrid">' + l.map(function (s) { return '<article class="lcard nm-raised' + (S.sel[s.id] ? '' : ' off') + '"><div class="top"><input type="checkbox" class="chk" data-s="' + s.id + '" ' + (S.sel[s.id] ? 'checked' : '') + ' aria-label="Include ' + x.esc(s.color) + ' #' + s.id + '"><span style="flex:1;min-width:0"><b>' + x.esc(s.color) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">' + x.esc(x.where(s).text) + '</span></span><button class="btn btn-sm" type="button" data-spool="' + s.id + '">Open</button></div>' + spoolLabel(s) + '</article>'; }).join('') + '</div>' +
|
||||
'<p class="small muted" style="margin:0">A spool counts as labeled once its PDF is generated successfully. Editing the spool later doesn\'t clear that. Reprint any label from the <b>All spools</b> tab.</p>';
|
||||
}
|
||||
function all() {
|
||||
return '<section class="nm-raised table-card"><div class="table-scroll"><table class="tbl"><thead><tr><th>Spool</th><th class="hide-m">Label text</th><th>Status</th><th class="r"><span class="sr-only">Actions</span></th></tr></thead><tbody>' +
|
||||
SPOOLS.map(function (s) { return '<tr><td><div class="row" style="gap:10px;flex-wrap:nowrap">' + x.sw(s.c, 'swatch-sm') + '<span><b>' + x.esc(s.color) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span></span></div></td><td class="hide-m"><span class="small">' + x.esc(s.brand + ' / ' + s.color) + '</span><span class="sub">' + x.esc([s.mat, s.type, s.speed, s.fin].filter(Boolean).join(' ')) + '</span></td><td>' + (s.label ? '<span class="st ok">Printed</span>' : '<span class="st warn">Not printed</span>') + '</td><td class="r"><button class="btn btn-sm" type="button" data-fake="Label PDF for #' + s.id + ' generated">' + (s.label ? 'Reprint' : 'Print') + '</button></td></tr>'; }).join('') + '</tbody></table></div></section>';
|
||||
}
|
||||
function holders() {
|
||||
return '<p class="small muted" style="margin:0">Stick one on each AMS and external holder. Scanning it with a phone starts a <a href="09-scan-holder.html">holder scan session</a>: every spool label scanned next is loaded, returned or moved there. No mode to pick.</p>' +
|
||||
PRINTERS.map(function (pr) { return '<section class="stack" style="gap:10px"><h2 class="section" style="margin:0">' + x.esc(pr.name) + '</h2><div class="lgrid">' + pr.containers.map(function (c) { return '<article class="lcard nm-raised"><div class="top"><b style="flex:1">' + x.esc(c.label) + '</b><span class="sub">' + x.contents(c.id).length + ' / ' + c.cap + '</span><button class="btn btn-sm" type="button" data-fake="Holder label #' + X.holderIds[c.id] + ' PDF generated">Print</button></div>' + holderLabel(pr, c) + '</article>'; }).join('') + '</div></section>'; }).join('');
|
||||
}
|
||||
function render() {
|
||||
var need = SPOOLS.filter(function (s) { return !s.label; }).length;
|
||||
return '<header class="page-head"><div><h1>Labels</h1><p class="sum">51 × 21 mm labels with a QR code. Scanning a spool label opens it; scanning a holder label starts loading into it.</p></div></header>' +
|
||||
'<div class="tabs" role="tablist" aria-label="Label lists">' + [['needed', 'Needs a label', need, 1], ['all', 'All spools', SPOOLS.length], ['holders', 'Holders', 6]].map(function (t) { return '<button role="tab" type="button" data-ltab="' + t[0] + '" aria-selected="' + (S.tab === t[0]) + '">' + t[1] + ' <span class="n' + (t[3] && t[2] ? ' warn' : '') + '">' + t[2] + '</span></button>'; }).join('') + '</div>' +
|
||||
(S.tab === 'needed' ? needed() : S.tab === 'all' ? all() : holders()) +
|
||||
'<details class="nm-inset panel"><summary class="small strong" style="cursor:pointer">Label format</summary><dl class="spec" style="margin-top:10px"><dt>Size</dt><dd>51 × 21 mm, one label per PDF page</dd><dt>QR</dt><dd>19 mm, left; encodes <code>/s/‹id›</code> or <code>/c/‹id›</code></dd><dt>Line 1</dt><dd>Brand / Color (holder: Printer: name)</dd><dt>Line 2</dt><dd>Material, type, speed, finish (holder: Container: label / kind)</dd><dt>Line 3</dt><dd>#id</dd></dl></details>';
|
||||
}
|
||||
function redraw() { document.getElementById('page').innerHTML = render(); history.replaceState(null, '', location.pathname + '?tab=' + S.tab); }
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if ((a = t.closest('[data-ltab]'))) { S.tab = a.getAttribute('data-ltab'); return redraw(); }
|
||||
if (t.matches('[data-s]')) { S.sel[t.getAttribute('data-s')] = t.checked; return redraw(); }
|
||||
if (t.matches('[data-all]')) { SPOOLS.filter(function (s) { return !s.label; }).forEach(function (s) { S.sel[s.id] = t.checked; }); return redraw(); }
|
||||
if ((a = t.closest('[data-gen]'))) { SPOOLS.forEach(function (s) { if (S.sel[s.id]) s.label = true; }); X.toast('Downloaded <b>extrudex-labels.pdf</b> · ' + a.getAttribute('data-gen') + ' labels, now marked printed'); return redraw(); }
|
||||
});
|
||||
X.init({ name: 'labels', refresh: redraw }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,58 @@
|
||||
<style>
|
||||
.roles { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: var(--space-3); }
|
||||
.role { padding: var(--space-4); display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-label); }
|
||||
.role b { font-size: 14px; }
|
||||
.person { padding: var(--space-4) var(--space-5); display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; gap: var(--space-4); align-items: center; }
|
||||
.avatar { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-control); }
|
||||
.sessions { grid-column: 2 / -1; display: flex; flex-direction: column; gap: 6px; }
|
||||
.sess { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; padding: 8px 12px; border-radius: var(--radius-sm); background: var(--surface-inset); border: 1px solid var(--border-hairline); font-size: var(--fs-label); }
|
||||
.token-once { padding: var(--space-4); border: 2px solid var(--status-warning); background: var(--wash-warning); border-radius: var(--radius-md); display: flex; flex-direction: column; gap: 8px; }
|
||||
.token-once code { font-size: 13px; padding: 8px 10px; display: block; overflow-wrap: anywhere; }
|
||||
.gap-tag { font-size: var(--fs-caption); }
|
||||
@media (max-width: 699px) { .person { grid-template-columns: 36px minmax(0, 1fr); } .person > .act { grid-column: 1 / -1; } .avatar { width: 36px; height: 36px; } .sessions { grid-column: 1 / -1; } }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var S = { revoke: null, newToken: null, expanded: { 1: true } };
|
||||
function rel(iso) { return x.when(iso); }
|
||||
var ROLE = { reader: ['Reader', 'Sees everything: inventory, prints, printers, camera snapshots, holder labels.'], operator: ['Operator', 'Reader, plus changing inventory: spools, weigh-ins, moves, labels, prints, lists.'], admin: ['Admin', 'Operator, plus printers and holders, access and system settings.'] };
|
||||
function people() {
|
||||
return PEOPLE.map(function (p) {
|
||||
var active = p.sessions.filter(function (s) { return !s.revoked; });
|
||||
var sess = S.expanded[p.id] ? '<div class="sessions">' + (active.length ? active.map(function (s) { return '<div class="sess"><span><b>' + (s.you ? 'This browser' : 'Session') + '</b> · signed in ' + rel(s.created) + '</span><span class="muted">last seen ' + rel(s.lastSeen) + '</span><span class="muted">idle expiry ' + rel(s.idleExp) + '</span><span class="muted">hard expiry ' + rel(s.maxExp) + '</span></div>'; }).join('') : '<span class="small muted">No local sessions recorded.</span>') + '</div>' : '';
|
||||
var act = S.revoke === p.id
|
||||
? '<div class="stack act" style="gap:6px;align-items:flex-end"><span class="small">Sign ' + x.esc(p.name) + ' out of ' + active.length + ' session' + (active.length > 1 ? 's' : '') + '?' + (p.id === 1 ? ' <b>Including this one.</b>' : '') + '</span><div class="row" style="gap:6px"><button class="btn btn-sm" type="button" data-keep>Cancel</button><button class="btn btn-sm btn-danger" type="button" data-do-revoke="' + p.id + '">Revoke</button></div></div>'
|
||||
: '<div class="row act" style="gap:6px;justify-content:flex-end">' + (active.length ? '<button class="btn btn-sm" type="button" data-expand="' + p.id + '" aria-expanded="' + !!S.expanded[p.id] + '">' + active.length + ' session' + (active.length > 1 ? 's' : '') + ' ' + (S.expanded[p.id] ? '▴' : '▾') + '</button><button class="btn btn-sm" type="button" data-revoke="' + p.id + '">Revoke…</button>' : '<span class="small muted">no sessions</span>') + '</div>';
|
||||
return '<article class="person nm-raised"><span class="avatar" aria-hidden="true">' + x.esc(p.name[0]) + '</span><div style="min-width:0"><b>' + x.esc(p.name) + '</b>' + (p.id === 1 ? ' <span class="sub" style="display:inline">(you)</span>' : '') + '<span class="sub">' + (p.email ? x.esc(p.email) + ' · ' : '') + '<span class="st ' + (p.role === 'admin' ? 'run' : p.role === 'operator' ? 'ok' : 'idle') + '" style="font-size:11px">' + ROLE[p.role][0] + '</span> · last seen ' + rel(p.lastSeen) + '</span></div>' + act + sess + '</article>';
|
||||
}).join('');
|
||||
}
|
||||
function machines() {
|
||||
return MACHINES.map(function (m) {
|
||||
return '<article class="nm-raised" style="padding:0;overflow:hidden"><div class="row" style="padding:14px 16px;gap:12px"><span class="avatar" style="border-radius:12px" aria-hidden="true">⚙</span><span style="flex:1;min-width:0"><b>' + x.esc(m.name) + '</b><span class="sub">machine · <span class="st ' + (m.role === 'operator' ? 'ok' : 'idle') + '" style="font-size:11px">' + ROLE[m.role][0] + '</span></span></span><button class="btn btn-sm" type="button" data-newtoken="' + m.id + '">+ New token</button></div>' +
|
||||
(S.newToken === m.id ? '<div style="padding:0 16px 16px"><div class="token-once" role="alert"><b>Copy this token now. It won\'t be shown again.</b><code>exd_9f2c…(sample, the full token appears once)</code><span class="small">Only its hash is stored. If it\'s lost, revoke it and make a new one.</span><div class="row" style="gap:8px"><button class="btn btn-sm btn-primary" type="button" data-fake="Token copied to clipboard">Copy</button><button class="btn btn-sm" type="button" data-done-token>I\'ve stored it</button></div></div></div>' : '') +
|
||||
'<table class="tbl"><thead><tr><th>Token</th><th class="hide-m">Created</th><th>Last used</th><th class="r"><span class="sr-only">Actions</span></th></tr></thead><tbody>' + m.tokens.map(function (t) { return '<tr' + (t.revoked ? ' style="opacity:.6"' : '') + '><td><b>' + x.esc(t.name) + '</b>' + (t.revoked ? '<span class="sub">revoked ' + rel(t.revoked) + '</span>' : '') + '</td><td class="hide-m">' + rel(t.created) + '</td><td>' + (t.lastUsed ? rel(t.lastUsed) : '<span class="muted">never</span>') + '</td><td class="r">' + (t.revoked ? '' : '<button class="btn btn-sm btn-danger" type="button" data-fake="Revoked ' + x.esc(t.name) + '. Requests using it now get 401.">Revoke</button>') + '</td></tr>'; }).join('') + '</tbody></table></article>';
|
||||
}).join('');
|
||||
}
|
||||
function render() {
|
||||
return '<header class="page-head"><div><h1>Access</h1><p class="sum">Admin only. Who can use Extrudex, and what they can do.</p></div><a class="btn" href="#" data-fake="Opens Authentik in a new tab">Open Authentik ↗</a></header>' +
|
||||
'<div class="banner" style="border-color:var(--status-info);background:var(--wash-info)"><b style="color:var(--status-info)">i</b><span style="flex:1">People, roles, passwords and account activation are managed in <b>Authentik</b>. This page shows Extrudex\'s local view: roles as Extrudex sees them, and sign-in sessions you can revoke here.</span></div>' +
|
||||
'<section class="roles" aria-label="Roles">' + ['reader', 'operator', 'admin'].map(function (r) { return '<div class="role nm-inset"><b>' + ROLE[r][0] + '</b><span class="muted">' + ROLE[r][1] + '</span></div>'; }).join('') + '</section>' +
|
||||
'<section class="stack" style="gap:12px" aria-labelledby="ppl"><h2 class="section" id="ppl" style="margin:0">People · ' + PEOPLE.length + '</h2>' + people() + '</section>' +
|
||||
'<section class="stack" style="gap:12px" aria-labelledby="mach"><h2 class="section" id="mach" style="margin:0">Machines & API tokens <span class="prov warn gap-tag"><i>⚠</i>API exists · no screen on dev yet</span></h2><p class="small muted" style="margin:0">For integrations like the gateway and health checks. Each machine gets a role; tokens are bearer credentials and can be revoked one at a time.</p>' + machines() +
|
||||
'<button class="btn" type="button" style="align-self:flex-start" data-fake="New machine form: name + role">+ Add machine</button></section>' +
|
||||
'<details class="nm-inset panel"><summary class="small strong" style="cursor:pointer">Runbook: changing someone\'s access</summary><ol class="small" style="margin:10px 0 0;padding-left:18px"><li>Change the group in Authentik.</li><li>Existing sessions pick it up when Extrudex next re-checks authorization (each session records when it was last checked). Revoke their sessions here to make it take effect now.</li><li>Removed people keep their name on audit history.</li></ol></details>';
|
||||
}
|
||||
function redraw() { document.getElementById('page').innerHTML = render(); }
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if ((a = t.closest('[data-expand]'))) { var id = +a.getAttribute('data-expand'); S.expanded[id] = !S.expanded[id]; return redraw(); }
|
||||
if ((a = t.closest('[data-revoke]'))) { S.revoke = +a.getAttribute('data-revoke'); return redraw(); }
|
||||
if (t.closest('[data-keep]')) { S.revoke = null; return redraw(); }
|
||||
if ((a = t.closest('[data-do-revoke]'))) { var p = PEOPLE.filter(function (pp) { return pp.id === +a.getAttribute('data-do-revoke'); })[0]; p.sessions.forEach(function (s) { s.revoked = '2026-09-18T09:41'; }); S.revoke = null; X.toast('Revoked ' + x.esc(p.name) + '\'s local sessions. They\'ll need to sign in again.'); return redraw(); }
|
||||
if ((a = t.closest('[data-newtoken]'))) { S.newToken = +a.getAttribute('data-newtoken'); return redraw(); }
|
||||
if (t.closest('[data-done-token]')) { S.newToken = null; return redraw(); }
|
||||
});
|
||||
X.init({ name: 'access' }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<style>
|
||||
.sysgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: var(--space-5); align-items: start; }
|
||||
.syscard { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.syscard h2 { margin: 0; font-size: var(--fs-h3); display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.afilters { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: center; padding: var(--space-4); }
|
||||
.afilters .select, .afilters .input { width: auto; min-height: 36px; padding: 6px 10px; font-size: 13px; }
|
||||
.act-code { font-size: 12px; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var S = { who: '', type: '', sent: false, update: false, installed: false };
|
||||
var LINK = { spool: function (id) { return '<a href="#spool=' + id + '" data-spool="' + id + '">spool #' + id + '</a>'; }, print: function (id) { return '<a href="#print=' + id + '" data-print="' + id + '">print #' + id + '</a>'; }, printer: function (id) { return '<a href="04-printers.html#printer=' + id + '">printer #' + id + '</a>'; }, spool_type: function (id) { return '<a href="05-lists.html?list=spool_types">spool type #' + id + '</a>'; }, principal: function (id) { return '<a href="07-access.html">person #' + id + '</a>'; } };
|
||||
var WST = { delivered: ['ok', 'Delivered'], no_low: ['idle', 'Nothing low'], pending: ['warn', 'Retrying'], expired: ['err', 'Expired'] };
|
||||
function webhook() {
|
||||
var lowNow = SPOOLS.filter(x.low).length;
|
||||
return '<section class="syscard nm-raised" aria-labelledby="wh"><h2 id="wh">Low-filament report <span class="st ok" style="font-size:12px">On</span><span class="prov warn" style="font-size:11px"><i>⚠</i>API exists · no screen on dev yet</span></h2>' +
|
||||
'<dl class="kv"><dt>Sends</dt><dd>' + WEBHOOK.schedule + '</dd><dt>Includes</dt><dd>spools under ' + WEBHOOK.threshold + ' g (' + lowNow + ' right now)</dd><dt>Destination</dt><dd class="muted">' + WEBHOOK.target + '</dd></dl>' +
|
||||
'<table class="tbl"><thead><tr><th>Week</th><th>Result</th><th class="r">Spools</th><th class="hide-m">Detail</th></tr></thead><tbody>' + WEBHOOK.events.map(function (ev) { var w = WST[ev.state]; return '<tr><td><b>' + ev.period + '</b><span class="sub">' + x.when(ev.observed) + '</span></td><td><span class="st ' + w[0] + '">' + w[1] + '</span>' + (ev.attempts > 1 ? '<span class="sub">' + ev.attempts + ' attempts</span>' : '') + '</td><td class="r">' + (ev.spools || '—') + '</td><td class="hide-m small">' + (ev.lastError ? '<span class="prov warn" style="white-space:normal"><i>!</i>' + x.esc(ev.lastError) + '</span>' : ev.delivered ? 'at ' + x.when(ev.delivered).split(' ').pop() : '<span class="muted">nothing sent</span>') + '</td></tr>'; }).join('') + '</tbody></table>' +
|
||||
(S.sent ? '<div class="banner" style="border-color:var(--status-success);background:var(--wash-success)"><b style="color:var(--status-success)">✓</b><span>This week\'s report (2026-W38) was already delivered Monday 08:00, so nothing was sent again. A week that\'s still retrying would be retried now.</span></div>' : '') +
|
||||
'<div class="row"><button class="btn btn-sm" type="button" data-send>Run this week\'s report now</button><span class="sub" style="display:inline">One report per week. Running it again never sends a duplicate.</span></div></section>';
|
||||
}
|
||||
function app() {
|
||||
return '<section class="syscard nm-raised" aria-labelledby="ap"><h2 id="ap">This device</h2>' +
|
||||
'<dl class="kv"><dt>Version</dt><dd>dev <code>321ce0d</code></dd><dt>Offline use</dt><dd><span class="st ok">Ready</span><span class="sub">App shell is cached; data still needs the server</span></dd><dt>Installed</dt><dd>' + (S.installed ? '<span class="st ok">Yes</span>' : '<span class="st idle">No</span>') + '</dd></dl>' +
|
||||
(S.update ? '<div class="banner"><b>↻</b><span style="flex:1">An update is ready. Finish any open form, then reload.</span><button class="btn btn-sm btn-primary" type="button" data-fake="Reloading with the update">Reload now</button></div>' : '') +
|
||||
'<div class="row" style="gap:8px">' + (S.installed ? '' : '<button class="btn btn-sm btn-primary" type="button" data-install>Install Extrudex</button>') + '<button class="btn btn-sm" type="button" data-update>Demo: update ready</button></div>' +
|
||||
'<p class="sub" style="margin:0">On iPhone or iPad: open in Safari, tap Share, then <b>Add to Home Screen</b>.</p></section>';
|
||||
}
|
||||
function audit() {
|
||||
var who = {}, types = {}; AUDIT.forEach(function (a) { who[a.who] = 1; types[a.type] = 1; });
|
||||
var l = AUDIT.filter(function (a) { return (!S.who || a.who === S.who) && (!S.type || a.type === S.type); });
|
||||
return '<section class="nm-raised table-card" aria-labelledby="au"><div class="afilters"><h2 id="au" style="margin:0;font-size:var(--fs-h3);flex:1">Audit log <span class="prov warn" style="font-size:11px"><i>⚠</i>API exists · no screen on dev yet</span></h2>' +
|
||||
'<select class="select" id="aw" aria-label="Who"><option value="">Anyone</option>' + Object.keys(who).map(function (w) { return '<option' + (S.who === w ? ' selected' : '') + '>' + x.esc(w) + '</option>'; }).join('') + '</select>' +
|
||||
'<select class="select" id="at" aria-label="Resource"><option value="">Any resource</option>' + Object.keys(types).map(function (w) { return '<option' + (S.type === w ? ' selected' : '') + '>' + w + '</option>'; }).join('') + '</select></div>' +
|
||||
'<div class="table-scroll"><table class="tbl"><thead><tr><th>When</th><th>Who</th><th>What</th><th class="hide-m">Detail</th></tr></thead><tbody>' + (l.length ? l.map(function (a) { return '<tr><td class="nowrap">' + x.when(a.at) + '</td><td>' + x.esc(a.who) + '</td><td><code class="act-code">' + a.action + '</code><span class="sub">' + (LINK[a.type] ? LINK[a.type](a.rid) : a.type + ' #' + a.rid) + '</span></td><td class="hide-m small">' + x.esc(a.ctx) + '</td></tr>'; }).join('') : '<tr><td colspan="4"><div class="empty"><h3>No events match</h3></div></td></tr>') + '</tbody></table></div>' +
|
||||
'<div class="table-foot"><span class="muted">Newest first · every write records who, what and when</span><button class="btn btn-sm" type="button" data-fake="Loaded older events (paged by id)">Older →</button></div></section>';
|
||||
}
|
||||
function render() {
|
||||
return '<header class="page-head"><div><h1>System</h1><p class="sum">Admin only. Scheduled reports, the audit trail, and this device.</p></div></header>' +
|
||||
'<div class="sysgrid">' + webhook() + app() + '</div>' + audit();
|
||||
}
|
||||
function redraw() { document.getElementById('page').innerHTML = render(); }
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target;
|
||||
if (t.closest('[data-send]')) { S.sent = true; return redraw(); }
|
||||
if (t.closest('[data-update]')) { S.update = true; return redraw(); }
|
||||
if (t.closest('[data-install]')) { S.installed = true; X.toast('Installed. Extrudex opens in its own window now.'); return redraw(); }
|
||||
});
|
||||
document.addEventListener('change', function (e) { if (e.target.id === 'aw') { S.who = e.target.value; redraw(); } if (e.target.id === 'at') { S.type = e.target.value; redraw(); } });
|
||||
X.init({ name: 'system' }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<style>
|
||||
.scanwrap { max-width: 560px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.hhead { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.hhead h1 { margin: 0; font-size: var(--fs-h2); line-height: 1.2; }
|
||||
.cap { display: flex; gap: 6px; }
|
||||
.cap i { flex: 1; height: 12px; border-radius: 4px; border: 1px solid var(--border-control); background: var(--surface-inset); }
|
||||
.cap i.on { background: var(--text-accent); border-color: var(--text-accent); }
|
||||
.bigscan { min-height: 64px; font-size: 17px; justify-content: center; width: 100%; border-radius: var(--radius-lg); }
|
||||
.rule { font-size: var(--fs-label); color: var(--text-secondary); display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; }
|
||||
.rule b { color: var(--text-primary); }
|
||||
.result { padding: var(--space-4); border-radius: var(--radius-lg); border: 2px solid var(--status-success); background: var(--wash-success); display: grid; grid-template-columns: 44px 1fr; gap: 4px 12px; align-items: center; }
|
||||
.result.err { border-color: var(--status-error); background: var(--wash-error); }
|
||||
.result .verb { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); font-weight: 800; }
|
||||
.result.ok .verb { color: var(--status-success); } .result.err .verb { color: var(--status-error); }
|
||||
.row-sp { all: unset; box-sizing: border-box; cursor: pointer; width: 100%; display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; padding: 12px 16px; border-top: 1px solid var(--border-hairline); font-size: 14px; min-height: 56px; }
|
||||
.row-sp:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
|
||||
.cam-sheet { position: fixed; inset: 0; z-index: 80; background: #0b0b0d; display: none; flex-direction: column; color: #f4f4f6; }
|
||||
.cam-sheet.open { display: flex; }
|
||||
.vf { flex: 1; position: relative; display: grid; place-items: center; background: radial-gradient(ellipse at 50% 55%, #2a2a31 0, #0b0b0d 70%); }
|
||||
.vf .frame { width: min(64vw, 280px); aspect-ratio: 1; border-radius: 24px; box-shadow: 0 0 0 2000px rgba(0,0,0,.45); border: 3px solid #a99af0; position: relative; }
|
||||
.vf .hint { position: absolute; bottom: 18%; left: 0; right: 0; text-align: center; font-size: 14px; }
|
||||
.cam-list { background: #1b1b1e; padding: 12px 16px 24px; display: flex; flex-direction: column; gap: 8px; max-height: 45vh; overflow: auto; }
|
||||
.cam-list button { all: unset; cursor: pointer; display: flex; gap: 12px; align-items: center; padding: 12px; border-radius: 12px; background: #2c2c30; border: 1px solid #85858f; color: #f4f4f6; font-size: 14px; }
|
||||
.cam-list button:focus-visible { outline: 2px solid #a99af0; outline-offset: 2px; }
|
||||
.reader-note { display: none; }
|
||||
body.as-reader .reader-note { display: flex; }
|
||||
body.as-reader [data-needs-op] { opacity: .5; pointer-events: none; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var ids = X.holderIds, byNum = {}; Object.keys(ids).forEach(function (k) { byNum[ids[k]] = k; });
|
||||
var q = new URLSearchParams(location.search), cid = byNum[+q.get('c')] || 'p3-lite';
|
||||
var S = { last: null, log: [] };
|
||||
function hold() { return x.container(cid); }
|
||||
function apply(sid) {
|
||||
var s = x.spool(sid), h = hold(), here = x.contents(cid), before = x.where(s).text;
|
||||
if (!s) return { err: true, verb: 'Not found', text: 'No spool #' + sid + '. Scan a spool label, or type its number.' };
|
||||
if (s.cont === cid) { s.cont = null; s.loc = s.loc || 'Storage'; return { verb: 'Returned to storage', s: s, text: 'Was in ' + h.c.label + '. Put it back on its shelf' + (s.loc && s.loc !== 'Storage' ? ' (' + s.loc + ')' : '') + '.' }; }
|
||||
if (here.length >= h.c.cap) return { err: true, verb: 'Full', s: s, text: h.c.label + ' already holds ' + h.c.cap + '. Scan a spool that\'s in it to return that one first. Nothing changed.' };
|
||||
var was = s.cont; s.cont = cid; s.v++;
|
||||
return { verb: was ? 'Moved here' : 'Loaded', s: s, text: (was ? 'Was in ' : 'Was on ') + before + '.' };
|
||||
}
|
||||
function resultCard(r) {
|
||||
if (!r) return '';
|
||||
return '<div class="result ' + (r.err ? 'err' : 'ok') + '" role="status" aria-live="polite">' + (r.s ? x.sw(r.s.c, '" style="width:44px;height:44px') : '<span class="avatar" style="font-size:22px;text-align:center">?</span>') + '<span><span class="verb">' + (r.err ? '✕ ' : '✓ ') + r.verb + '</span><br>' + (r.s ? '<b>' + x.esc(r.s.color) + '</b> <span class="muted">#' + r.s.id + ' · ' + x.esc(r.s.brand) + '</span>' : '') + '</span><span></span><span class="small">' + x.esc(r.text) + '</span></div>';
|
||||
}
|
||||
function render() {
|
||||
var h = hold(), here = x.contents(cid), free = h.c.cap - here.length;
|
||||
return '<div class="scanwrap">' +
|
||||
'<nav class="crumbs" aria-label="Breadcrumb"><a href="04-printers.html#printer=' + h.printer.id + '">' + x.esc(h.printer.name) + '</a> › <span>Holder #' + ids[cid] + '</span></nav>' +
|
||||
'<section class="hhead nm-raised"><span class="sub">Scan session · holder #' + ids[cid] + '</span><h1>' + x.esc(h.c.label) + ' <span class="muted" style="font-weight:500">· ' + x.esc(h.printer.model) + '</span></h1>' +
|
||||
'<div class="cap" aria-label="' + here.length + ' of ' + h.c.cap + ' in use">' + Array.apply(null, Array(h.c.cap)).map(function (_, i) { return '<i class="' + (i < here.length ? 'on' : '') + '"></i>'; }).join('') + '</div><span class="small">' + here.length + ' in use · <b>' + free + ' free</b></span></section>' +
|
||||
'<div class="banner reader-note" role="note"><span>You\'re signed in as a <b>reader</b>. You can see what\'s recorded here; an operator or admin can move spools.</span></div>' +
|
||||
'<button class="btn btn-primary bigscan" type="button" data-cam data-needs-op>▣ Scan a spool label</button>' +
|
||||
'<div class="row" style="gap:8px" data-needs-op><input class="input num" id="manual" inputmode="numeric" placeholder="or type spool # (e.g. 49)" aria-label="Spool number" style="flex:1"><button class="btn" type="button" data-manual>Go</button></div>' +
|
||||
resultCard(S.last) +
|
||||
'<details class="nm-inset panel" ' + (S.log.length ? '' : 'open') + '><summary class="small strong" style="cursor:pointer">What a scan does</summary><div class="rule" style="margin-top:10px"><b>In storage</b><span>→ loads here (if there\'s room)</span><b>Already here</b><span>→ returns to storage</span><b>In another holder</b><span>→ moves here</span></div><p class="sub" style="margin:8px 0 0">No mode to choose. This holder stays active for this browser tab, so every spool label you scan next lands here until you tap Done.</p></details>' +
|
||||
'<section class="nm-raised" style="padding:0;overflow:hidden" aria-labelledby="inh"><div class="card-h" style="display:flex;padding:14px 16px 8px"><h2 id="inh" style="margin:0;font-size:var(--fs-label);text-transform:uppercase;letter-spacing:var(--tracking-caps-wide);color:var(--text-secondary)">Recorded in ' + x.esc(h.c.label) + '</h2></div>' +
|
||||
(here.length ? here.map(function (s) { return '<button class="row-sp" type="button" data-spool="' + s.id + '">' + x.sw(s.c) + '<span style="min-width:0"><b>' + x.esc(s.color) + '</b> <span class="sub" style="display:inline">#' + s.id + '</span><span class="sub">' + x.esc(x.fullName(s)) + '</span></span><span class="num' + (x.low(s) ? '" style="color:var(--status-warning);font-weight:700' : '') + '">' + x.g(s.rem) + '</span></button>'; }).join('') : '<p class="small muted" style="padding:0 16px 16px;margin:0">Empty.</p>') + '</section>' +
|
||||
(S.log.length ? '<section class="stack" style="gap:6px"><h2 class="section" style="margin:0">This session</h2>' + S.log.map(function (r) { return '<span class="small">' + (r.err ? '<span class="prov warn"><i>✕</i></span>' : '✓') + ' ' + r.verb + (r.s ? ' · ' + x.esc(r.s.color) + ' #' + r.s.id : '') + '</span>'; }).join('') + '</section>' : '') +
|
||||
'<div class="row" style="justify-content:space-between"><label class="row small" style="gap:6px"><input type="checkbox" data-reader> Demo: view as reader</label><a class="btn" href="04-printers.html#printer=' + h.printer.id + '">Done scanning</a></div></div>' +
|
||||
'<div class="cam-sheet" id="cam" role="dialog" aria-modal="true" aria-label="Camera"><div class="row" style="padding:14px 16px;justify-content:space-between"><b>Point at a spool label</b><button class="btn btn-sm" type="button" data-cam-close style="background:#2c2c30;color:#f4f4f6">Close</button></div><div class="vf"><div class="frame"></div><span class="hint">Demo: tap a label below to simulate a scan</span></div><div class="cam-list">' +
|
||||
SPOOLS.filter(function (s) { return [49, 41, 47, 56, 43].indexOf(s.id) >= 0; }).map(function (s) { return '<button type="button" data-scan="' + s.id + '">' + x.sw(s.c) + '<span style="flex:1"><b>#' + s.id + ' ' + x.esc(s.color) + '</b><br><small style="color:#b2b2ba">' + x.esc(x.where(s).text) + '</small></span></button>'; }).join('') + '</div></div>';
|
||||
}
|
||||
function redraw() { document.getElementById('page').innerHTML = render(); }
|
||||
function doScan(id) { var r = apply(id); S.last = r; S.log.unshift(r); var cam = document.getElementById('cam'); if (cam) cam.classList.remove('open'); redraw(); if (navigator.vibrate) try { navigator.vibrate(r.err ? [60, 40, 60] : 30); } catch (e) {} }
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target, a;
|
||||
if (t.closest('[data-cam]')) { document.getElementById('cam').classList.add('open'); var f = document.querySelector('#cam [data-scan]'); if (f) f.focus(); return; }
|
||||
if (t.closest('[data-cam-close]')) { document.getElementById('cam').classList.remove('open'); return; }
|
||||
if ((a = t.closest('[data-scan]'))) return doScan(+a.getAttribute('data-scan'));
|
||||
if (t.closest('[data-manual]')) { var v = +document.getElementById('manual').value.replace(/\D/g, ''); if (v) doScan(v); return; }
|
||||
if (t.matches('[data-reader]')) { document.body.classList.toggle('as-reader', t.checked); }
|
||||
});
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { var c = document.getElementById('cam'); if (c && c.classList.contains('open')) c.classList.remove('open'); } if (e.key === 'Enter' && e.target.id === 'manual') { var v = +e.target.value.replace(/\D/g, ''); if (v) doScan(v); } });
|
||||
X.init({ name: 'printers', refresh: redraw }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<style>
|
||||
.scanwrap { max-width: 560px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.hero { padding: var(--space-5); display: grid; grid-template-columns: 64px 1fr; gap: 6px 16px; align-items: center; }
|
||||
.hero h1 { margin: 0; font-size: var(--fs-h2); line-height: 1.15; }
|
||||
.hero .meter { grid-column: 1 / -1; height: 12px; margin-top: 8px; }
|
||||
.big { font-size: 40px; font-weight: 800; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.scale { font-size: 28px !important; min-height: 68px !important; text-align: center; }
|
||||
.res { display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: center; text-align: center; padding: var(--space-4); }
|
||||
.res .n { font-size: 26px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.bigbtn { min-height: 60px; font-size: 16px; justify-content: center; width: 100%; border-radius: var(--radius-lg); }
|
||||
.pick { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
|
||||
.pick a { flex: none; text-decoration: none; color: inherit; padding: 8px 12px; border-radius: var(--radius-pill); border: 1px solid var(--border-control); font-size: 13px; background: var(--surface-raised); }
|
||||
.pick a[aria-current] { border: 2px solid var(--text-accent); color: var(--text-accent); font-weight: 700; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var x = X;
|
||||
var q = new URLSearchParams(location.search), sid = +(q.get('s') || 41);
|
||||
var S = { reading: '', saved: null };
|
||||
function render() {
|
||||
var s = x.spool(sid), w = x.where(s);
|
||||
var demo = '<nav class="pick" aria-label="Demo: pick a scanned spool">' + [41, 44, 47, 49].map(function (i) { return '<a href="?s=' + i + '"' + (i === sid ? ' aria-current="page"' : '') + '>#' + i + ' ' + x.esc(x.spool(i).color) + '</a>'; }).join('') + '</nav>';
|
||||
var weigh;
|
||||
if (s.tare == null) {
|
||||
weigh = '<section class="nm-raised panel stack" style="gap:12px"><h2 class="section" style="margin:0">Weigh it</h2><div class="banner"><b>?</b><span>No empty spool weight is known for this spool, so a scale reading can\'t be turned into filament. It is never assumed to be 0.</span></div><select class="select"><option>Pick its spool type…</option><option>Generic — 1 kg spool (assumed) · 180 g · estimated</option><option>Creality — Hyper cardboard · 176 g · community</option></select><button class="btn bigbtn" type="button" data-fake="Spool type saved. Weigh-in unlocked.">Save spool type</button></section>';
|
||||
} else {
|
||||
var v = +S.reading, r = Math.round(v - s.tare), ok = v > 0 && v >= s.tare && r <= s.tot + 1;
|
||||
weigh = '<section class="nm-raised panel stack" style="gap:12px"><h2 class="section" style="margin:0">Weigh it</h2><label class="stack" style="gap:6px"><span class="small strong">Whole spool on the scale</span><input class="input scale num" id="sc" inputmode="numeric" value="' + x.esc(S.reading) + '" placeholder="grams" autocomplete="off"><span class="sub">minus ' + x.g(s.tare) + ' empty spool (' + x.tareProv(s) + ') = what\'s left</span></label>' +
|
||||
'<div class="res nm-inset"><span><span class="sub">now</span><br><span class="n">' + x.g(s.rem) + '</span></span><span class="muted" style="font-size:22px">→</span><span><span class="sub">after</span><br><span class="n"' + (v && !ok ? ' style="color:var(--status-error)"' : '') + '>' + (v ? x.g(r) : '—') + '</span></span></div>' +
|
||||
(v && v < s.tare ? '<p class="field-error" style="margin:0">' + x.g(v) + ' is less than the ' + x.g(s.tare) + ' empty spool. Re-weigh, or correct the empty spool weight.</p>' : v && r > s.tot + 1 ? '<p class="field-error" style="margin:0">That leaves ' + x.g(r) + ', more than a full spool (' + x.g(s.tot) + '). Check the total or the empty spool weight.</p>' : '') +
|
||||
'<button class="btn btn-primary bigbtn" type="button" data-wsave ' + (ok ? '' : 'disabled') + '>Save weigh-in</button></section>';
|
||||
}
|
||||
return '<div class="scanwrap">' + demo +
|
||||
'<section class="hero nm-raised">' + x.sw(s.c, '" style="width:64px;height:64px') + '<div style="min-width:0"><span class="sub">Scanned spool #' + s.id + '</span><h1>' + x.esc(s.color) + '</h1><span class="sub">' + x.esc(x.fullName(s)) + '</span></div>' +
|
||||
'<div style="grid-column:1/-1;display:flex;align-items:baseline;gap:10px;margin-top:8px"><span class="big"' + (x.low(s) ? ' style="color:var(--status-warning)"' : '') + '>' + Math.round(s.rem).toLocaleString('en-US') + '<span style="font-size:16px"> g</span></span><span class="muted">of ' + x.g(s.tot) + ' net</span><span class="spacer"></span>' + x.remProv(s) + '</div>' + x.meter(s) + '</section>' +
|
||||
(S.saved ? '<div class="result ok banner" style="border-color:var(--status-success);background:var(--wash-success)" role="status"><b style="color:var(--status-success)">✓</b><span>Saved: ' + x.g(S.saved[0]) + ' → <b>' + x.g(S.saved[1]) + '</b>. Scan the next label whenever you\'re ready.</span></div>' : '') +
|
||||
'<div class="findit"><span class="sub">Lives in</span><b>' + x.esc(w.text) + '</b></div>' +
|
||||
weigh +
|
||||
'<div class="fr2"><a class="btn bigbtn" href="09-scan-holder.html?c=' + (s.cont ? X.holderIds[s.cont] : 5) + '">' + (s.cont ? 'Open its holder' : 'Load into a holder…') + '</a><a class="btn bigbtn" href="02-filament.html#spool=' + s.id + '">Full details</a></div>' +
|
||||
'<p class="small muted" style="margin:0">Tip: scan a holder\'s label first to start a <a href="09-scan-holder.html">scan session</a>. Spool labels scanned after that load or move into it instead of opening this page.</p></div>';
|
||||
}
|
||||
function redraw(focus) { var pos = focus && document.getElementById('sc') && document.getElementById('sc').selectionStart; document.getElementById('page').innerHTML = render(); if (focus && document.getElementById('sc')) { var el = document.getElementById('sc'); el.focus(); el.setSelectionRange(pos, pos); } }
|
||||
document.addEventListener('input', function (e) { if (e.target.id === 'sc') { S.reading = e.target.value.replace(/[^\d]/g, ''); S.saved = null; redraw(true); } });
|
||||
document.addEventListener('click', function (e) { if (e.target.closest('[data-wsave]')) { var s = x.spool(sid), before = s.rem; s.rem = Math.round(+S.reading - s.tare); s.remSrc = 'weighed'; s.weighedAt = '18 Sep 09:41'; S.saved = [before, s.rem]; S.reading = ''; redraw(); } });
|
||||
X.init({ name: 'filament', refresh: redraw }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<style>
|
||||
.dv-bar { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; }
|
||||
.dv-row { display: flex; gap: var(--space-6); align-items: flex-start; flex-wrap: wrap; }
|
||||
.dev { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
|
||||
.dev h2 { margin: 0; font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); }
|
||||
.bezel { border-radius: 36px; padding: 14px; background: linear-gradient(145deg, #3a3a40, #1b1b1e); box-shadow: var(--nm-raised-3); border: 1px solid var(--border-control); }
|
||||
.bezel.tab { border-radius: 28px; padding: 18px; }
|
||||
.screen { overflow: hidden; border-radius: 24px; background: var(--surface-page); position: relative; }
|
||||
.bezel.tab .screen { border-radius: 12px; }
|
||||
.screen iframe { border: 0; transform-origin: 0 0; position: absolute; left: 0; top: 0; }
|
||||
.dv-list { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.dv-list button { font: var(--fw-medium) 12px/1 var(--font-body); padding: 8px 12px; border-radius: var(--radius-pill); border: 1px solid var(--border-control); background: var(--surface-raised); color: var(--text-primary); cursor: pointer; }
|
||||
.dv-list button[aria-pressed="true"] { background: var(--accent-wash); border-color: var(--text-accent); color: var(--text-accent); font-weight: 700; }
|
||||
.dv-list button[aria-pressed="true"]::before { content: "✓ "; }
|
||||
.dv-list button:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
|
||||
.bp { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: var(--space-3); }
|
||||
.bp div { padding: var(--space-4); font-size: 13px; }
|
||||
.bp b { display: block; margin-bottom: 4px; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var SCREENS = [['01-dashboard.html', 'Dashboard'], ['02-filament.html', 'Filament'], ['02-filament.html?f=low#spool=41&tab=weigh', 'Weigh panel'], ['03-prints.html', 'Prints'], ['04-printers.html', 'Printers'], ['05-lists.html', 'Lists'], ['06-labels.html', 'Labels'], ['07-access.html', 'Access'], ['08-system.html', 'System'], ['09-scan-holder.html', 'Scan: holder'], ['10-scan-spool.html?s=44', 'Scan: spool'], ['02-filament.html?palette=weigh', 'Palette']];
|
||||
var DEV = [['Phone', 390, 844, 0.62, ''], ['Tablet portrait', 820, 1180, 0.46, 'tab'], ['Tablet landscape', 1180, 820, 0.5, 'tab']];
|
||||
var q = new URLSearchParams(location.search);
|
||||
var S = { i: +(q.get('i') || 0), theme: q.get('t') || 'dark' };
|
||||
function url(u) { return u.replace(/(\?[^#]*)?(#.*)?$/, function (m, qs, h) { return (qs ? qs + '&' : '?') + 'theme=' + S.theme + (h || ''); }); }
|
||||
function frames() {
|
||||
return DEV.map(function (d) {
|
||||
var w = Math.round(d[1] * d[3]), h = Math.round(d[2] * d[3]);
|
||||
return '<div class="dev"><h2>' + d[0] + ' · ' + d[1] + ' × ' + d[2] + '</h2><div class="bezel ' + d[4] + '"><div class="screen" style="width:' + w + 'px;height:' + h + 'px"><iframe title="' + SCREENS[S.i][1] + ' on ' + d[0] + '" src="' + url(SCREENS[S.i][0]) + '" width="' + d[1] + '" height="' + d[2] + '" style="transform:scale(' + d[3] + ')"></iframe></div></div><a class="small" href="' + url(SCREENS[S.i][0]) + '" target="_blank" rel="noopener">Open full size ↗</a></div>';
|
||||
}).join('');
|
||||
}
|
||||
function render() {
|
||||
return '<header class="page-head"><div><h1>Devices</h1><p class="sum">Every screen on a phone and on a tablet, portrait and landscape. The frames are live, so you can click inside them.</p></div>' +
|
||||
'<div class="tabs" role="group" aria-label="Preview theme"><button type="button" data-t="dark" aria-selected="' + (S.theme === 'dark') + '">Dark</button><button type="button" data-t="light" aria-selected="' + (S.theme === 'light') + '">Light</button></div></header>' +
|
||||
'<div class="dv-list" role="group" aria-label="Screen">' + SCREENS.map(function (s, i) { return '<button type="button" data-i="' + i + '" aria-pressed="' + (i === S.i) + '">' + s[1] + '</button>'; }).join('') + '</div>' +
|
||||
'<div class="dv-row">' + frames() + '</div>' +
|
||||
'<section class="bp" aria-label="Breakpoints"><div class="nm-inset"><b>Phone · under 700 px</b>Bottom tab bar (4 + More) with badges, a floating + for the page\'s main action, panels as full-screen sheets, tables drop to 2–3 columns, 44 px touch targets.</div>' +
|
||||
'<div class="nm-inset"><b>Tablet · 700–1180 px</b>Compact icon rail with labels and badges, panels as a 480 px side sheet over the page, low-priority columns hidden, no keyboard hints on touch.</div>' +
|
||||
'<div class="nm-inset"><b>Desktop · over 1180 px</b>Full rail with the Manage group, a 520 px panel, all columns, keyboard shortcuts shown.</div></section>';
|
||||
}
|
||||
function redraw() { document.getElementById('page').innerHTML = render(); history.replaceState(null, '', location.pathname + '?i=' + S.i + '&t=' + S.theme); }
|
||||
document.addEventListener('click', function (e) {
|
||||
var a = e.target.closest('[data-i]'); if (a) { S.i = +a.getAttribute('data-i'); return redraw(); }
|
||||
a = e.target.closest('[data-t]'); if (a) { S.theme = a.getAttribute('data-t'); return redraw(); }
|
||||
});
|
||||
X.init({ name: 'devices' }, render);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<style>
|
||||
.map { padding: var(--space-5); }
|
||||
.map svg { width: 100%; height: auto; display: block; }
|
||||
.map .node rect { fill: var(--surface-raised); stroke: var(--border-control); stroke-width: 1.5; }
|
||||
.map .node.hub rect { fill: var(--accent-wash); stroke: var(--text-accent); stroke-width: 2; }
|
||||
.map .node.page rect { stroke: var(--text-primary); }
|
||||
.map text { fill: var(--text-primary); font-family: var(--font-mono); font-size: 13px; }
|
||||
.map text.s { fill: var(--text-secondary); font-size: 11px; }
|
||||
.map .e { stroke: var(--text-secondary); stroke-width: 1.5; fill: none; }
|
||||
.map .e.hot { stroke: var(--text-accent); stroke-width: 2; }
|
||||
.map .lbl { fill: var(--text-secondary); font-size: 10.5px; }
|
||||
.map a:focus-visible rect { stroke: var(--focus-ring); stroke-width: 3; }
|
||||
.flows { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 330px), 1fr)); gap: var(--space-4); }
|
||||
.flow { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.flow h3 { margin: 0; font-size: 15px; }
|
||||
.flow ol { margin: 0; padding-left: 20px; font-size: 13px; color: var(--text-secondary); display: flex; flex-direction: column; gap: 4px; }
|
||||
.flow ol b { color: var(--text-primary); }
|
||||
.flow .btn { align-self: flex-start; }
|
||||
.compare { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: var(--space-4); }
|
||||
.compare > div { padding: var(--space-4); font-size: 13px; }
|
||||
.compare h3 { margin: 0 0 8px; font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); }
|
||||
.compare ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
function node(x, y, w, h, t, s, cls, href) {
|
||||
var inner = '<g class="node ' + cls + '"><rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" rx="14"/><text x="' + (x + 16) + '" y="' + (y + 26) + '" font-weight="700">' + t + '</text>' + s.map(function (l, i) { return '<text class="s" x="' + (x + 16) + '" y="' + (y + 46 + i * 16) + '">' + l + '</text>'; }).join('') + '</g>';
|
||||
return href ? '<a href="' + href + '">' + inner + '</a>' : inner;
|
||||
}
|
||||
function edge(d, hot, lx, ly, label) { return '<path class="e' + (hot ? ' hot' : '') + '" d="' + d + '" marker-end="url(#ar' + (hot ? 'h' : '') + ')"/>' + (label ? '<text class="lbl" x="' + lx + '" y="' + ly + '">' + label + '</text>' : ''); }
|
||||
function svg() {
|
||||
return '<svg viewBox="0 -50 1100 600" role="img" aria-labelledby="map-t map-d"><title id="map-t">Extrudex iteration 2 flow map</title><desc id="map-d">Four pages share one navigation, a command palette and a detail panel. The Dashboard links into each page\'s filtered views; spools, prints and holders open in the panel from any page.</desc>' +
|
||||
'<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 10 5 0 10z" fill="var(--text-secondary)"/></marker><marker id="arh" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 10 5 0 10z" fill="var(--text-accent)"/></marker></defs>' +
|
||||
node(30, 30, 230, 110, 'Dashboard', ['“Needs you” queue', 'stock · recent prints', 'printers · running low'], 'page', '01-dashboard.html') +
|
||||
node(310, 30, 230, 110, 'Filament', ['saved views + counts', 'Stock / Weights columns', 'weigh session'], 'page', '02-filament.html') +
|
||||
node(590, 30, 230, 110, 'Prints', ['Needs attention queue', 'All · Running · Archived', 'batch attribute'], 'page', '03-prints.html') +
|
||||
node(870, 30, 200, 110, 'Printers', ['urgency order', 'holders + Load', 'connection state'], 'page', '04-printers.html') +
|
||||
node(250, 300, 600, 130, 'Detail panel (#spool= · #print= · #load=)', ['Spool: Overview · Weigh · Move · Edit (+ 409 / 400 states)', 'Print: what it needs, fixed in place · provenance explained', 'Load: pick a stored spool for a holder with space', 'Opens over any page · Esc / back closes · ↑↓ / j k step through the list'], 'hub', '02-filament.html#spool=47') +
|
||||
node(30, 450, 300, 90, '⌘K / “/” palette', ['spools · prints · printers', '“weigh copper” jumps straight in'], 'hub', null) +
|
||||
node(770, 450, 300, 90, 'Toasts + badges', ['every save confirms what changed', 'nav counts update immediately'], '', null) +
|
||||
edge('M262 85 L306 85', 1, 262, 76, 'views') +
|
||||
edge('M180 30 C180 -18 705 -18 705 26', 1, 395, -8, 'Review N prints') +
|
||||
edge('M110 30 C110 -38 970 -38 970 26', 1, 520, -30, 'Open printer') +
|
||||
edge('M145 140 C145 230 330 250 400 298', 1, 150, 225, 'spool / print rows') +
|
||||
edge('M425 140 L470 298', 1, 452, 230, 'row / ↵') +
|
||||
edge('M705 140 L640 298', 1, 680, 230, 'row / fix') +
|
||||
edge('M970 140 C970 230 860 270 800 298', 1, 900, 235, 'spool · + Load') +
|
||||
edge('M180 450 C180 400 240 380 248 380', 0, 120, 420, 'open anything') +
|
||||
edge('M850 380 C900 400 920 420 920 448', 0, 930, 415, 'after save') +
|
||||
'</svg>';
|
||||
}
|
||||
var FLOWS = [
|
||||
['Morning check (≈1 min)', ['<b>Dashboard</b> opens on “Needs you”', 'Review 3 → <b>Use</b> the same-color suggestion', 'Badge on <b>Prints</b> drops as each one clears'], '01-dashboard.html', 'Start on the Dashboard'],
|
||||
['Weigh the low spools', ['<b>Filament → Low</b> view', '<b>Start weighing</b>: type the scale reading', 'See before → after, then <b>Save & weigh next</b>'], '02-filament.html?f=low', 'Open low stock'],
|
||||
['Load a spool into a printer', ['<b>Printers</b> → A1 AMS Lite → <b>+ Load</b>', 'Filter stored spools, pick one', 'Toast confirms; the holder fill updates'], '04-printers.html#printer=3', 'Open A1'],
|
||||
['Find anything', ['Press <b>/</b> or <b>⌘K</b> on any page', 'Type <i>copper</i>, <i>#47</i> or <i>weigh black</i>', '↵ opens the panel right there'], '02-filament.html', 'Try it on Filament'],
|
||||
['Someone changed it first (409)', ['Spool #47 → <b>Edit</b>', 'Demo state: <b>409 conflict</b>', 'Side-by-side; reapply your change, or discard it'], '02-filament.html#spool=47&tab=edit', 'Open the editor'],
|
||||
['A spool with no empty spool weight', ['<b>Empty spool weight unverified</b> view (7)', 'Weigh tab explains why it can\'t weigh yet', 'Pick a spool type, or weigh the empty spool'], '02-filament.html?f=tare#spool=44&tab=weigh', 'Open #44'],
|
||||
['A printer drops off', ['<b>Printers</b>: A1 sits first with a warning border', 'Toggle <b>dev today / + feature branch</b>', 'What happens on reconnect is spelled out'], '04-printers.html#printer=3', 'Open Printers'],
|
||||
['Label new spools', ['<b>Labels</b> → Needs a label (4)', 'Previews at real 51 × 21 mm proportions', '<b>Download</b>: they\'re marked printed on success'], '06-labels.html', 'Open Labels'],
|
||||
['Load spools at the printer (phone)', ['Scan the <b>AMS Lite</b> holder label', 'Scan spool labels: each loads, returns or moves', 'A full holder says so; nothing changes'], '09-scan-holder.html?c=5', 'Open scan session'],
|
||||
['Weigh from a label (phone)', ['Scan a spool label with no session active', 'See where it lives; type the scale reading', 'Before → after, then <b>Save</b>'], '10-scan-spool.html?s=41', 'Open #41'],
|
||||
['Fix a wrong attribution', ['Print <b>Benchy_x4</b> → <b>Change spool</b>', 'See: 46 g goes back to Elegoo Black', 'Pick the right spool; cost re-rates'], '03-prints.html?tab=all#print=211', 'Open Benchy_x4'],
|
||||
['Add or remove a printer', ['<b>Printers → + Add printer</b>: brand filters models', 'Remove asks for the exact name', 'Refused while its holders still hold spools'], '04-printers.html#premove=3', 'Try removing A1'],
|
||||
['Palette commands', ['<b>/</b> then <i>load copper into p1s ext</i>', 'or <i>attribute bracket to black</i>', '↵ does it; a toast confirms'], '02-filament.html?palette=load%20', 'Open palette'],
|
||||
['When the data is not live', ['Mockup pill (bottom left) → <b>Simulate polling failure</b>', 'Header turns amber, banner says the data\'s age', '<b>Replay first load</b> shows skeletons'], '01-dashboard.html', 'Open Dashboard']
|
||||
];
|
||||
function render() {
|
||||
return '<header class="page-head"><div><h1>Flow map</h1><p class="sum">Iteration 3 · every screen, how they connect, and <b>' + FLOWS.length + ' flows</b> to click through</p></div><div class="actions"><a class="btn" href="../../iteration-2/screens/index.html">← Iteration 2</a><a class="btn btn-primary" href="devices.html">Devices →</a></div></header>' +
|
||||
'<section class="map nm-raised" aria-label="Flow diagram">' + svg() + '</section>' +
|
||||
'<section class="stack" style="gap:12px" aria-labelledby="sc-h"><h2 class="section" id="sc-h" style="margin:0">All screens · <a href="devices.html">see them on phone and tablet →</a></h2><div class="flows">' +
|
||||
[['01-dashboard.html', 'Dashboard', 'Needs you · stock · recent prints'], ['02-filament.html', 'Filament', 'views · weigh session · panel'], ['03-prints.html', 'Prints', 'attention queue · history'], ['04-printers.html', 'Printers', 'holders · load · admin panels'], ['05-lists.html', 'Lists', 'the 5 name parts + spool types'], ['06-labels.html', 'Labels', 'spool + holder QR labels'], ['07-access.html', 'Access', 'people · sessions · API tokens'], ['08-system.html', 'System', 'weekly report · audit · device'], ['09-scan-holder.html', 'Scan: holder', 'phone · load/return/move'], ['10-scan-spool.html', 'Scan: spool', 'phone · weigh from a label']].map(function (s2) { return '<a class="flow nm-raised" style="text-decoration:none;color:inherit;padding:14px 16px" href="' + s2[0] + '"><h3>' + s2[1] + ' →</h3><span class="small muted">' + s2[2] + '</span></a>'; }).join('') + '</div></section>' +
|
||||
'<section aria-labelledby="fl-h" class="stack" style="gap:12px"><h2 class="section" id="fl-h" style="margin:0">Try these flows</h2><div class="flows">' + FLOWS.map(function (f) { return '<article class="flow nm-raised"><h3>' + f[0] + '</h3><ol>' + f[1].map(function (s) { return '<li>' + s + '</li>'; }).join('') + '</ol><a class="btn btn-sm" href="' + f[2] + '">' + f[3] + ' →</a></article>'; }).join('') + '</div></section>' +
|
||||
'<section class="compare" aria-label="What changed"><div class="nm-inset"><h3>Iteration 1</h3><ul><li>“Prints” and “All prints” were dead ends</li><li>Edit left the list for a separate page</li><li>A spool in an AMS slot or low-stock list went nowhere</li><li>Every data point was a loud chip, so a legend was needed</li><li>No freshness, loading or error state</li><li>Invented “slot 2” positions the database doesn\'t store</li></ul></div>' +
|
||||
'<div class="nm-raised"><h3>Iteration 2</h3><ul><li>Every spool, print and holder opens one shared panel, deep-linkable</li><li>Dashboard leads with what needs doing, each item one click from the fix</li><li>Chips only for state; provenance is quiet text with a tooltip</li><li>Palette, badges, toasts, j/k stepping, Esc/back to close</li><li>“Live · updated 3s ago”, with an honest offline state</li><li>Holders show fill (“3 / 4”), not tray positions</li></ul></div></section>';
|
||||
}
|
||||
X.init({ name: 'map' }, render);
|
||||
})();
|
||||
</script>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,87 @@
|
||||
/* ── Mockup app shell (shared by all four screens) ── */
|
||||
.shell { display: grid; grid-template-columns: 232px minmax(0, 1fr); gap: var(--space-6); max-width: 1440px; margin: 0 auto; padding: var(--space-6); min-height: 100vh; }
|
||||
.rail { position: sticky; top: var(--space-6); align-self: start; padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-5); }
|
||||
.wordmark { font-weight: var(--fw-display); font-size: var(--fs-h3); letter-spacing: var(--tracking-wordmark); display: flex; align-items: center; gap: var(--space-2); }
|
||||
.wordmark-mark { width: 30px; height: 30px; border-radius: 10px; background: var(--accent-fill); color: var(--text-on-accent); display: grid; place-items: center; font-size: 15px; box-shadow: var(--nm-raised-1); border: 1px solid var(--border-control); }
|
||||
.nav { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.nav a { display: flex; align-items: center; gap: var(--space-3); padding: 10px 12px; border-radius: var(--radius-md); color: var(--text-primary); text-decoration: none; border: 1px solid transparent; font-weight: var(--fw-medium); }
|
||||
.nav a:hover { border-color: var(--border-control); }
|
||||
.nav a:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: var(--focus-offset); }
|
||||
.nav a[aria-current="page"] { background: var(--surface-inset); box-shadow: var(--nm-inset-1); border-color: var(--text-accent); color: var(--text-accent); font-weight: var(--fw-bold); }
|
||||
.nav svg { width: 18px; height: 18px; flex: none; }
|
||||
.nav .nav-new { margin-left: auto; }
|
||||
.rail-foot { display: flex; flex-direction: column; gap: var(--space-3); border-top: 1px solid var(--border-hairline); padding-top: var(--space-4); }
|
||||
.seg { display: inline-flex; padding: 4px; gap: 4px; border-radius: var(--radius-pill); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-control); }
|
||||
.seg button { font: var(--fw-medium) var(--fs-label)/1 var(--font-body); color: var(--text-secondary); background: transparent; border: 1px solid transparent; border-radius: var(--radius-pill); padding: 7px 12px; cursor: pointer; display: inline-flex; gap: 6px; align-items: center; }
|
||||
.seg button[aria-pressed="true"] { background: var(--surface-raised); color: var(--text-primary); box-shadow: var(--nm-raised-1); border-color: var(--border-control); font-weight: var(--fw-bold); }
|
||||
.seg button[aria-pressed="true"]::before { content: "✓"; color: var(--text-accent); }
|
||||
.seg button:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }
|
||||
.who { font-size: var(--fs-label); }
|
||||
|
||||
main { min-width: 0; display: flex; flex-direction: column; gap: var(--space-6); padding-bottom: var(--space-16); }
|
||||
.proposal { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); align-items: center; font-size: var(--fs-label); color: var(--text-secondary); padding: var(--space-3) var(--space-4); }
|
||||
.page-head { display: flex; flex-wrap: wrap; gap: var(--space-4); align-items: flex-end; justify-content: space-between; }
|
||||
.page-head h1 { margin: 0; font-size: var(--fs-h1); line-height: var(--lh-tight); font-weight: var(--fw-display); }
|
||||
.page-head p { margin: var(--space-1) 0 0; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; }
|
||||
h2.section { font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); margin: 0 0 var(--space-3); font-weight: var(--fw-bold); display: flex; align-items: center; gap: var(--space-2); }
|
||||
.card { padding: var(--space-5); }
|
||||
.card h3 { margin: 0; font-size: var(--fs-body-lg); }
|
||||
.grid { display: grid; gap: var(--space-5); }
|
||||
.stack { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.row { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
|
||||
.spacer { flex: 1; }
|
||||
a { color: var(--text-accent); }
|
||||
a:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; border-radius: 4px; }
|
||||
small, .small { font-size: var(--fs-label); }
|
||||
.tiny { font-size: var(--fs-caption); }
|
||||
code { font-family: var(--font-mono); font-size: 0.92em; background: var(--surface-inset); padding: 1px 5px; border-radius: var(--radius-xs); border: 1px solid var(--border-hairline); }
|
||||
.kbd-note { font-size: var(--fs-caption); color: var(--text-secondary); }
|
||||
|
||||
/* Data tables: square, numbers right-aligned, inside a soft card */
|
||||
.table-wrap { overflow-x: auto; margin: 0 calc(var(--space-5) * -1); padding: 0 var(--space-5); }
|
||||
table.tbl { width: 100%; border-collapse: collapse; border-radius: var(--radius-none); font-size: 13px; }
|
||||
.tbl th { text-align: left; font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps); color: var(--text-secondary); font-weight: var(--fw-bold); padding: 10px 10px; border-bottom: 1px solid var(--border-strong); white-space: nowrap; }
|
||||
.tbl td { padding: 11px 10px; border-bottom: 1px solid var(--border-hairline); vertical-align: middle; }
|
||||
.tbl tr:last-child td { border-bottom: 0; }
|
||||
.tbl .r { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.tbl tbody tr:hover td { background: var(--accent-wash); }
|
||||
|
||||
/* Provenance marker used across screens */
|
||||
.src { display: inline-flex; gap: 4px; align-items: center; font-size: var(--fs-caption); font-weight: var(--fw-bold); letter-spacing: var(--tracking-caps); text-transform: uppercase; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex: none; }
|
||||
|
||||
/* Stat tiles */
|
||||
.stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-5); }
|
||||
.stat { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.stat-value { font-size: 30px; font-weight: var(--fw-display); line-height: var(--lh-tight); font-variant-numeric: tabular-nums; }
|
||||
.stat-label { font-size: var(--fs-label); color: var(--text-secondary); text-transform: uppercase; letter-spacing: var(--tracking-caps); font-weight: var(--fw-bold); }
|
||||
.stat-foot { font-size: var(--fs-label); color: var(--text-secondary); }
|
||||
.stat.warn .stat-value { color: var(--status-warning); }
|
||||
|
||||
/* Modal backdrop for dialogs */
|
||||
.scrim { position: fixed; inset: 0; background: rgba(10, 10, 14, 0.55); }
|
||||
:root[data-theme="light"] .scrim { background: rgba(60, 55, 80, 0.35); }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1100px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 860px) {
|
||||
.shell { grid-template-columns: 1fr; padding: var(--space-4); gap: var(--space-4); }
|
||||
.rail { position: static; flex-direction: row; flex-wrap: wrap; align-items: center; padding: var(--space-3) var(--space-4); gap: var(--space-3); }
|
||||
.nav { flex-direction: row; flex-wrap: wrap; gap: var(--space-1); }
|
||||
.nav a { padding: 8px 10px; }
|
||||
.nav a span.lbl { display: none; }
|
||||
.nav a[aria-current="page"] span.lbl { display: inline; }
|
||||
.nav .nav-new { display: none; }
|
||||
.rail-foot { border: 0; padding: 0; flex-direction: row; margin-left: auto; }
|
||||
.who { display: none; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.stats { grid-template-columns: 1fr 1fr; gap: var(--space-3); }
|
||||
.stat { padding: var(--space-4); }
|
||||
.stat-value { font-size: 22px; }
|
||||
.page-head h1 { font-size: var(--fs-h2); }
|
||||
}
|
||||
|
||||
a.btn { text-decoration: none; }
|
||||
.th-note { display: block; margin-top: 4px; }
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
"""Assemble self-contained mockup pages: tokens + fonts (base64) + shell + page."""
|
||||
import base64, pathlib, re
|
||||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
OUT = pathlib.Path.home() / 'Design/extrudex-v3'
|
||||
FONTS = OUT / 'fonts'
|
||||
|
||||
tokens = (OUT / 'design-system-v3.css').read_text()
|
||||
|
||||
def embed_font(match):
|
||||
name = match.group(1)
|
||||
data = base64.b64encode((FONTS / name).read_bytes()).decode()
|
||||
return f"url('data:font/woff2;base64,{data}')"
|
||||
|
||||
tokens = re.sub(r"url\('\./fonts/([^']+)'\)", embed_font, tokens)
|
||||
app_css = (HERE / 'app.css').read_text()
|
||||
|
||||
ICONS = {
|
||||
'dashboard': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="2"/><rect x="14" y="3" width="7" height="5" rx="2"/><rect x="14" y="12" width="7" height="9" rx="2"/><rect x="3" y="16" width="7" height="5" rx="2"/></svg>',
|
||||
'filament': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v6M12 15v6"/></svg>',
|
||||
'prints': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>',
|
||||
'printers': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 17h8M9 7h6v5H9z"/></svg>',
|
||||
}
|
||||
NAV = [('dashboard', 'Dashboard', '01-dashboard.html'), ('filament', 'Filament', '02-filament.html'),
|
||||
('prints', 'Prints', None), ('printers', 'Printers', '04-printers.html')]
|
||||
|
||||
THEME_HEAD = """<script>
|
||||
(function () {
|
||||
var root = document.documentElement, key = 'extrudex-v3-theme', t = null;
|
||||
try { t = localStorage.getItem(key); } catch (e) {}
|
||||
var q = /[?&]theme=(light|dark)/.exec(location.search); if (q) t = q[1];
|
||||
if (t !== 'light' && t !== 'dark') {
|
||||
t = window.matchMedia && matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
root.setAttribute('data-theme', t);
|
||||
function set(v) {
|
||||
root.setAttribute('data-theme', v);
|
||||
document.querySelectorAll('[data-set-theme]').forEach(function (b) {
|
||||
b.setAttribute('aria-pressed', String(b.getAttribute('data-set-theme') === v));
|
||||
});
|
||||
try { localStorage.setItem(key, v); } catch (e) {}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function () { set(root.getAttribute('data-theme')); });
|
||||
document.addEventListener('click', function (e) {
|
||||
var b = e.target.closest && e.target.closest('[data-set-theme]');
|
||||
if (b) set(b.getAttribute('data-set-theme'));
|
||||
});
|
||||
})();
|
||||
</script>"""
|
||||
|
||||
def rail(active):
|
||||
links = []
|
||||
for key, label, href in NAV:
|
||||
cur = ' aria-current="page"' if key == active else ''
|
||||
extra = ''
|
||||
if key == 'dashboard': extra = '<span class="chip chip-accent nav-new">new</span>'
|
||||
if href is None:
|
||||
links.append(f'<a href="#" title="Prints history: not part of this proposal"{cur}>{ICONS[key]}<span class="lbl">{label}</span></a>')
|
||||
else:
|
||||
links.append(f'<a href="{href}"{cur}>{ICONS[key]}<span class="lbl">{label}</span>{extra}</a>')
|
||||
return f"""<nav class="rail nm-raised" aria-label="Main">
|
||||
<div class="wordmark"><span class="wordmark-mark" aria-hidden="true">X</span>Extrudex</div>
|
||||
<div class="nav">{''.join(links)}</div>
|
||||
<div class="rail-foot">
|
||||
<div class="seg" role="group" aria-label="Color theme">
|
||||
<button type="button" data-set-theme="light" aria-pressed="false">Light</button>
|
||||
<button type="button" data-set-theme="dark" aria-pressed="true">Dark</button>
|
||||
</div>
|
||||
<span class="who muted">Signed in · operator</span>
|
||||
</div>
|
||||
</nav>"""
|
||||
|
||||
def page(slug, title, active, description):
|
||||
src = (HERE / 'pages' / f'{slug}.html').read_text()
|
||||
css_m = re.search(r'<style>(.*?)</style>', src, re.S)
|
||||
page_css = css_m.group(1) if css_m else ''
|
||||
body = re.sub(r'<style>.*?</style>', '', src, flags=re.S).strip()
|
||||
js_m = re.search(r'<script>(.*?)</script>', body, re.S)
|
||||
page_js = ''
|
||||
if js_m:
|
||||
page_js = f'<script>{js_m.group(1)}</script>'
|
||||
body = re.sub(r'<script>.*?</script>', '', body, flags=re.S)
|
||||
html = f"""<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
<meta name="description" content="{description}">
|
||||
{THEME_HEAD}
|
||||
<style>
|
||||
{tokens}
|
||||
{app_css}
|
||||
{page_css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
{rail(active)}
|
||||
<main id="main">
|
||||
<div class="proposal nm-inset" role="note">
|
||||
<span class="chip chip-accent">Design System v3 proposal</span>
|
||||
<span>Mockup with sample data from the real inventory. Nothing here is wired to the API.</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="row" style="gap:12px"><span class="src"><span class="chip chip-neutral">dev</span> on dev today</span>
|
||||
<span class="src"><span class="chip chip-info chip-dashed">feature</span> feature branch only</span>
|
||||
<span class="src"><span class="chip chip-warn chip-dashed">API gap</span> stored, not served</span></span>
|
||||
</div>
|
||||
{body}
|
||||
</main>
|
||||
</div>
|
||||
{page_js}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
(OUT / 'screens' / f'{slug}.html').write_text(html)
|
||||
print(slug, len(html) // 1024, 'KB')
|
||||
|
||||
page('01-dashboard', 'Extrudex Dashboard', 'dashboard', 'Stock summary and recent prints, Soft UI v3 mockup.')
|
||||
page('02-filament', 'Extrudex Filament', 'filament', 'Filament inventory list with search, add, edit and delete, Soft UI v3 mockup.')
|
||||
page('03-spool-form', 'Extrudex Spool Form', 'filament', 'Add/Edit spool form with 409 conflict and field-level 400 errors, Soft UI v3 mockup.')
|
||||
page('04-printers', 'Extrudex Printers', 'printers', 'Printers screen with stored fields, dropped MQTT state and a Future section, Soft UI v3 mockup.')
|
||||
@@ -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:])}")
|
||||
@@ -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**'} |")
|
||||
@@ -0,0 +1,176 @@
|
||||
<style>
|
||||
.dash { display: grid; grid-template-columns: minmax(0, 2.4fr) minmax(280px, 1fr); gap: var(--space-6); align-items: start; }
|
||||
.fleet { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-4); }
|
||||
.fleet a { text-decoration: none; color: inherit; padding: var(--space-4); display: flex; flex-direction: column; gap: 6px; border-radius: var(--radius-md); }
|
||||
.fleet a:hover { border-color: var(--text-accent); }
|
||||
.fleet .name { font-weight: var(--fw-bold); font-size: 13px; }
|
||||
.mat { display: grid; grid-template-columns: 56px minmax(0, 1fr) 84px; gap: var(--space-3); align-items: center; font-size: 13px; }
|
||||
.mat .track { height: 12px; }
|
||||
.low li { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; padding: var(--space-3) 0; border-bottom: 1px solid var(--border-hairline); }
|
||||
.low li:last-child { border-bottom: 0; }
|
||||
.low ul { list-style: none; margin: 0; padding: 0; }
|
||||
.low .nm { font-weight: var(--fw-bold); font-size: 13px; line-height: var(--lh-snug); }
|
||||
.low .sub { font-size: var(--fs-caption); color: var(--text-secondary); line-height: var(--lh-snug); }
|
||||
.low .g { text-align: right; font-variant-numeric: tabular-nums; line-height: var(--lh-snug); }
|
||||
.low .g strong { color: var(--status-warning); font-size: 15px; }
|
||||
.job { font-weight: var(--fw-bold); }
|
||||
.job-sub { display: block; font-size: var(--fs-caption); color: var(--text-secondary); font-weight: var(--fw-regular); }
|
||||
.dur-ub { color: var(--status-warning); font-weight: var(--fw-bold); }
|
||||
.legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: var(--space-3) var(--space-5); font-size: var(--fs-label); color: var(--text-secondary); margin-top: var(--space-4); padding: var(--space-4); }
|
||||
.legend div { display: flex; gap: var(--space-2); align-items: baseline; }
|
||||
@media (max-width: 1280px) { .dash { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media (max-width: 640px) { .fleet { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p class="muted">Friday 18 September · refreshes every 10 s · last update 09:41:07</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="btn" href="02-filament.html">Open inventory</a>
|
||||
<a class="btn btn-primary" href="03-spool-form.html">+ Add spool</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="stock-h">
|
||||
<h2 class="section" id="stock-h">Stock</h2>
|
||||
<div class="stats">
|
||||
<div class="stat nm-raised"><span class="stat-label">Spools</span><span class="stat-value">105</span><span class="stat-foot">18 brands · 6 materials</span></div>
|
||||
<div class="stat nm-raised"><span class="stat-label">Filament left <abbr title="Net filament, not including the empty spools">net</abbr></span><span class="stat-value">95.72 kg</span><span class="stat-foot">filament only, empty spools excluded</span></div>
|
||||
<div class="stat nm-raised"><span class="stat-label">Stock value</span><span class="stat-value">$1,721.18</span><span class="stat-foot">remaining net g × each spool’s $/kg</span></div>
|
||||
<div class="stat nm-raised warn"><span class="stat-label">Running low</span><span class="stat-value">⚠ 5</span><span class="stat-foot">under 200 g net each</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="dash">
|
||||
<div class="stack" style="gap: var(--space-6)">
|
||||
<section class="card nm-raised" aria-labelledby="prints-h">
|
||||
<div class="row" style="margin-bottom: var(--space-3)">
|
||||
<h2 class="section" id="prints-h" style="margin:0">Recent prints</h2>
|
||||
<span class="spacer"></span>
|
||||
<a href="#" class="small">All prints →</a>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr>
|
||||
<th>Job</th><th>Status</th><th class="r">Duration</th>
|
||||
<th class="r">Filament</th><th>Weight source <span class="th-note"><span class="chip chip-warn chip-dashed" title="Stored in print.weight_source; GET /api/prints does not return it yet">API gap</span></span></th><th class="r">Cost</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="job">Gridfinity_bins_3x2</span><span class="job-sub">P1S - Prints Charming · 08:37</span></td>
|
||||
<td><span class="chip chip-accent">● Running</span></td>
|
||||
<td class="r">1h 04m <span class="job-sub">so far</span></td>
|
||||
<td class="r muted">—</td>
|
||||
<td><span class="small muted">weighed when it ends</span></td>
|
||||
<td class="r muted">pending</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="job">Benchy_x4</span><span class="job-sub">H2C - Constipation Orion · 02:15</span></td>
|
||||
<td><span class="chip chip-ok">✓ Finished</span></td>
|
||||
<td class="r"><span class="dur-ub" title="The connection dropped once during this print. That time is inside the duration and nothing watched it, so this is an upper bound, not a measurement.">≤ 3h 12m</span>
|
||||
<span class="job-sub"><span class="chip chip-warn chip-dashed">upper bound · 1 gap</span></span></td>
|
||||
<td class="r">46 g</td>
|
||||
<td><span class="chip chip-info">Sliced</span> <span class="job-sub">from this run's 3MF</span></td>
|
||||
<td class="r">$0.53</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="job">Sign_CubeCraft</span><span class="job-sub">H2C - Constipation Orion · yesterday 21:48 · 2 colors</span></td>
|
||||
<td><span class="chip chip-ok">✓ Finished</span></td>
|
||||
<td class="r">2h 05m</td>
|
||||
<td class="r">64 g <span class="job-sub">41 g + 23 g</span></td>
|
||||
<td><span class="chip chip-info">Sliced</span></td>
|
||||
<td class="r">$0.92</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="job">Cable_clips</span><span class="job-sub">A1 - Jordyns Layer Slayer · yesterday 19:30</span></td>
|
||||
<td><span class="chip chip-warn chip-dashed">? Unknown</span></td>
|
||||
<td class="r"><span class="muted" title="Nobody saw this print end. The only measurable figure is the outage, which is not how long anything printed.">no duration</span><span class="job-sub">end not seen</span></td>
|
||||
<td class="r">12 g</td>
|
||||
<td><span class="chip chip-neutral">Typed</span> <span class="job-sub">someone weighed it</span></td>
|
||||
<td class="r">$0.14</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="job">Lid_v3</span><span class="job-sub">P1S - Prints Charming · yesterday 16:02</span></td>
|
||||
<td><span class="chip chip-err">✕ Failed</span></td>
|
||||
<td class="r">47m</td>
|
||||
<td class="r">118 g <span class="job-sub">full job weight</span></td>
|
||||
<td><span class="chip chip-neutral">Typed</span></td>
|
||||
<td class="r">$1.38</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="job">Planter_small</span><span class="job-sub">A1 - Jordyns Layer Slayer · 16 Sep 10:20</span></td>
|
||||
<td><span class="chip chip-ok">✓ Finished</span></td>
|
||||
<td class="r">5h 40m</td>
|
||||
<td class="r">212 g</td>
|
||||
<td><span class="chip chip-info chip-dashed">Recalled</span> <span class="job-sub">remembered for this job</span></td>
|
||||
<td class="r">$2.49</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="job">Test_cube</span><span class="job-sub">P1S - Prints Charming · 7 Aug 14:11</span></td>
|
||||
<td><span class="chip chip-ok">✓ Finished</span></td>
|
||||
<td class="r">31m <span class="job-sub" title="This print predates gap counting (connection_gaps is null), so nobody asked whether the connection dropped.">gaps not counted</span></td>
|
||||
<td class="r">9 g</td>
|
||||
<td><span class="chip chip-warn chip-dashed">Unknown</span> <span class="job-sub">predates provenance</span></td>
|
||||
<td class="r">$0.10</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="legend nm-inset" aria-label="How to read this table">
|
||||
<div><span class="dur-ub">≤</span><span><b>Upper bound.</b> The connection dropped during the print, so the time includes a gap nobody watched.</span></div>
|
||||
<div><span class="chip chip-neutral">Typed</span><span>A measurement. It survives an identity correction.</span></div>
|
||||
<div><span class="chip chip-info">Sliced</span><span>Read from the job's 3MF over FTPS.</span></div>
|
||||
<div><span class="chip chip-info chip-dashed">Recalled</span><span>A copy of a weight remembered for the same gcode path.</span></div>
|
||||
<div><span class="chip chip-warn chip-dashed">Unknown</span><span>Written before the source was recorded. Neither path will move it.</span></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="stack" style="gap: var(--space-6)" aria-label="Stock detail">
|
||||
<section class="card nm-raised low" aria-labelledby="low-h">
|
||||
<h2 class="section" id="low-h">⚠ Running low <span class="muted">· under 200 g</span></h2>
|
||||
<ul>
|
||||
<li><span class="swatch"><i style="background:#1f3fbf"></i></span>
|
||||
<span><span class="nm">Klein Blue</span><br><span class="sub">Sunlu PLA + 2.0 Basic · 250 g mini</span></span>
|
||||
<span class="g"><strong>64 g</strong><br><span class="sub">gross 124 g</span></span></li>
|
||||
<li><span class="swatch"><i style="background:#b87333"></i></span>
|
||||
<span><span class="nm">Copper</span><br><span class="sub">Sunlu PLA + Silk</span></span>
|
||||
<span class="g"><strong>97 g</strong><br><span class="sub">gross 237 g</span></span></li>
|
||||
<li><span class="swatch"><i style="background:#141414"></i></span>
|
||||
<span><span class="nm">Black</span><br><span class="sub">Elegoo Rapid PETG Basic</span></span>
|
||||
<span class="g"><strong>142 g</strong><br><span class="sub">gross 296 g</span></span></li>
|
||||
<li><span class="swatch"><i style="background:#ece8df"></i></span>
|
||||
<span><span class="nm">Rock White</span><br><span class="sub">Overture PLA Marble</span></span>
|
||||
<span class="g"><strong>171 g</strong><br><span class="sub">gross 326 g</span></span></li>
|
||||
<li><span class="swatch"><i style="background:#161616"></i><i style="background:#c0232f"></i></span>
|
||||
<span><span class="nm">Black Red</span><br><span class="sub">TECSONAR PLA Matte</span></span>
|
||||
<span class="g"><strong>188 g</strong><br><span class="sub">gross <span class="chip chip-warn chip-dashed" title="No tare is known for this spool, so gross weight cannot be worked out. It is never shown as 0.">tare unknown</span></span></span></li>
|
||||
</ul>
|
||||
<p class="tiny muted" style="margin: var(--space-3) 0 0">Net grams are filament only. Gross = empty-spool tare + net, what a scale reads.</p>
|
||||
</section>
|
||||
|
||||
<section class="card nm-raised" aria-labelledby="mat-h">
|
||||
<h2 class="section" id="mat-h">Net filament by material</h2>
|
||||
<div class="stack">
|
||||
<div class="mat"><b>PLA</b><div class="track"><span style="width:100%"></span></div><span class="r num">48.12 kg</span></div>
|
||||
<div class="mat"><b>PETG</b><div class="track"><span style="width:68.8%"></span></div><span class="r num">33.10 kg</span></div>
|
||||
<div class="mat"><b>ASA</b><div class="track"><span style="width:19.7%"></span></div><span class="r num">9.50 kg</span></div>
|
||||
<div class="mat"><b>TPU</b><div class="track"><span style="width:6.2%"></span></div><span class="r num">3.00 kg</span></div>
|
||||
<div class="mat"><b>PA6</b><div class="track"><span style="width:2.1%"></span></div><span class="r num">1.00 kg</span></div>
|
||||
<div class="mat"><b>ABS</b><div class="track"><span style="width:2.1%"></span></div><span class="r num">1.00 kg</span></div>
|
||||
</div>
|
||||
<p class="tiny muted" style="margin: var(--space-3) 0 0">55 PLA · 34 PETG · 10 ASA · 3 TPU · 2 PA6 · 1 ABS spools</p>
|
||||
</section>
|
||||
|
||||
<section class="card nm-raised" aria-labelledby="fleet-h">
|
||||
<h2 class="section" id="fleet-h">Printers</h2>
|
||||
<div class="fleet" style="grid-template-columns: 1fr">
|
||||
<a class="nm-raised-sm" href="04-printers.html"><span class="name">P1S - Prints Charming</span><span><span class="chip chip-accent">● Printing</span> <span class="small muted">Gridfinity_bins_3x2</span></span></a>
|
||||
<a class="nm-raised-sm" href="04-printers.html"><span class="name">H2C - Constipation Orion</span><span><span class="chip chip-neutral">Idle</span> <span class="small muted">last: Benchy_x4</span></span></a>
|
||||
<a class="nm-raised-sm" href="04-printers.html" style="border-color: var(--status-warning)"><span class="name">A1 - Jordyns Layer Slayer</span><span><span class="chip chip-warn">⚡ Connection lost</span> <span class="small muted">last contact 6m ago</span></span></a>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -0,0 +1,182 @@
|
||||
<style>
|
||||
.filters { padding: var(--space-4) var(--space-5); display: grid; grid-template-columns: minmax(220px, 2fr) repeat(3, minmax(130px, 1fr)); gap: var(--space-3) var(--space-4); align-items: end; }
|
||||
.filters .field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.filters label > span, .lbl-t { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: var(--tracking-caps); color: var(--text-secondary); font-weight: var(--fw-bold); }
|
||||
.filters .toggles { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; }
|
||||
.search { position: relative; }
|
||||
.search svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-secondary); pointer-events: none; }
|
||||
.search .input { padding-left: 36px; }
|
||||
.name-main { font-weight: var(--fw-bold); }
|
||||
.name-parts { display: block; font-size: var(--fs-caption); color: var(--text-secondary); }
|
||||
.name-parts .opt { opacity: 1; font-style: italic; }
|
||||
.colorcell { display: flex; gap: var(--space-3); align-items: center; }
|
||||
.rem { min-width: 150px; }
|
||||
.rem .track { margin-top: 5px; height: 8px; }
|
||||
.wt { line-height: var(--lh-snug); }
|
||||
.wt .k { font-size: var(--fs-caption); color: var(--text-secondary); text-transform: uppercase; letter-spacing: var(--tracking-caps); }
|
||||
.loc { min-width: 140px; font-size: 13px; line-height: var(--lh-snug); }
|
||||
.fn { display: block; font-size: 13px; }
|
||||
.acts { display: flex; gap: var(--space-2); justify-content: flex-end; }
|
||||
.low-row td:first-child { box-shadow: inset 3px 0 0 var(--status-warning); }
|
||||
.cards { display: none; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: var(--space-5); }
|
||||
.view-cards .cards { display: grid; }
|
||||
.view-cards .table-card { display: none; }
|
||||
.scard { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.scard .head { display: flex; gap: var(--space-3); align-items: center; }
|
||||
.scard dl { display: grid; grid-template-columns: auto 1fr; gap: 2px var(--space-3); margin: 0; font-size: var(--fs-label); }
|
||||
.scard dt { color: var(--text-secondary); }
|
||||
.scard dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.pager { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-4); }
|
||||
dialog.confirm { padding: 0; border: 0; background: transparent; color: inherit; max-width: 480px; width: calc(100% - 32px); }
|
||||
dialog.confirm::backdrop { background: rgba(10, 10, 14, 0.55); }
|
||||
dialog.confirm .nm-dialog { padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
dialog.confirm h2 { margin: 0; font-size: var(--fs-h3); }
|
||||
.empty-note { padding: var(--space-8); text-align: center; }
|
||||
@media (max-width: 640px) { #view:not(.view-table) .table-card { display: none; } #view:not(.view-table) .cards { display: grid; } }
|
||||
@media (max-width: 900px) { .filters { grid-template-columns: 1fr 1fr; } .filters .search-f { grid-column: 1 / -1; } }
|
||||
</style>
|
||||
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1>Filament</h1>
|
||||
<p class="muted"><b class="num">105</b> spools · <b>18</b> brands · <b class="num">95.72 kg</b> net · <b class="num">$1,721.18</b></p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span class="small muted">Labels outstanding: <b>12</b></span>
|
||||
<button class="btn" type="button">Download unlabeled labels</button>
|
||||
<button class="btn" type="button">Manage lists</button>
|
||||
<a class="btn btn-primary" href="03-spool-form.html#add">+ Add spool</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filters nm-raised" aria-label="Search and filter">
|
||||
<label class="field search-f"><span>Search</span>
|
||||
<span class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></svg>
|
||||
<input class="input" id="q" type="search" placeholder="Brand, color, material, location…" autocomplete="off">
|
||||
</span>
|
||||
</label>
|
||||
<label class="field"><span>Brand</span>
|
||||
<select class="select" id="brand"><option value="">All brands (105)</option><option>Sunlu</option><option>Elegoo</option><option>Overture</option><option>TECSONAR</option><option>AMOLEN</option><option>JAYO</option><option>iSANMATE</option></select></label>
|
||||
<label class="field"><span>Material</span>
|
||||
<select class="select" id="mat"><option value="">All materials (105)</option><option>PLA</option><option>PETG</option><option>ASA</option><option>TPU</option><option>PA6</option><option>ABS</option></select></label>
|
||||
<label class="field"><span>Sort</span>
|
||||
<select class="select" id="sort"><option value="asc">Least remaining</option><option value="desc">Most remaining</option><option value="brand">Brand A–Z</option><option value="value">Highest value</option></select></label>
|
||||
<div class="toggles">
|
||||
<button class="btn btn-sm" type="button" aria-pressed="false" id="t-low">Low stock only</button>
|
||||
<button class="btn btn-sm" type="button" aria-pressed="false" id="t-store">In storage only</button>
|
||||
<button class="btn btn-sm" type="button" aria-pressed="false" id="t-tare">Tare unknown only</button>
|
||||
<span class="spacer"></span>
|
||||
<div class="seg" role="group" aria-label="Inventory view">
|
||||
<button type="button" aria-pressed="true" data-view="table">Table</button>
|
||||
<button type="button" aria-pressed="false" data-view="cards">Cards</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="view">
|
||||
<section class="card nm-raised table-card" aria-label="Filament inventory">
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr>
|
||||
<th>Spool <span class="th-note tiny muted" style="text-transform:none;letter-spacing:0">color · brand speed material type finish</span></th>
|
||||
<th class="r">Remaining <abbr title="Net filament only">net</abbr></th>
|
||||
<th class="r">Empty spool (tare)</th>
|
||||
<th class="r">Gross <abbr title="What the scale reads: tare + remaining">on scale</abbr></th>
|
||||
<th class="r">Value</th><th>Location</th><th class="r">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pager"><span class="small muted" id="count"></span>
|
||||
<div class="actions"><button class="btn btn-sm" disabled type="button">← Previous</button><span class="small">Page 1 of 9</span><button class="btn btn-sm" type="button">Next →</button></div></div>
|
||||
</section>
|
||||
<div class="cards" id="cards" aria-label="Filament inventory cards"></div>
|
||||
</div>
|
||||
|
||||
<dialog class="confirm" id="confirm" aria-labelledby="confirm-h">
|
||||
<form method="dialog" class="nm-dialog">
|
||||
<h2 id="confirm-h">Delete <span id="confirm-name"></span>?</h2>
|
||||
<p style="margin:0">This removes the spool from inventory. <b>Its print history is kept</b>: prints that used it keep their grams and cost, and only lose the link to this spool.</p>
|
||||
<p class="small muted" style="margin:0">This can't be undone. If you just ran the spool out, you can also set remaining to 0 g and keep the record.</p>
|
||||
<div class="actions" style="justify-content:flex-end">
|
||||
<button class="btn" value="cancel" autofocus>Keep spool</button>
|
||||
<button class="btn btn-danger" value="delete">Delete spool</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
var SPOOLS = [
|
||||
{c:['#1f3fbf'], color:'Klein Blue', brand:'Sunlu', speed:null, mat:'PLA', type:'+ 2.0', fin:'Basic', rem:64, tot:250, tare:60, tsrc:'community', tfrom:'Sunlu — 250 g mini', kg:25.20, loc:'Shelf B2', store:true},
|
||||
{c:['#b87333'], color:'Copper', brand:'Sunlu', speed:null, mat:'PLA', type:'+', fin:'Silk', rem:97, tot:1000, tare:140, tsrc:'community', tfrom:'Sunlu — 1 kg plastic', kg:19.99, loc:'P1S · AMS 1 · slot 2', store:false},
|
||||
{c:['#141414'], color:'Black', brand:'Elegoo', speed:'Rapid', mat:'PETG', type:null, fin:'Basic', rem:142, tot:1000, tare:154, tsrc:'manufacturer', tfrom:'Elegoo — 1 kg cardboard', kg:11.74, loc:'H2C · AMS 1 · slot 1', store:false},
|
||||
{c:['#ece8df'], color:'Rock White', brand:'Overture', speed:null, mat:'PLA', type:null, fin:'Marble', rem:171, tot:1000, tare:155, tsrc:'community', tfrom:'Overture — cardboard, PLA lines', kg:17.99, loc:'Shelf C', store:true},
|
||||
{c:['#161616','#c0232f'], color:'Black Red', brand:'TECSONAR', speed:null, mat:'PLA', type:null, fin:'Matte', rem:188, tot:1000, tare:null, kg:15.99, loc:'Shelf C', store:true},
|
||||
{c:['#101010'], color:'Black', brand:'JAYO', speed:'Rapid', mat:'PETG', type:null, fin:'Matte', rem:655, tot:1000, tare:null, kg:12.72, loc:'P1S · AMS 1 · slot 4', store:false},
|
||||
{c:['#8a8f7a','#e9e4d8'], color:'Marble', brand:'iSANMATE', speed:null, mat:'ASA', type:null, fin:'Marble', rem:430, tot:1000, tare:180, tsrc:'estimated', tfrom:'Generic — 1 kg spool (assumed)', kg:25.99, loc:'Dry box 1', store:true},
|
||||
{c:['#1b1b1b'], color:'Black', brand:'Sunlu', speed:null, mat:'PETG', type:'CF', fin:'Matte', rem:812, tot:1000, tare:262, tsrc:'weighed', tfrom:'this spool, weighed 14 Sep', kg:22.79, loc:'A1 · AMS Lite · slot 1', store:false, weighed:true},
|
||||
{c:['#b8864b'], color:'Natural Wood', brand:'Sunlu', speed:null, mat:'PLA', type:null, fin:'Wood', rem:1000, tot:1000, tare:35, tsrc:'estimated', tfrom:'Refill — bare cardboard core', kg:16.50, loc:'Shelf A3', store:true},
|
||||
{c:['#a3402c'], color:'Brick Red', brand:'Sunlu', speed:'High Speed', mat:'PLA', type:null, fin:'Marble', rem:1000, tot:1000, tare:140, tsrc:'community', tfrom:'Sunlu — 1 kg plastic', kg:17.50, loc:'Shelf A2', store:true},
|
||||
{c:['#e8453c','#f2c230','#2a8fd6'], color:'Gradient Rainbow', brand:'AMOLEN', speed:null, mat:'PETG', type:null, fin:'Gradient', rem:1000, tot:1000, tare:180, tsrc:'estimated', tfrom:'Generic — 1 kg spool (assumed)', kg:23.99, loc:'Shelf D', store:true},
|
||||
{c:['#1d4fa8'], color:'Blue', brand:'Elegoo', speed:'Rapid', mat:'PETG', type:null, fin:'Basic', rem:1000, tot:1000, tare:154, tsrc:'manufacturer', tfrom:'Elegoo — 1 kg cardboard', kg:17.99, loc:'Shelf A1', store:true}
|
||||
];
|
||||
var LOW = 200;
|
||||
function esc(s){return String(s).replace(/[&<>"]/g,function(ch){return {'&':'&','<':'<','>':'>','"':'"'}[ch]})}
|
||||
function sw(c){return '<span class="swatch" title="'+esc(c.join(' / '))+'">'+c.map(function(h){return '<i style="background:'+h+'"></i>'}).join('')+'</span>'}
|
||||
function fullName(s){return [s.brand,s.speed,s.mat,s.type,s.fin].filter(Boolean).join(' ')}
|
||||
function parts(s){
|
||||
function p(v,req){return v?esc(v):'<span class="opt" title="optional, not set">–</span>'}
|
||||
return '<span class="name-parts" title="brand · speed · material · type · finish">'+[p(s.brand),p(s.speed),p(s.mat),p(s.type),p(s.fin)].join(' · ')+'</span>'}
|
||||
var SRC = {manufacturer:'<span class="chip chip-ok">manufacturer</span>', community:'<span class="chip chip-info">community</span>', estimated:'<span class="chip chip-warn chip-dashed">estimated</span>', weighed:'<span class="chip chip-accent">this spool</span>'};
|
||||
function tareCell(s){
|
||||
if (s.tare == null) return '<span class="chip chip-warn chip-dashed" title="No empty-spool weight is known. Shown as unknown, never 0.">? unknown</span><span class="name-parts">weigh the bare spool</span>';
|
||||
return '<span class="wt"><b class="num">'+s.tare+' g</b><br>'+SRC[s.tsrc]+'<span class="name-parts">'+esc(s.tfrom)+'</span></span>'}
|
||||
function grossCell(s){
|
||||
if (s.tare == null) return '<span class="muted" title="Gross = tare + remaining. Tare is unknown, so gross is too.">unknown</span>';
|
||||
return '<b class="num">'+(s.tare+s.rem).toLocaleString()+' g</b><span class="name-parts">'+s.tare+' + '+s.rem+'</span>'}
|
||||
function remCell(s){
|
||||
var pct=Math.max(0,Math.min(100,s.rem/s.tot*100)); var low=s.rem<LOW;
|
||||
return '<div class="rem"><b class="num" style="'+(low?'color:var(--status-warning)':'')+'">'+(low?'⚠ ':'')+s.rem+'</b> <span class="muted">of '+s.tot+' g</span>'+
|
||||
(s.weighed?'<span class="name-parts">weighed on a scale</span>':'')+
|
||||
'<div class="track'+(low?' is-low':'')+'" role="progressbar" aria-valuenow="'+Math.round(pct)+'" aria-valuemin="0" aria-valuemax="100" aria-label="'+esc(s.color)+' remaining"><span style="width:'+pct+'%"></span></div></div>'}
|
||||
function value(s){return (s.rem/1000*s.kg)}
|
||||
function render(){
|
||||
var q=document.getElementById('q').value.trim().toLowerCase();
|
||||
var b=document.getElementById('brand').value, m=document.getElementById('mat').value, sort=document.getElementById('sort').value;
|
||||
var low=document.getElementById('t-low').getAttribute('aria-pressed')==='true';
|
||||
var store=document.getElementById('t-store').getAttribute('aria-pressed')==='true';
|
||||
var tare=document.getElementById('t-tare').getAttribute('aria-pressed')==='true';
|
||||
var list=SPOOLS.filter(function(s){
|
||||
var hay=(fullName(s)+' '+s.color+' '+s.loc).toLowerCase();
|
||||
return (!q||hay.indexOf(q)>=0)&&(!b||s.brand===b)&&(!m||s.mat===m)&&(!low||s.rem<LOW)&&(!store||s.store)&&(!tare||s.tare==null)});
|
||||
list.sort(function(a,z){return sort==='desc'?z.rem-a.rem:sort==='brand'?a.brand.localeCompare(z.brand):sort==='value'?value(z)-value(a):a.rem-z.rem});
|
||||
var rows=list.map(function(s,i){var idx=SPOOLS.indexOf(s);
|
||||
return '<tr class="'+(s.rem<LOW?'low-row':'')+'"><td><div class="colorcell">'+sw(s.c)+'<span><span class="name-main">'+esc(s.color)+' <span class="muted tiny">#'+(40+idx)+'</span></span><span class="fn">'+esc(fullName(s))+'</span>'+parts(s)+'</span></div></td>'+
|
||||
'<td class="r">'+remCell(s)+'</td><td class="r">'+tareCell(s)+'</td><td class="r">'+grossCell(s)+'</td>'+
|
||||
'<td class="r num">$'+value(s).toFixed(2)+'<span class="name-parts">$'+s.kg.toFixed(2)+'/kg</span></td>'+
|
||||
'<td class="loc">'+(s.store?'<span class="name-parts">In storage</span>':'<span class="chip chip-accent">loaded</span><br>')+esc(s.loc)+'</td>'+
|
||||
'<td class="r"><div class="acts"><a class="btn btn-sm" href="03-spool-form.html" aria-label="Edit '+esc(s.color)+', '+esc(s.brand)+'">Edit</a>'+
|
||||
'<button class="btn btn-sm btn-danger" type="button" data-del="'+idx+'" aria-label="Delete '+esc(s.color)+', '+esc(s.brand)+'">Delete</button></div></td></tr>'}).join('');
|
||||
document.getElementById('rows').innerHTML = rows || '<tr><td colspan="7"><div class="empty-note muted">No spools match that filter. <button class="btn btn-sm" type="button" id="clear">Clear filters</button></div></td></tr>';
|
||||
document.getElementById('cards').innerHTML = list.map(function(s){var idx=SPOOLS.indexOf(s);
|
||||
return '<article class="scard nm-raised"><div class="head">'+sw(s.c)+'<div><h3 style="margin:0;font-size:15px">'+esc(s.color)+'</h3><span class="name-parts">'+esc(fullName(s))+'</span></div></div>'+remCell(s)+
|
||||
'<dl><dt>Tare</dt><dd>'+tareCell(s)+'</dd><dt>Gross</dt><dd>'+grossCell(s)+'</dd><dt>Value</dt><dd>$'+value(s).toFixed(2)+'</dd><dt>Location</dt><dd>'+esc(s.loc)+'</dd></dl>'+
|
||||
'<div class="acts"><a class="btn btn-sm" href="03-spool-form.html">Edit</a><button class="btn btn-sm btn-danger" type="button" data-del="'+idx+'">Delete</button></div></article>'}).join('');
|
||||
document.getElementById('count').textContent = 'Showing '+list.length+' of 105 spools (sample page)';
|
||||
}
|
||||
document.addEventListener('input', function(e){ if (e.target.closest('.filters')) render(); });
|
||||
document.addEventListener('click', function(e){
|
||||
var t=e.target.closest('#t-low,#t-store,#t-tare');
|
||||
if (t){t.setAttribute('aria-pressed', String(t.getAttribute('aria-pressed')!=='true')); render(); return;}
|
||||
var v=e.target.closest('[data-view]');
|
||||
if (v){document.querySelectorAll('[data-view]').forEach(function(b){b.setAttribute('aria-pressed', String(b===v))});
|
||||
document.getElementById('view').className = v.getAttribute('data-view')==='cards'?'view-cards':'view-table'; return;}
|
||||
var d=e.target.closest('[data-del]');
|
||||
if (d){var s=SPOOLS[+d.getAttribute('data-del')]; document.getElementById('confirm-name').textContent=s.color+' · '+fullName(s);
|
||||
var dlg=document.getElementById('confirm'); if (dlg.showModal) dlg.showModal(); return;}
|
||||
if (e.target.id==='clear'){document.getElementById('q').value='';['t-low','t-store','t-tare'].forEach(function(id){document.getElementById(id).setAttribute('aria-pressed','false')});
|
||||
document.getElementById('brand').value='';document.getElementById('mat').value='';render();}
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', function(){ render(); if (matchMedia('(max-width: 640px)').matches) document.querySelectorAll('[data-view]').forEach(function(b){b.setAttribute('aria-pressed', String(b.getAttribute('data-view')==='cards'))}); });
|
||||
</script>
|
||||
@@ -0,0 +1,286 @@
|
||||
<style>
|
||||
.scenarios { padding: var(--space-4) var(--space-5); display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; }
|
||||
.form-wrap { position: relative; border-radius: var(--radius-xl); padding: var(--space-6); background: repeating-linear-gradient(135deg, transparent 0 14px, var(--accent-wash) 14px 15px); }
|
||||
.sheet { max-width: 880px; margin: 0 auto; padding: 0; overflow: hidden; }
|
||||
.sheet-head { display: flex; gap: var(--space-4); align-items: flex-start; padding: var(--space-6) var(--space-6) var(--space-4); border-bottom: 1px solid var(--border-hairline); }
|
||||
.sheet-head h2 { margin: 0; font-size: var(--fs-h2); }
|
||||
.sheet-body { padding: var(--space-5) var(--space-6); display: flex; flex-direction: column; gap: var(--space-6); }
|
||||
.sheet-foot { padding: var(--space-4) var(--space-6); border-top: 1px solid var(--border-hairline); display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; justify-content: flex-end; }
|
||||
fieldset.sec { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
fieldset.sec > legend { padding: 0; margin-bottom: var(--space-3); font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); font-weight: var(--fw-bold); }
|
||||
.fr { display: grid; gap: var(--space-4); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.field > .lab { display: flex; gap: var(--space-2); align-items: baseline; font-size: var(--fs-label); font-weight: var(--fw-bold); }
|
||||
.field > .lab em { font-style: normal; font-weight: var(--fw-regular); color: var(--text-secondary); font-size: var(--fs-caption); }
|
||||
.req { color: var(--text-accent); font-size: var(--fs-caption); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: var(--tracking-caps); }
|
||||
.hint { font-size: var(--fs-caption); color: var(--text-secondary); line-height: var(--lh-snug); }
|
||||
.has-error > .lab { color: var(--status-error); }
|
||||
.has-error > .lab::before { content: "⚠"; }
|
||||
.srv { font-size: var(--fs-caption); color: var(--text-secondary); }
|
||||
.namebar { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.slots { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-2); }
|
||||
.slot { padding: 8px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-control); background: var(--surface-raised); box-shadow: var(--nm-raised-1); display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.slot .k { font-size: var(--fs-caption); color: var(--text-secondary); text-transform: uppercase; letter-spacing: var(--tracking-caps); }
|
||||
.slot .v { font-weight: var(--fw-bold); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.slot.empty { border-style: dashed; box-shadow: none; background: transparent; }
|
||||
.slot.empty .v { color: var(--text-secondary); font-weight: var(--fw-regular); font-style: italic; }
|
||||
.fullname { font-size: var(--fs-h3); font-weight: var(--fw-display); }
|
||||
.weights { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: var(--space-5); align-items: start; }
|
||||
.scale { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.stackbar { display: flex; height: 26px; border-radius: var(--radius-pill); overflow: hidden; border: 1px solid var(--border-control); box-shadow: var(--nm-inset-1); background: var(--surface-inset); }
|
||||
.stackbar .t { background: repeating-linear-gradient(45deg, var(--border-strong) 0 5px, transparent 5px 10px); border-right: 2px solid var(--text-primary); }
|
||||
.stackbar .f { background: var(--text-accent); }
|
||||
.stackbar .u { background: transparent; }
|
||||
.eq { display: grid; grid-template-columns: auto 1fr auto; gap: 4px var(--space-3); font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.eq .tot { border-top: 1px solid var(--border-strong); padding-top: 4px; font-weight: var(--fw-bold); }
|
||||
.key { display: inline-block; width: 12px; height: 12px; border-radius: 3px; border: 1px solid var(--border-control); vertical-align: -1px; }
|
||||
.colors-row { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
|
||||
.conflict, .errsum { padding: var(--space-5); border: 2px solid var(--status-warning); border-radius: var(--radius-lg); background: var(--wash-warning); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.errsum { border-color: var(--status-error); background: var(--wash-error); }
|
||||
.conflict h3, .errsum h3 { margin: 0; font-size: var(--fs-body-lg); display: flex; gap: var(--space-2); align-items: center; }
|
||||
.conflict h3 { color: var(--status-warning); } .errsum h3 { color: var(--status-error); }
|
||||
.cmp th, .cmp td { padding: 8px 10px; }
|
||||
.cmp .mine { color: var(--text-accent); font-weight: var(--fw-bold); }
|
||||
.cmp .theirs { color: var(--status-warning); font-weight: var(--fw-bold); }
|
||||
.specimen { padding: var(--space-6); }
|
||||
.spec-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: var(--space-6) var(--space-5); align-items: start; }
|
||||
.spec { display: flex; flex-direction: column; gap: var(--space-3); align-items: flex-start; }
|
||||
.spec .cap { font-size: var(--fs-caption); color: var(--text-secondary); line-height: var(--lh-snug); }
|
||||
.spec .cap b { color: var(--text-primary); text-transform: uppercase; letter-spacing: var(--tracking-caps); }
|
||||
.btn.is-hover { border-color: var(--text-accent); }
|
||||
[data-only] { display: none; }
|
||||
body.s-conflict [data-only~="conflict"], body.s-errors [data-only~="errors"], body.s-add [data-only~="add"], body.s-tare [data-only~="tare"], body.s-edit [data-only~="edit"] { display: revert; }
|
||||
body.s-conflict .fr[data-only~="conflict"], body.s-errors .fr[data-only~="errors"] { display: grid; }
|
||||
body.s-conflict .conflict[data-only], body.s-errors .errsum[data-only] { display: flex; }
|
||||
@media (max-width: 760px) { .weights { grid-template-columns: 1fr; } .slots { grid-template-columns: repeat(2, minmax(0, 1fr)); } .form-wrap { padding: var(--space-2); } .sheet-head, .sheet-body, .sheet-foot { padding-left: var(--space-4); padding-right: var(--space-4); } }
|
||||
</style>
|
||||
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1>Add / Edit spool</h1>
|
||||
<p class="muted">Opens as a dialog over Filament. Pick a scenario to see each state.</p>
|
||||
</div>
|
||||
<a class="btn" href="02-filament.html">← Back to Filament</a>
|
||||
</header>
|
||||
|
||||
<section class="scenarios nm-raised" aria-label="Scenario">
|
||||
<span class="lbl-t small muted">Scenario</span>
|
||||
<div class="seg" role="group" aria-label="Form scenario" id="scen">
|
||||
<button type="button" data-s="conflict" aria-pressed="true">409 · changed since opened</button>
|
||||
<button type="button" data-s="errors" aria-pressed="false">400 · field errors</button>
|
||||
<button type="button" data-s="tare" aria-pressed="false">Tare unknown</button>
|
||||
<button type="button" data-s="add" aria-pressed="false">Add new</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-wrap">
|
||||
<section class="sheet nm-dialog" role="dialog" aria-labelledby="form-h" aria-modal="false">
|
||||
<div class="sheet-head">
|
||||
<span class="swatch" style="width:40px;height:40px"><i style="background:#1b1b1b"></i></span>
|
||||
<div style="flex:1;min-width:0">
|
||||
<h2 id="form-h"><span data-only="conflict errors tare edit">Edit spool #47</span><span data-only="add">Add spool</span></h2>
|
||||
<p class="small muted" style="margin:0"><span data-only="conflict errors tare edit">Opened 09:32 at version 4 · created 2 Aug · label printed 3 Aug</span><span data-only="add">New spools carry no version: nobody could have read one yet.</span></p>
|
||||
</div>
|
||||
<button class="btn btn-icon" type="button" aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="sheet-body">
|
||||
<!-- 409 -->
|
||||
<div class="conflict" role="alert" data-only="conflict">
|
||||
<h3>⚠ This spool changed since you opened it. Nothing was saved.</h3>
|
||||
<p style="margin:0">Someone weighed it at <b>09:39</b>, after you opened this form at 09:32 (version 4 → 5). Saving now would have put the old remaining weight back and lost that measurement.</p>
|
||||
<div class="table-wrap" style="margin:0;padding:0">
|
||||
<table class="tbl cmp">
|
||||
<thead><tr><th>Field</th><th class="r">When you opened (v4)</th><th class="r">Now (v5)</th><th class="r">Your edit</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Remaining <abbr title="net filament">net</abbr></td><td class="r num">860 g</td><td class="r num theirs">812 g · weighed</td><td class="r muted">not changed</td></tr>
|
||||
<tr><td>Empty spool (tare)</td><td class="r num">262 g</td><td class="r num">262 g</td><td class="r muted">not changed</td></tr>
|
||||
<tr><td>Location</td><td class="r">Shelf A3</td><td class="r">Shelf A3</td><td class="r mine">A1 · AMS Lite · slot 1</td></tr>
|
||||
<tr><td>Cost per kg</td><td class="r num">$22.79</td><td class="r num">$22.79</td><td class="r muted">not changed</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="small" style="margin:0">Your only change doesn't overlap theirs, so it's safe to reapply it to the new version.</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" type="button">Reload and reapply my location change</button>
|
||||
<button class="btn" type="button">Reload and discard my edit</button>
|
||||
</div>
|
||||
<p class="srv" style="margin:0">Server said (409): <code>this spool changed after you loaded it — reload it and reapply your edit, so a newer weigh-in is not overwritten (you loaded version 4, it is now version 5)</code></p>
|
||||
</div>
|
||||
|
||||
<!-- 400 summary -->
|
||||
<div class="errsum" role="alert" data-only="errors">
|
||||
<h3>⚠ 2 fields need fixing. Nothing was saved.</h3>
|
||||
<ul style="margin:0;padding-left:20px" class="small">
|
||||
<li><a href="#f-remaining">Remaining is more than the spool holds</a></li>
|
||||
<li><a href="#f-colorname">Color name is empty</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<fieldset class="sec">
|
||||
<legend>Name: 5 parts</legend>
|
||||
<div class="namebar nm-inset">
|
||||
<div class="slots" aria-label="Name parts">
|
||||
<div class="slot"><span class="k">Brand <span class="req">req</span></span><span class="v" id="sl-b">Sunlu</span></div>
|
||||
<div class="slot empty"><span class="k">Speed · opt</span><span class="v">none</span></div>
|
||||
<div class="slot"><span class="k">Material <span class="req">req</span></span><span class="v" id="sl-m">PETG</span></div>
|
||||
<div class="slot" id="sl-t"><span class="k">Type · opt</span><span class="v">CF</span></div>
|
||||
<div class="slot"><span class="k">Finish <span class="req">req</span></span><span class="v">Matte</span></div>
|
||||
</div>
|
||||
<div class="row"><span class="small muted">Reads as</span><span class="fullname">Sunlu PETG CF Matte</span><span class="chip chip-neutral">Black</span></div>
|
||||
</div>
|
||||
<div class="fr">
|
||||
<label class="field"><span class="lab">Brand <span class="req">required</span></span><select class="select"><option>Sunlu</option></select></label>
|
||||
<label class="field"><span class="lab">Print speed <em>optional</em></span><select class="select"><option>None</option><option>Rapid</option><option>High Speed</option></select></label>
|
||||
<label class="field"><span class="lab">Material <span class="req">required</span></span><select class="select"><option>PETG</option></select></label>
|
||||
<label class="field"><span class="lab">Filament type <em>optional</em></span><select class="select"><option>CF</option></select></label>
|
||||
<label class="field"><span class="lab">Finish <span class="req">required</span></span><select class="select"><option>Matte</option></select></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="sec">
|
||||
<legend>Color</legend>
|
||||
<div class="fr">
|
||||
<div class="field" id="f-colorname">
|
||||
<label class="lab" for="cn">Color name <span class="req">required</span></label>
|
||||
<input class="input" id="cn" value="Black" data-only="conflict tare add edit">
|
||||
<div data-only="errors" class="field has-error" style="gap:6px">
|
||||
<input class="input" value="" placeholder="e.g. Charcoal" aria-invalid="true" aria-describedby="cn-err">
|
||||
<p class="field-error" id="cn-err" style="margin:0">Give the color a name, even "Unknown". <span class="srv">(server: <code>color_name is required</code>)</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><span class="lab">Colors <em>1–3, in order</em></span>
|
||||
<div class="colors-row"><span class="swatch"><i style="background:#1b1b1b"></i></span><input class="input" value="#1b1b1b" style="max-width:120px" aria-label="Color 1 hex"><button class="btn btn-sm" type="button">+ Add color</button></div></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="sec">
|
||||
<legend>Weight: net filament vs what the scale reads</legend>
|
||||
<div class="weights">
|
||||
<div class="stack">
|
||||
<div class="fr">
|
||||
<label class="field"><span class="lab">Total <em>net, as sold</em></span><input class="input num" value="1000 g"><span class="hint">Filament only. A "1 kg spool" is 1000 g plus the spool.</span></label>
|
||||
<div class="field" id="f-remaining">
|
||||
<label class="lab" for="rem">Remaining <em>net</em></label>
|
||||
<input class="input num" id="rem" value="812 g" data-only="conflict tare edit">
|
||||
<input class="input num" value="600 g" data-only="add">
|
||||
<div data-only="errors" class="field has-error" style="gap:6px">
|
||||
<input class="input num is-error" value="1040 g" aria-invalid="true" aria-describedby="rem-err">
|
||||
<p class="field-error" id="rem-err" style="margin:0">1,040 g is more than this spool holds (1,000 g net). Lower remaining, or correct total. <span class="srv">(server: <code>remaining_grams cannot exceed total_grams</code>)</span></p>
|
||||
</div>
|
||||
<span class="hint" data-only="conflict edit">⚖ From a scale reading on 18 Sep 09:39, not typed</span>
|
||||
</div>
|
||||
</div>
|
||||
<label class="field"><span class="lab">Spool type <em>supplies the tare</em></span>
|
||||
<select class="select"><option>Sunlu — 1 kg plastic · 140 g · community</option><option>Elegoo — 1 kg cardboard · 154 g · manufacturer</option><option>Generic — 1 kg spool (assumed) · 180 g · estimated</option></select></label>
|
||||
<div class="field">
|
||||
<span class="lab">Empty spool (tare) <em>this reel</em></span>
|
||||
<div data-only="conflict errors edit">
|
||||
<input class="input num" value="262 g" aria-describedby="tare-src">
|
||||
<p class="hint" id="tare-src" style="margin:6px 0 0"><span class="chip chip-accent">this spool</span> Weighed bare on 14 Sep. It beats the spool type's 140 g (community).</p>
|
||||
</div>
|
||||
<div data-only="add">
|
||||
<input class="input num" value="140 g" aria-describedby="tare-add">
|
||||
<p class="hint" id="tare-add" style="margin:6px 0 0"><span class="chip chip-info">community</span> From the Sunlu — 1 kg plastic spool type. Measured by users and corroborated. Only saved on this spool if you weigh it.</p>
|
||||
</div>
|
||||
<div data-only="tare">
|
||||
<input class="input" value="" placeholder="unknown: type it, or pick a spool type" aria-describedby="tare-unk">
|
||||
<p class="hint" id="tare-unk" style="margin:6px 0 0"><span class="chip chip-warn chip-dashed">? unknown</span> No spool type chosen, and TECSONAR has no published tare. <b>Not treated as 0</b>: 0 would count the whole scale reading as filament.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scale nm-raised">
|
||||
<span class="lab-t small" style="font-weight:700">On the scale</span>
|
||||
<div data-only="conflict errors edit">
|
||||
<div class="stackbar" aria-hidden="true"><span class="t" style="width:20.8%"></span><span class="f" style="width:64.4%"></span><span class="u"></span></div>
|
||||
<div class="eq" style="margin-top:12px">
|
||||
<span><span class="key" style="background:repeating-linear-gradient(45deg,var(--border-strong) 0 3px,transparent 3px 6px)"></span></span><span>Empty spool (tare)</span><span>262 g</span>
|
||||
<span><span class="key" style="background:var(--text-accent)"></span></span><span>Filament remaining (net)</span><span>812 g</span>
|
||||
<span></span><span class="tot">Gross: should read</span><span class="tot">1,074 g</span>
|
||||
</div>
|
||||
<label class="field" style="margin-top:12px"><span class="lab">Weigh it <em>whole spool</em></span><input class="input num" placeholder="full reads 1,262 g"></label>
|
||||
<span class="hint">Type what the scale shows and remaining is worked out for you: reading − 262 g.</span>
|
||||
</div>
|
||||
<div data-only="add">
|
||||
<div class="stackbar" aria-hidden="true"><span class="t" style="width:12.3%"></span><span class="f" style="width:52.6%"></span><span class="u"></span></div>
|
||||
<div class="eq" style="margin-top:12px">
|
||||
<span><span class="key" style="background:repeating-linear-gradient(45deg,var(--border-strong) 0 3px,transparent 3px 6px)"></span></span><span>Empty spool (tare, community)</span><span>140 g</span>
|
||||
<span><span class="key" style="background:var(--text-accent)"></span></span><span>Filament remaining (net)</span><span>600 g</span>
|
||||
<span></span><span class="tot">Gross: should read</span><span class="tot">740 g</span>
|
||||
</div>
|
||||
<label class="field" style="margin-top:12px"><span class="lab">Weigh it <em>whole spool</em></span><input class="input num" placeholder="full reads 1,140 g"></label>
|
||||
<span class="hint">Weighing saves the 140 g tare on this spool, marked community, alongside the reading.</span>
|
||||
</div>
|
||||
<div data-only="tare">
|
||||
<div class="stackbar" aria-hidden="true" style="border-style:dashed"><span class="f" style="width:18.8%"></span></div>
|
||||
<div class="eq" style="margin-top:12px">
|
||||
<span></span><span>Empty spool (tare)</span><span class="muted">unknown</span>
|
||||
<span><span class="key" style="background:var(--text-accent)"></span></span><span>Filament remaining (net)</span><span>188 g</span>
|
||||
<span></span><span class="tot">Gross</span><span class="tot muted">unknown</span>
|
||||
</div>
|
||||
<label class="field" style="margin-top:12px"><span class="lab">Weigh it <em>whole spool</em></span><input class="input is-disabled" disabled placeholder="needs a tare first"></label>
|
||||
<span class="hint">Set an empty-spool weight (pick a spool type, or weigh the bare spool) and you can weigh the whole spool instead of guessing.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="sec">
|
||||
<legend>Cost and place</legend>
|
||||
<div class="fr">
|
||||
<label class="field"><span class="lab">Cost per kg</span><input class="input num" value="$22.79"></label>
|
||||
<label class="field"><span class="lab">Location <em>optional</em></span>
|
||||
<input class="input" value="Shelf A3" data-only="errors tare add">
|
||||
<input class="input" value="A1 · AMS Lite · slot 1" data-only="conflict edit" style="border-color:var(--text-accent)">
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="sheet-foot">
|
||||
<span class="small muted" style="margin-right:auto" data-only="conflict">Save is off until you choose how to handle the newer version.</span>
|
||||
<button class="btn" type="button">Cancel</button>
|
||||
<button class="btn btn-primary is-disabled" type="button" disabled data-only="conflict">Save changes</button>
|
||||
<button class="btn btn-primary" type="button" data-only="errors tare edit">Save changes</button>
|
||||
<button class="btn btn-primary" type="button" data-only="add">Add spool</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="specimen nm-raised" aria-labelledby="spec-h">
|
||||
<h2 class="section" id="spec-h">Control states: v3 specimen</h2>
|
||||
<p class="small muted" style="margin-top:0">Every state changes something other than shadow direction. Switch themes with the toggle to check both.</p>
|
||||
<div class="spec-grid">
|
||||
<div class="spec"><button class="btn" type="button">Save</button><span class="cap"><b>Rest</b><br>raised + 1px border (3:1)</span></div>
|
||||
<div class="spec"><button class="btn is-hover" type="button">Save</button><span class="cap"><b>Hover</b><br>border turns accent</span></div>
|
||||
<div class="spec"><button class="btn is-pressed" type="button">Low stock</button><span class="cap"><b>Pressed / on</b><br>inset + wash fill + ✓ + accent border</span></div>
|
||||
<div class="spec"><button class="btn is-focused" type="button">Save</button><span class="cap"><b>Focused</b><br>2px outline, 3px offset</span></div>
|
||||
<div class="spec"><button class="btn" disabled type="button">Save</button><span class="cap"><b>Disabled</b><br>flat + dashed border + muted text</span></div>
|
||||
<div class="spec"><button class="btn btn-primary" type="button">Add spool</button><span class="cap"><b>Primary</b><br>ink on light violet (6.14:1 dark)</span></div>
|
||||
<div class="spec"><button class="btn btn-primary is-pressed" type="button">Add spool</button><span class="cap"><b>Primary pressed</b><br>deeper fill + ✓ + ring</span></div>
|
||||
<div class="spec"><button class="btn btn-danger" type="button">Delete</button><span class="cap"><b>Danger</b><br>red text + red border</span></div>
|
||||
<div class="spec" style="width:100%"><input class="input" placeholder="Rest"><span class="cap"><b>Input rest</b><br>inset + border</span></div>
|
||||
<div class="spec" style="width:100%"><input class="input is-focused" value="Focused"><span class="cap"><b>Input focus</b><br>outline, border unchanged</span></div>
|
||||
<div class="spec" style="width:100%"><input class="input" disabled value="Disabled"><span class="cap"><b>Input disabled</b><br>dashed, flat</span></div>
|
||||
<div class="spec field has-error" style="width:100%;gap:6px"><input class="input" value="1040 g" aria-invalid="true"><p class="field-error" style="margin:0">Too much</p><span class="cap"><b>Input error</b><br>2px red + ⚠ + message</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
function set(s){document.body.className='s-'+s;
|
||||
document.querySelectorAll('#scen [data-s]').forEach(function(b){b.setAttribute('aria-pressed',String(b.getAttribute('data-s')===s))});
|
||||
var tareSheet = s==='tare';
|
||||
document.querySelector('.sheet-head .swatch').innerHTML = tareSheet ? '<i style="background:#161616"></i><i style="background:#c0232f"></i>' : '<i style="background:#1b1b1b"></i>';
|
||||
document.querySelector('.fullname').textContent = tareSheet ? 'TECSONAR PLA Matte' : 'Sunlu PETG CF Matte';
|
||||
document.getElementById('sl-b').textContent = tareSheet ? 'TECSONAR' : 'Sunlu';
|
||||
document.getElementById('sl-m').textContent = tareSheet ? 'PLA' : 'PETG';
|
||||
var t = document.getElementById('sl-t'); t.className = tareSheet ? 'slot empty' : 'slot'; t.querySelector('.v').textContent = tareSheet ? 'none' : 'CF';
|
||||
document.querySelector('.sheet-head p span').textContent = tareSheet ? 'Opened 09:32 at version 2 · created 2 Aug' : 'Opened 09:32 at version 4 · created 2 Aug · label printed 3 Aug';
|
||||
document.getElementById('form-h').firstElementChild.textContent = tareSheet ? 'Edit spool #44' : 'Edit spool #47';
|
||||
}
|
||||
document.addEventListener('click',function(e){var b=e.target.closest('#scen [data-s]'); if(b) set(b.getAttribute('data-s'));});
|
||||
document.addEventListener('DOMContentLoaded',function(){ set(location.hash==='#add'?'add':'conflict'); });
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,209 @@
|
||||
<style>
|
||||
.datasrc { padding: var(--space-4) var(--space-5); display: flex; flex-wrap: wrap; gap: var(--space-3) var(--space-4); align-items: center; }
|
||||
.group-h { display: flex; align-items: center; gap: var(--space-3); margin: 0 0 var(--space-4); font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--tracking-caps-wide); color: var(--text-secondary); font-weight: var(--fw-bold); }
|
||||
.group-h .n { display: inline-grid; place-items: center; min-width: 24px; height: 24px; border-radius: var(--radius-pill); background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-hairline); color: var(--text-primary); }
|
||||
.groups { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: var(--space-6); align-items: start; }
|
||||
.pgrid { display: grid; gap: var(--space-6); }
|
||||
.pcard { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.pcard.attn { border: 2px solid var(--status-warning); }
|
||||
.phead { display: flex; gap: var(--space-3); align-items: flex-start; }
|
||||
.phead h3 { margin: 0; font-size: var(--fs-h3); line-height: var(--lh-snug); }
|
||||
.phead .model { font-size: var(--fs-label); color: var(--text-secondary); }
|
||||
.cam { aspect-ratio: 16 / 9; border-radius: var(--radius-md); background: radial-gradient(ellipse at 50% 90%, #5a4b3a 0 28%, transparent 29%), linear-gradient(180deg, #1c1d22 0%, #2b2c33 60%, #3a3326 100%); border: 1px solid var(--border-control); box-shadow: var(--nm-inset-2); position: relative; overflow: hidden; }
|
||||
.cam::after { content: ""; position: absolute; left: 30%; right: 30%; bottom: 20%; height: 22%; background: repeating-linear-gradient(0deg, #6b8fd6 0 3px, #5a7cc2 3px 6px); border-radius: 4px 4px 0 0; opacity: .85; }
|
||||
.cam .stamp { position: absolute; left: 10px; top: 8px; font-size: var(--fs-caption); color: #f4f4f6; background: rgba(0, 0, 0, .55); padding: 2px 8px; border-radius: var(--radius-pill); z-index: 1; }
|
||||
.cam-off { min-height: 72px; border-radius: var(--radius-md); border: 1px dashed var(--border-control); display: grid; place-items: center; text-align: center; padding: var(--space-4); color: var(--text-secondary); font-size: var(--fs-label); }
|
||||
.nowjob { padding: var(--space-4); display: grid; grid-template-columns: 1fr auto; gap: 2px var(--space-3); }
|
||||
.nowjob .jn { font-weight: var(--fw-bold); }
|
||||
.slots4 { display: grid; gap: var(--space-2); }
|
||||
.cont { padding: var(--space-3) var(--space-4); }
|
||||
.cont-h { display: flex; justify-content: space-between; font-size: var(--fs-label); margin-bottom: var(--space-2); }
|
||||
.slotrow { display: grid; grid-template-columns: 22px auto minmax(0, 1fr) auto; gap: var(--space-2) var(--space-3); align-items: center; font-size: 13px; padding: 4px 0; }
|
||||
.slotrow .i { color: var(--text-secondary); font-size: var(--fs-caption); }
|
||||
.slotrow.empty .nm { color: var(--text-secondary); font-style: italic; }
|
||||
.conn { display: grid; grid-template-columns: auto 1fr; gap: 4px var(--space-4); font-size: var(--fs-label); margin: 0; }
|
||||
.conn dt { color: var(--text-secondary); }
|
||||
.conn dd { margin: 0; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||
details.cfg { border-top: 1px solid var(--border-hairline); padding-top: var(--space-3); }
|
||||
details.cfg summary { cursor: pointer; font-size: var(--fs-label); font-weight: var(--fw-bold); }
|
||||
details.cfg summary:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; border-radius: 4px; }
|
||||
.lost { padding: var(--space-4); border-radius: var(--radius-md); border: 1px solid var(--status-warning); background: var(--wash-warning); display: flex; flex-direction: column; gap: var(--space-2); font-size: 13px; }
|
||||
.lost h4 { margin: 0; color: var(--status-warning); font-size: 14px; }
|
||||
.lost ul { margin: 0; padding-left: 18px; }
|
||||
.stale { opacity: 1; border-style: dashed !important; }
|
||||
.stale-tag { font-size: var(--fs-caption); color: var(--status-warning); font-weight: var(--fw-bold); }
|
||||
.pulse { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--status-success); box-shadow: 0 0 0 3px var(--wash-success); }
|
||||
.future { padding: var(--space-6); border: 2px dashed var(--border-control); border-radius: var(--radius-lg); background: transparent; }
|
||||
.future h2 { margin: 0; font-size: var(--fs-h3); display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
|
||||
.fgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: var(--space-4); margin-top: var(--space-5); }
|
||||
.fitem { padding: var(--space-4); border: 1px dashed var(--border-control); border-radius: var(--radius-md); display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.fitem h3 { margin: 0; font-size: 14px; }
|
||||
.fitem .why { font-size: var(--fs-caption); color: var(--text-secondary); line-height: var(--lh-snug); }
|
||||
.ghost { opacity: .9; filter: grayscale(1); pointer-events: none; }
|
||||
body.dev-only [data-feature] { display: none !important; }
|
||||
body:not(.dev-only) [data-devonly] { display: none !important; }
|
||||
</style>
|
||||
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1>Printers</h1>
|
||||
<p class="muted">3 printers · status polled every 10 s · MQTT is read-only</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" type="button">Manage AMS units</button>
|
||||
<button class="btn btn-primary" type="button">+ Add printer</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="datasrc nm-raised" aria-label="Data source">
|
||||
<span class="small"><b>Data shown</b></span>
|
||||
<div class="seg" role="group" aria-label="Which build's data to show" id="ds">
|
||||
<button type="button" data-ds="dev" aria-pressed="false">dev today</button>
|
||||
<button type="button" data-ds="feat" aria-pressed="true">dev + printer-connections branch</button>
|
||||
</div>
|
||||
<span class="small muted">Fields tagged <span class="chip chip-info chip-dashed">feature</span> exist only on <code>feature/7efc2030…-printer-connections</code> (4e17f9e), not in production.</span>
|
||||
</section>
|
||||
|
||||
<div class="groups">
|
||||
<section aria-labelledby="g-progress">
|
||||
<h2 class="group-h" id="g-progress">In progress <span class="n">1</span></h2>
|
||||
<div class="pgrid">
|
||||
<article class="pcard nm-raised" aria-labelledby="p1">
|
||||
<div class="phead">
|
||||
<div style="flex:1;min-width:0"><h3 id="p1">P1S - Prints Charming</h3><span class="model">Bambu Lab · P1S</span></div>
|
||||
<span class="chip chip-accent">● Printing</span>
|
||||
<button class="btn btn-icon btn-sm" type="button" aria-label="Actions for P1S - Prints Charming" aria-haspopup="menu">⋯</button>
|
||||
</div>
|
||||
<div class="row small" data-feature><span class="pulse" aria-hidden="true"></span><span>Ready · last report <b>2s ago</b></span><span class="chip chip-info chip-dashed">feature</span></div>
|
||||
<div class="row small" data-devonly><span class="pulse" aria-hidden="true"></span><span>Connected</span></div>
|
||||
<div class="cam" role="img" aria-label="Camera snapshot from P1S - Prints Charming"><span class="stamp">snapshot 09:41:02</span></div>
|
||||
<div class="nowjob nm-inset">
|
||||
<span class="jn">Gridfinity_bins_3x2</span><span class="num">1h 04m</span>
|
||||
<span class="small muted">started 08:37 · weight read from the 3MF when it ends</span><span class="small muted">so far</span>
|
||||
</div>
|
||||
<div class="slots4" aria-label="Filament loaded in P1S - Prints Charming">
|
||||
<div class="row"><span class="small"><b>Loaded filament</b></span><span class="chip chip-neutral">not confirmed in use</span></div>
|
||||
<div class="cont nm-inset">
|
||||
<div class="cont-h"><b>AMS 1</b><span class="muted">4 / 4</span></div>
|
||||
<div class="slotrow"><span class="i">1</span><span class="swatch swatch-sm"><i style="background:#141414"></i></span><span class="nm">Sunlu · Black <span class="muted">PLA + 2.0</span></span><span class="num">1000 g</span></div>
|
||||
<div class="slotrow"><span class="i">2</span><span class="swatch swatch-sm"><i style="background:#b87333"></i></span><span class="nm">Sunlu · Copper <span class="muted">PLA + Silk</span></span><span class="num" style="color:var(--status-warning)">⚠ 97 g</span></div>
|
||||
<div class="slotrow"><span class="i">3</span><span class="swatch swatch-sm"><i style="background:#ece8df"></i></span><span class="nm">Overture · Mist Gray <span class="muted">PLA Marble</span></span><span class="num">920 g</span></div>
|
||||
<div class="slotrow"><span class="i">4</span><span class="swatch swatch-sm"><i style="background:#101010"></i></span><span class="nm">JAYO · Black <span class="muted">Rapid PETG Matte</span></span><span class="num">655 g</span></div>
|
||||
</div>
|
||||
<div class="cont nm-inset">
|
||||
<div class="cont-h"><b>External</b><span class="muted">0 / 1</span></div>
|
||||
<div class="slotrow empty"><span class="i">1</span><span></span><span class="nm">Empty</span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="cfg"><summary>Connection details (stored)</summary>
|
||||
<dl class="conn" style="margin-top:8px">
|
||||
<dt>IP</dt><dd>10.60.1.242</dd><dt>Serial</dt><dd>01P09A••••••4821</dd><dt>Access code</dt><dd>✓ stored, never shown</dd><dt>Added</dt><dd>7 Aug 2026</dd>
|
||||
<dt data-feature>Session</dt><dd data-feature>#3 <span class="chip chip-info chip-dashed">feature</span></dd>
|
||||
</dl></details>
|
||||
<p class="small muted" style="margin:0">Last finished: Lid_v3 · failed yesterday 16:49</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="g-attn">
|
||||
<h2 class="group-h" id="g-attn">Needs attention <span class="n">1</span></h2>
|
||||
<div class="pgrid">
|
||||
<article class="pcard nm-raised attn" aria-labelledby="p3">
|
||||
<div class="phead">
|
||||
<div style="flex:1;min-width:0"><h3 id="p3">A1 - Jordyns Layer Slayer</h3><span class="model">Bambu Lab · A1</span></div>
|
||||
<span class="chip chip-warn" data-feature>⟳ Reconnecting</span>
|
||||
<span class="chip chip-warn" data-devonly>⚡ Offline</span>
|
||||
<button class="btn btn-icon btn-sm" type="button" aria-label="Actions for A1 - Jordyns Layer Slayer" aria-haspopup="menu">⋯</button>
|
||||
</div>
|
||||
|
||||
<div class="lost" role="status">
|
||||
<h4>⚡ MQTT connection lost</h4>
|
||||
<span data-feature>Disconnected, reconnecting. Last contact <b>6m ago</b> (09:35:11). <span class="chip chip-info chip-dashed">feature</span></span>
|
||||
<span data-devonly>Extrudex's MQTT session to this printer dropped. The time it dropped isn't recorded on dev.</span>
|
||||
<ul class="small">
|
||||
<li>The printer itself may still be printing. This is Extrudex's connection, not the printer's power.</li>
|
||||
<li><b>Print capture is paused.</b> A job that starts and finishes during the outage won't be recorded.</li>
|
||||
<li>On reconnect: if <i>Coaster_set</i> is still running, its record stays open and gets <b>+1 gap</b> (its duration becomes an upper bound). Otherwise it closes as <b>unknown</b>, with no duration.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="nowjob nm-inset stale" style="border:1px dashed var(--status-warning)">
|
||||
<span class="jn">Coaster_set</span><span class="stale-tag">OPEN RECORD</span>
|
||||
<span class="small muted">started 08:55 · watched until the connection dropped</span><span></span>
|
||||
<span class="small" data-feature style="grid-column:1/-1"><span class="stale-tag">Last known:</span> Printing · 6m ago. Cached from the lost session, not live. <span class="chip chip-info chip-dashed">feature</span></span>
|
||||
</div>
|
||||
<div class="cam-off">Camera unavailable while the printer is offline</div>
|
||||
<div class="slots4" aria-label="Filament loaded in A1 - Jordyns Layer Slayer">
|
||||
<div class="row"><span class="small"><b>Loaded filament</b></span><span class="chip chip-neutral">from Extrudex records, not the printer</span></div>
|
||||
<div class="cont nm-inset">
|
||||
<div class="cont-h"><b>AMS Lite</b><span class="muted">2 / 4</span></div>
|
||||
<div class="slotrow"><span class="i">1</span><span class="swatch swatch-sm"><i style="background:#1b1b1b"></i></span><span class="nm">Sunlu · Black <span class="muted">PETG CF Matte</span></span><span class="num">812 g</span></div>
|
||||
<div class="slotrow"><span class="i">2</span><span class="swatch swatch-sm"><i style="background:#e8453c"></i><i style="background:#f2c230"></i><i style="background:#2a8fd6"></i></span><span class="nm">AMOLEN · Red Yellow Blue <span class="muted">PLA Matte</span></span><span class="num">744 g</span></div>
|
||||
<div class="slotrow empty"><span class="i">3</span><span></span><span class="nm">Empty</span><span></span></div>
|
||||
<div class="slotrow empty"><span class="i">4</span><span></span><span class="nm">Empty</span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="cfg" open><summary>Connection details (stored)</summary>
|
||||
<dl class="conn" style="margin-top:8px">
|
||||
<dt>IP</dt><dd>10.60.1.x <span class="muted">(sample)</span></dd><dt>Serial</dt><dd>039••••••••7710</dd><dt>Access code</dt><dd>✓ stored, never shown</dd>
|
||||
<dt data-feature>Lifecycle</dt><dd data-feature>reconnecting · <code>stale: true</code> <span class="chip chip-info chip-dashed">feature</span></dd>
|
||||
<dt data-feature>Failure</dt><dd data-feature><span class="muted">none reported. A reconnect can have no failure detail.</span></dd>
|
||||
<dt data-feature>Session</dt><dd data-feature>#14 (increments on every reconnect)</dd>
|
||||
</dl></details>
|
||||
<div class="actions"><button class="btn btn-sm" type="button">Edit connection</button><span class="small muted">Retrying automatically. There's nothing to press.</span></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="g-idle">
|
||||
<h2 class="group-h" id="g-idle">Idle <span class="n">1</span></h2>
|
||||
<div class="pgrid">
|
||||
<article class="pcard nm-raised-sm" aria-labelledby="p2" style="padding: var(--space-5)">
|
||||
<div class="phead">
|
||||
<div style="flex:1;min-width:0"><h3 id="p2">H2C - Constipation Orion</h3><span class="model">Bambu Lab · H2C</span></div>
|
||||
<span class="chip chip-neutral">Idle</span>
|
||||
<button class="btn btn-icon btn-sm" type="button" aria-label="Actions for H2C - Constipation Orion" aria-haspopup="menu">⋯</button>
|
||||
</div>
|
||||
<div class="row small"><span class="pulse" aria-hidden="true"></span><span data-feature>Ready · last report 4s ago <span class="chip chip-info chip-dashed">feature</span></span><span data-devonly>Connected</span></div>
|
||||
<p class="small" style="margin:0">Last: <b>Benchy_x4</b> · finished 05:27 · <span style="color:var(--status-warning);font-weight:700">≤ 3h 12m</span> <span class="chip chip-warn chip-dashed">upper bound · 1 gap</span></p>
|
||||
<div class="cont nm-inset">
|
||||
<div class="cont-h"><b>AMS 1</b><span class="muted">3 / 4</span></div>
|
||||
<div class="slotrow"><span class="i">1</span><span class="swatch swatch-sm"><i style="background:#141414"></i></span><span class="nm">Elegoo · Black <span class="muted">Rapid PETG</span></span><span class="num" style="color:var(--status-warning)">⚠ 142 g</span></div>
|
||||
<div class="slotrow"><span class="i">2</span><span class="swatch swatch-sm"><i style="background:#1d4fa8"></i></span><span class="nm">Elegoo · Blue <span class="muted">Rapid PETG</span></span><span class="num">1000 g</span></div>
|
||||
<div class="slotrow"><span class="i">3</span><span class="swatch swatch-sm"><i style="background:#f4f4f0"></i></span><span class="nm">Elegoo · White <span class="muted">PLA Matte</span></span><span class="num">610 g</span></div>
|
||||
<div class="slotrow empty"><span class="i">4</span><span></span><span class="nm">Empty</span><span></span></div>
|
||||
</div>
|
||||
<details class="cfg"><summary>Connection details (stored)</summary>
|
||||
<dl class="conn" style="margin-top:8px"><dt>IP</dt><dd>10.60.1.202</dd><dt>Serial</dt><dd>31B••••••••0093</dd><dt>Access code</dt><dd>✓ stored, never shown</dd></dl></details>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<section class="future" aria-labelledby="future-h">
|
||||
<h2 id="future-h"><span class="chip chip-neutral chip-dashed">Future, not built</span> Ideas that need data Extrudex doesn't store</h2>
|
||||
<p class="small muted" style="margin:var(--space-2) 0 0">Nothing in this box can be built from today's data. Each item says what's missing.</p>
|
||||
<div class="fgrid">
|
||||
<div class="fitem"><h3>Progress % and time left</h3><div class="track ghost" aria-hidden="true"><span style="width:58%"></span></div>
|
||||
<span class="why"><code>mc_percent</code> is parsed from MQTT but used only internally. It isn't stored or returned by <code>/api/printers</code>. ETA isn't read at all.</span></div>
|
||||
<div class="fitem"><h3>Nozzle and bed temperature</h3><span class="ghost small">220 °C · 60 °C</span>
|
||||
<span class="why">Temperatures aren't parsed from the report.</span></div>
|
||||
<div class="fitem"><h3>Current layer</h3><span class="ghost small">Layer 112 / 190</span>
|
||||
<span class="why">Layer fields aren't parsed.</span></div>
|
||||
<div class="fitem"><h3>AMS remaining % and humidity</h3><span class="ghost small">82% · humidity 2</span>
|
||||
<span class="why">Not read. AMS <code>remain</code> is only meaningful for genuine RFID spools, which isn't what's mostly run here.</span></div>
|
||||
<div class="fitem"><h3>Alert when MQTT drops</h3><span class="ghost small">🔔 notify after 5 min</span>
|
||||
<span class="why">Drops only show in container logs today (CONTEXT open work #4). On dev, nothing stores <i>when</i> it dropped.</span></div>
|
||||
<div class="fitem"><h3>Which spool is feeding now</h3><span class="ghost small">▶ AMS 1 · slot 2</span>
|
||||
<span class="why">"Loaded" is recorded; "in use" isn't. That's why the list says <i>not confirmed in use</i>.</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
function set(v){document.body.classList.toggle('dev-only', v==='dev');
|
||||
document.querySelectorAll('#ds [data-ds]').forEach(function(b){b.setAttribute('aria-pressed',String(b.getAttribute('data-ds')===v))});}
|
||||
document.addEventListener('click',function(e){var b=e.target.closest('#ds [data-ds]'); if(b) set(b.getAttribute('data-ds'));});
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user