Files
Donkie e09d63b443 client_v2: accept decimal commas in every numeric field
Most of the world writes 1,75 rather than 1.75, and `<input type="number">`
only accepts the separator the browser locale dictates: everything else it
reports as an empty value. A user typing a comma therefore watched a weight
or a diameter silently come out blank or, once the separator was dropped,
a hundred times too large. The same input type also accepts "e", "+" and "-"
anywhere in the text, since they are legal exponent syntax, and then reports
the whole value as empty too, so "1e" read back as nothing at all.

Numeric fields are now text inputs with `inputmode="decimal"`, guarded by a
shared `numericInput` action that filters at the keystroke: both separators
go in, letters cannot, and a pasted "12,5 g" is cleaned rather than refused.
Reading is centralised in `parseDecimal`, which takes either separator and
returns null instead of NaN for text that is not a number, so an unset field
stays distinguishable from a real 0. What the parent, and the API, receive is
always canonical.

The typed separator stays on screen: each field keeps a draft of the text and
gives it up only when the value moves somewhere that text no longer means, so
storing 1.5 does not rewrite a typed "1,5" between keystrokes. Arrow-key
stepping, which was native to the old input type, is now the component's own.

Covers the spool/filament/vendor inspectors, the add-spool modal (whose price
box was a bare text input accepting anything at all), extra fields of every
numeric type, the extra-field editor's order box and the low-stock threshold.
2026-08-08 19:42:39 +02:00
..
2026-08-08 06:43:28 +00:00
2026-08-06 18:44:00 +00:00
2026-08-06 18:44:00 +00:00

Spoolman client v2

A ground-up rewrite of the Spoolman frontend using Svelte 5 + SvelteKit, replacing the React / Refine / Ant Design stack in client/.

Status: connected to the real Spoolman API. src/lib/api/spoolSource.ts talks to /api/v1 over HTTP and src/lib/api/live.ts opens the real WebSocket channels; src/lib/api/map.ts maps the API shape to the client's domain types. Reads (grouped + flat lists, inspector details), writes (spool / filament / vendor edits, adjust weight, archive, add spools, drag-to-relocate) and live updates all round-trip against the backend.

Dev: point the client at a running backend with VITE_APIURL, e.g. VITE_APIURL=http://localhost:8000/api/v1 npm run dev. In production the backend serves this SPA same-origin, so VITE_APIURL is unset and the base resolves to <SPOOLMAN_BASE_PATH>/api/v1.

Settings (currency / round_prices / external URL) round-trip to /api/v1/setting; the low-stock threshold is a local per-browser preference. Extra fields have full parity with the original frontend: manage definitions per entity (all 8 types, key/type/multi_choice immutable after create, append-only choices) in Settings, and edit values inline on each inspector via type-aware inputs (ExtraFieldInput).

Add spools is a single unified flow that creates spool + filament + vendor as needed. It searches the local catalog and SpoolmanDB (/api/v1/external/filament); a picked SpoolmanDB filament is imported (with its vendor, de-duplicated by external_id). "Create a new filament" expands the same modal inline: a vendor combobox reuses an existing vendor or creates a new one (with a live hint), and picking a material auto-fills density/temps from SpoolmanDB material presets. One submit creates whatever doesn't yet exist. Imported filaments show a "SpoolmanDB" badge in the inspector.

Not yet wired: library search is a filament-name match (the API has no combined free-text or color-similarity search); status/diameter filters were dropped as the API can't express them.

Data flow (server does the work)

Filtering, sorting, searching, aggregation and pagination are the server's job — the client never sifts the full dataset. The list paginates groups, not spools, so a group is atomic on a page (no splitting) and its header totals are server-accurate. See docs/backend-api-proposal.md for the endpoints this assumes.

UI state (filters/sort/group/search/page)
        │
   grouped mode (group≠none, not a color search):
        │  buildGroupQuery()                     api/query.ts
        ▼
   GroupQuery ── listGroups() ──▶  Page<GroupSummary>       api/spoolSource.ts
        ▼                          (key, title, counts, total_remaining,
   FilamentList                     has_stock, last_used — server aggregates)
        └─ GroupRow (per group)
              │ buildScopedSpoolQuery(group, limit)   ← lazily loads the
              ▼                                          group's spools,
           listSpools()  ── Page<Spool> ─▶ rows          in-use first, "show more"

   flat mode (group=none OR color search):
        │  buildFlatSpoolQuery()
        ▼
   SpoolQuery ── listSpools() ──▶ Page<Spool> ─▶ flat rows (no split problem)

   Pagination  ── paginates groups (grouped) or spools (flat)
  • Grouped: listGroups returns a page of group summaries; each GroupRow fetches its own spools (filament.id / vendor.id / location / material scope). "Sort:" orders both the groups (by aggregate) and the rows within.
  • Flat (group=none or a color search): plain spool pagination — no groups to split. Color search orders by similarity.
  • Pagination is classic limit/offset + X-Total-Count. Any query change resets to page 1.

Live updates (WebSocket)

Everything viewed is subscribed to and live-updates. The seam is api/live.ts (LiveConnection.subscribe(resource, {id?}, handler) — mirrors ws://…/api/v1/<resource>[/<id>]):

  • inventory is a reactive normalized cache. api/liveSync.ts runs one central subscription per resource and applies remote events to it, so every cache-backed view (inspectors, locations) is live automatically.
  • The paginated list fetches server-paged data outside the cache, so it keeps its own subscription and refetches the current page on relevant events.
  • Local edits patch the cache and emit a local event (mirroring the server echo). Try it in dev: __spoolman.bumpId(103, 120) in the console injects a remote update — the inspector and list both update instantly.

Design

The look is ported from the design prototype (Spoolman Prototype.dc.html / Spoolman Mobile.dc.html): a dark theme, IBM Plex type, warm-orange (#be682f) accent, and a dense two-pane Library (list + inspector). Design tokens live as CSS custom properties in src/app.css.

Responsiveness

  • Desktop — two panes: the filament/spool list on the left, a live inspector on the right.
  • Mobile — the list becomes full-width and front-and-centre; selecting an item slides the same inspector up as a bottom-sheet Drawer (src/lib/components/Drawer.svelte). The top-bar search/nav reflow onto their own rows and the "Add spools" button becomes a FAB.

The breakpoint is 860px.

Structure

src/
  app.css                     design tokens + resets
  routes/
    +layout.svelte            top bar + add-spools modal shell
    +page.svelte              Library (list + inspector / drawer)
    locations/+page.svelte    drag-and-drop shelf grid
    settings/+page.svelte     server-settings style sections
  lib/
    types.ts                  domain types
    api/
      config.ts               API base + WebSocket URL resolution (VITE_APIURL)
      http.ts                 fetch wrappers (list + X-Total-Count, GET/PATCH/POST)
      map.ts                  API JSON ↔ domain types (colors, rel-time, patches)
      types.ts                GroupQuery/SpoolQuery/Page/SpoolDataSource contract
      query.ts                UI state → queries (sort/group field maps)
      spoolSource.ts          HTTP client: /spool/group, /spool (+ scoping),
                              writes, filter-option lists; populates the cache
      live.ts                 real WebSockets to /api/v1/{spool,filament,vendor}
      liveSync.ts             central live events → cache
    stores/                   inventory (reactive cache), settings, ui (runes)
    utils/                    color, format, saver (debounced writes), library
    components/               Swatch, ProgressBar, Button, Toggle, TopBar,
                              Pagination, library/* (GroupRow, …), Drawer, …
  docs/
    backend-api-proposal.md   the grouped/aggregated endpoint (implemented)

How the backend is wired

  • Reads — api/spoolSource.ts hits GET /api/v1/spool/group (grouped) and GET /api/v1/spool (flat / group-scoped), both limit/offset + X-Total-Count. Filter option lists come from /material, /location, /lot-number, /vendor. Every fetched entity is upserted into the cache.
  • Writes — inline edits PATCH /spool|/filament|/vendor/{id} (debounced, optimistic); adjust-weight and archive PATCH the spool; Add-spools POSTs /spool; drag-to-relocate PATCHes location.
  • Live — api/live.ts opens one WebSocket per resource; liveSync applies events to the cache, so cache-backed views update instantly and the paginated list refetches. api/map.ts maps every payload to domain types.

Develop

npm install
npm run dev      # http://localhost:5174
npm run check    # svelte-check (type gate)
npm run build    # static SPA build (adapter-static, index.html fallback)

Mobile accessibility audit

npm run audit:a11y          # Playwright + axe-core under Pixel 5 emulation
npm run audit:a11y:report   # open the last HTML report

Runs axe-core (WCAG 2.0/2.1 A+AA plus the 2.2 target-size rule) and a custom tap-target measurement across the main surfaces (library, locations, labels, settings, add-spool modal, inspector drawer). Reuses a vite dev already on 5174, or starts one. Human-readable results land in a11y-report.md; the tap-target advisory minimum is 44px (Apple HIG / Material), stricter than the WCAG 24px floor. See e2e/a11y-mobile.spec.ts.