Commit Graph
1341 Commits
Author SHA1 Message Date
Donkie 3a7ec376a2 docs: add authentication & authorization design plan
Design proposal for an opt-in auth system on top of client_v2: local
password login, generic OIDC, API keys, optional TOTP 2FA, and optional
mTLS-as-identity behind a reverse proxy. Three permission levels
(read/edit/manage) plus orthogonal admin/owner flags.

Nothing is implemented yet; this records the agreed shape and the
decisions behind it so the five delivery phases can be picked up
independently.

Placed at the repo root because .gitignore:75 carries a blanket docs/
rule from the Python template.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-27 09:55:14 +02:00
Donkie 06ae678586 client_v2: stop idle-tab data/CPU drain (WS backoff + coalesced refetch)
A tab left open would rack up gigabytes of transfer and heavy lag over
time. Two causes, both fixed:

- live.ts retried a dropped backend every fixed 2s forever (x3 sockets,
  no backoff). Each failed connect is a console error the browser can't
  GC, so an overnight tab bloated memory and janked. Now backs off
  1s->30s with jitter, resets on connect, and reconnects immediately on
  'online' / tab-visible (laptop sleep/wake). Measured churn ~15x lower.

- Every live event refetched the group list AND every visible GroupRow
  (~21-31 uncached requests) once per event. A burst (bulk import,
  printer streaming use-updates, initial sync) detonated that fan-out
  per event. A 300ms debounce collapses each burst into one refetch
  cycle (~10x fewer requests on a 20-event burst).
2026-07-26 20:37:27 +02:00
Donkie bf3896b1f2 client_v2: fix nested-interactive a11y on add-spool modal
The overlay was role=button wrapping the whole dialog, so screen readers
announced every control as nested inside one button. Make the click-outside
catcher a sibling <button> behind the modal instead of a parent, and move
Escape-to-close to a window keydown listener.
2026-07-26 16:30:55 +02:00
Donkie 1d5164b629 client_v2: label form controls for a11y (select-name/label)
Add aria-labels to the Settings language select, currency/base-URL/
low-stock inputs, and the Labels toolbar + preview-bar selects, which
axe-core flagged as unnamed (WCAG select-name/label, critical) under the
mobile a11y audit. Adds a labels.selectDesign message key.
2026-07-26 16:22:46 +02:00
Donkie cd5ef6f8d6 client_v2: close Combobox dropdown on blur (tab-away)
The suggestion list only closed on Escape, outside pointerdown, or
selection, so tabbing focus out of the input left the portaled dropdown
open. Add an onblur handler that closes the list, guarding against blurs
that hand focus into the list itself.
2026-07-26 10:34:28 +02:00
Donkie f6827c0a69 client_v2: improve mobile tap targets and inspector actions
Address the pervasive sub-44px tap-target audit finding and related
mobile UX issues:

- Add a centralized 44px touch-target layer in app.css (gated on the
  existing <=860px mobile breakpoint) raising the shared Button/tab/chip/
  select/etc. primitives; grow TopBar scan/add to 44px and the number
  steppers so they clear the WCAG 24px floor. Audit: 148 -> 46 sub-44px.
- Collapse the mobile search into an icon in the top row that expands to a
  full-width overlay on tap, dropping the top bar from three rows to two.
  Give the search toggle the same outlined look as the scan button.
- Fix breadcrumb links not vertically centering inside their 44px tap area.
- Stop hiding the spool/filament inspector action rows on narrow panels;
  reflow them onto their own row, and drop to icon-only (labels kept in the
  a11y tree) below 560px so three actions stay on a single row.
2026-07-25 21:22:47 +02:00
Donkie 18214ee0f8 client_v2: fix low-contrast text/accent tokens for WCAG AA
The mobile a11y audit reported color-contrast (serious) on ~11-17 nodes
per page, the nav tabs being the recurring offender. Every flagged node
routed through a design token, so retune the tokens rather than patch
components: --text-muted/--text-dim/--text-faint and light --accent-soft
now clear 4.5:1 on the surfaces they sit on in both themes (down to the
lightest dark surface and darkest light surface), keeping the
muted->dim->faint ramp monotonic. --text-2 already passed and is
unchanged.
2026-07-25 20:30:59 +02:00
Donkie 386ec36d1f client_v2: set <html lang>/<dir> to the resolved locale at runtime
app.html shipped `%paraglide.lang%`/`%paraglide.dir%` placeholders, but SSR
is off and there is no handle hook, so they were never substituted — axe
reported html-lang-valid (serious) on every page from the literal value.

Ship a valid `lang="en" dir="ltr"` static default and apply the real locale
(getLocale/getTextDirection) to <html> in +layout.svelte at mount, alongside
where the theme is applied. Verified in-browser: en→ltr, de→ltr, fa→rtl.

Also remove the unused Locale class in paraglide.svelte.ts (dead code, never
imported).
2026-07-25 20:16:20 +02:00
Donkie 8a5cf7ec73 client_v2: add mobile accessibility audit (Playwright + axe-core)
Adds an npm run audit:a11y target that 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 under Pixel 5 emulation, reusing the dev server on 5174.
Results are aggregated to a11y-report.md via globalTeardown so they survive
Playwright worker restarts.
2026-07-25 19:07:24 +02:00
Donkie b7772ca743 client_v2: fix base path for direct loads of non-prerendered routes
SvelteKit's adapter-static emits the SPA fallback (200.html) with absolute
asset paths (/_app/...) and a hardcoded base: "", since it can't know the
deploy base path at build time. Prerendered per-route documents use relative
paths and a runtime-computed base, so they work under any base path, but the
fallback is used for non-prerendered dynamic routes (/spool/show/<id>,
/filament/show/<id>) -- exactly the URLs printed into QR labels. Under a base
path those direct loads 404'd every asset and rendered a blank page.

Rewrite the fallback server-side using the base path the backend already
knows, mirroring the legacy-client asset rewrite: prefix /_app/ and /favicon
references and set the runtime base. Prerendered docs are still served
verbatim, and the legacy client is unaffected (its tweaked_html is always
produced when rewrite_asset_paths is on, so the file_response guard change is
equivalent there).
2026-07-25 17:49:11 +02:00
Donkie 2f9259227d client_v2: drop "g each" from unused summary when spool weights differ 2026-07-24 10:49:51 +02:00
Donkie e6e3be2773 client_v2: add filament total-remaining summary; show large weights in kg
Add an aggregate remaining-weight line to the FilamentInspector spools
section (respects the showArchived toggle), answering the recurring
total-available ask (#572/#347/#265).

Switch inventory weight displays (group totals, library list, both
inspectors) to weightAuto so amounts >=1000 g render in kg. Floor the
kg value to one decimal so the figure never overstates what's on the
spool. Remove the now-unused kg() helper.
2026-07-24 10:41:24 +02:00
Donkie 7e309e36b9 client_v2: add material suggestions to filament inspector
Extract the AddSpoolModal material-list building into a shared, session-
cached loadMaterials() helper (mirrors getExternalMaterials' cache), and
use it to give the filament inspector's material field autocomplete via
Combobox. The inspector uses only the name suggestions and never prefills
density/diameter/temps, unlike the add-spool form.
2026-07-24 09:56:29 +02:00
DonkieandClaude Opus 4.8 054c59948e client_v2: portal inspector popovers to fix mobile misplacement
The Combobox location dropdown and DateTimeField calendar are position:fixed
and anchored to their trigger's viewport rect. On mobile the inspector renders
inside DetailPane's bottom sheet (transform) and the .insp panel
(container-type), both of which become the containing block for fixed
descendants, re-basing the popover's coordinates and dropping it far below its
field.

Add a portal action that reparents the popover to <body> so position:fixed
anchors to the viewport regardless of offsetting ancestors. DateTimeField's
outside-click handler now also checks the portaled popover so picking a date
doesn't close it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 10:40:30 +02:00
DonkieandClaude Opus 4.8 5222df98a3 client_v2: hide color swatch on non-filament group headers
Group headers rendered a Swatch unconditionally, but only filament
groups carry colors — vendor/material/location groups pass an empty
list, which swatchStyle() paints as a meaningless gray (#555) chip.
Only render the swatch when the group actually has colors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 10:30:29 +02:00
DonkieandClaude Opus 4.8 5eb660f420 client_v2: add ESLint + wire lint/format/typecheck into lefthook
client_v2 has matured, so enforce its checks like the old client:

- Add ESLint (typescript-eslint + eslint-plugin-svelte) with a flat
  config; `lint` now runs prettier --check and eslint.
- Add a "Client v2" group to lefthook pre-commit and ci: eslint,
  prettier, and svelte-check type checking (svelte-kit sync first).
- Exclude generated paraglide output from prettier.

Fix the linting warnings this surfaced: drop dead vars and an unused
svelte-ignore, mark intentional reactive reads with `void`, remove a
useless children snippet, and clean up useless string-literal mustaches.

Disable svelte/no-navigation-without-resolve (base-path resolution is
centralized in params.ts helpers, so the rule only false-positives and
wrapping call sites would double-apply the base path). Keep
svelte/prefer-svelte-reactivity on with per-line disables at the
transient-local Set/Map sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 10:23:17 +02:00
Donkie 52b0580df9 removed unused locales 2026-07-23 09:04:49 +02:00
Donkie 01be7b7572 minor package lock json update 2026-07-23 09:01:40 +02:00
DonkieandClaude Opus 4.8 f69d098473 client_v2: approve esbuild install script for npm 11 allowScripts
npm 11 holds back dependency install scripts unless allow-listed, printing
a notice for esbuild's postinstall. Approve it explicitly so `npm install`
runs cleanly; older npm versions ignore the unknown field harmlessly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 09:00:16 +02:00
Donkie df2cd09bcc client_v2: use real <a href> links for navigation
Navigation across the app was done with <button onclick> calling the
params.* router helpers, so the browser never treated these as links: no
middle-click/ctrl-click open-in-new-tab, no copy-link-address, no hover
URL preview. Add href-builder twins of the URL mutators in
library/params.ts (selectHref, selectHrefFromState, pageHrefFromState,
libraryHref, searchResultHref) and point real anchors at them; SvelteKit
intercepts the clicks for SPA nav while data-sveltekit-keepfocus/-noscroll
preserve the old goto() semantics.

Converted: spool/filament rows in the library list and inspectors,
breadcrumbs, filament/vendor group headers (material/location headers,
which navigated nowhere, are now inert divs), the spool inspector's
open-filament link and print-label button (Button gains an optional
href), search results, location chips (still drag items), and pagination.
Genuinely non-navigational actions (modals, sort/filter/group toggles,
expand/collapse, label-canvas selection) stay buttons.
2026-07-23 08:56:39 +02:00
Donkie 3d6888ec2c Added filament labels 2026-07-21 19:29:50 +02:00
Donkie ca8dba9e54 client_v2: redirect legacy /spool/show/<id> QR URLs to the library
Spoolman v1 QR labels (and the label designer's `url` encoding) point at
/spool/show/<id>, a route that no longer exists in client_v2. Add a
non-prerendered route whose load 307-redirects to /?sel=spool:<id>, so the
SPA fallback boots the app and the client-side redirect opens the spool.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-21 17:52:49 +02:00
Donkie 3a2fc68e1e client_v2: configurable custom QR URL target with {id} template
The QR "URL" encoding was hardcoded to <base_url>/spool/show/{id}. Add a
per-QR-element "Custom URL" encoding with a urlTemplate field so a printed
QR can point at a third-party scanner/app or a different host. The {id}
placeholder reuses the existing qrContent substitution.

The inspector seeds the field from the default URL form and warns that a
custom target won't scan back into Spoolman's own scanner. The no-spool
editor-canvas preview now renders the actual per-encoding template so the
QR module density matches what will print.

Requested in #68 and #741.
2026-07-21 17:45:53 +02:00
Donkie acb367262b client_v2: don't collapse a lone unused spool under a header
A group with a single unused spool now renders it inline as a normal
SpoolRow instead of behind an 'X unused' collapsing header, which added
a click and a row for no vertical-space benefit. The header only appears
when 2+ spools are unused.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-21 12:32:04 +02:00
Donkie 9b79135c1c client_v2: show an add-spools CTA when the library is empty
When the list has zero results and no filters are active the library is
genuinely empty (a fresh install), so nudge the user toward adding their
first spool instead of the generic "nothing matches the filters" line.
The CTA button opens the same add-spool modal as the top-right button.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-21 12:27:12 +02:00
Donkie ecd43bd51a backend: make color similarity search scale with matches, not catalog size
find_by_color loaded every filament as a full ORM object (with its vendor
join) into memory and computed delta-E in Python, then the endpoint re-queried
the matched ids anyway. The full-catalog ORM load dominated the cost: a color
search ran at a flat ~100ms regardless of how few results it returned, ~5-20x
slower than an equivalent regular filtered search, and each concurrent request
cloned the whole catalog into memory.

Scan only id/color_hex/multi_color_hexes and return the matched ids; callers
load full rows for just the matches. The matching algorithm is unchanged.
Color search now scales with the match count like any other filter: ~5-16x
faster here and the concurrency memory spike (+21MB at 8 concurrent) is gone.
Drops the "Slow operation!" note from the color_hex query param since it no
longer applies.

Verified across SQLite/Postgres/MariaDB/CockroachDB (372 integration tests
pass on each).

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-21 11:53:08 +02:00
Donkie 64d5f84bc3 Fixed swatch being messed up in addspoolmodal 2026-07-21 11:51:59 +02:00
Donkie 6ade40517f backend: notify spools when their filament changes
Spool responses carry fields derived from the filament (remaining_length,
plus the initial_weight/price fall-backs). Editing a filament emitted only a
filament WS event, so subscribers to that filament's spools kept stale derived
values until each spool was next touched — visible in client_v2 as a length
inconsistent with the displayed density, but affecting every consumer.

filament.update now re-broadcasts the filament's spools (archived included)
as spool "updated" events after commit. Adds a WS integration test asserting
a density patch reaches /api/v1/spool with the new density and changed length.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-21 10:58:09 +02:00
Donkie 990f16a8e9 client_v2: fetch deep-linked entities missing from the cache
The library inspector read purely from the reactive cache, which is only
filled by the list page, search and live events. A selection reached by
bookmark/QR/shared link (?sel=spool:4093) for an entity not on the loaded
page rendered the empty "select…" state.

Add fetchSpool/fetchFilament/fetchVendor single-entity fetches to
spoolSource (upserting the entity plus embedded relations; a 404 resolves
to undefined via swallow404). Inspector.svelte runs a fetch-on-select
effect that resolves a missing selection by id, showing a loading state
while in flight and a distinct not-found state for a deleted entity.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-21 10:45:39 +02:00
Donkie 9582962e37 client_v2: keep grouped-view sorting coherent
The Library's group list can only be ordered three ways by the backend
(group.title, group.total_remaining, group.last_used), yet the sort menu
offers ~20 fields. Picking any other sort while grouped silently fell back
to alphabetical group order and only reordered spools within each collapsed
group — an invisible, confusing no-op.

Enforce the invariant that a grouped view is only ever ordered by a
group-orderable sort (the axis's own name → group.title, remaining weight,
or last used). Picking a per-spool ranking now switches to the flat list
where the ranking is actually visible; regrouping while holding an
incompatible sort resets to the default group ordering. Axis-name sorts map
to group.title with a working direction toggle, and name/text sorts default
to ascending.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-20 23:17:28 +02:00
Donkie a34d52a597 client_v2: fall back empty locale values to base language
Weblate writes an empty string for every untranslated key. Paraglide
compiles a present-but-empty value into a message function that returns
"", so the site rendered a blank instead of falling back to English. A
key that is absent from a locale, by contrast, is re-exported from the
base locale and falls back correctly.

Add scripts/strip-empty-locales.mjs to delete empty/whitespace-only
leaves from the non-base locales/*/common.json before paraglide compiles
them, turning "present-but-empty" into "absent". Idempotent, base en
untouched, key order and formatting preserved. Wired as predev/prebuild
so builds stay correct if empties reappear from a later Weblate sync;
deliberately not a lint/CI gate, since empties are a benign expected
input to be neutralised, not forbidden.

Cleans 125 existing empties across 10 locales.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-20 16:32:04 +02:00
Donkie 5b5e07e2c1 client_v2: flatten color swatch and vary multi-color gradient
Replace the glossy 3D swatch (diagonal sheen + directional inset shade)
with a flat chip that keeps only two non-directional hairline rings for
edge definition on light and dark backgrounds.

Multi-color swatches now split into per-colour bands with softly feathered
boundaries instead of a full-slot blur, and the gradient angle follows the
filament's layout: coaxial splits side-by-side, longitudinal along the
strand, unknown stays diagonal. Direction is threaded through every Swatch
callsite, including the filament-grouped library header (already present in
the /spool/group response, just not mapped through before).

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-20 11:34:53 +02:00
Donkie 38397424ee client_v2: page grouped "show more" incrementally
The Library's grouped view fetched every "show more" click as a fresh window
from offset 0, so the request grew with each click (8 → 28 → 48 …), wasting
bandwidth and backend work. buildScopedSpoolQuery now takes an explicit offset,
and GroupRow's "show more" fetches only the next slice (offset = loaded count,
limit = 20) and appends it — a constant-size request per click.

The base-load effect still refetches from offset 0, but for as many rows as are
already shown (spools.length read via untrack, so appending doesn't re-trigger
it), so a live-event refresh keeps the expanded window instead of collapsing to
the first page. "Show more" is disabled during a base load so its append can't
race the replace, and its request aborts on unmount like the base one.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-20 11:17:03 +02:00
Donkie 128a0a4a34 client_v2: localize datetime formatting for extra fields and labels
Datetime extra fields rendered through their own path (native datetime-local
input when editable, new Date(v).toLocaleString() when readonly) while builtin
datetimes like Last Used render via DateTimeField with a localized label.
Extract that label logic into formatDateTime/dateLocale (utils/datetime.ts);
DateTimeField reuses it, and ExtraFieldInput now uses DateTimeField for editing
and formatDateTime for readonly display so both format identically.

Also localize two datetime helpers in map.ts: shortDate (registered/first-used
labels) used the browser locale instead of the app locale, and relTime
(last-used labels) hard-coded English unit letters. Both move to datetime.ts as
formatShortDate/formatDurationShort, using the app locale and Intl.NumberFormat
unit style. Formatters are memoized per locale since they run over every spool.

Also correct two mislabeled fields in the spool inspector's filament section
(weight and spool weight i18n keys).

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-20 11:05:10 +02:00
Donkie 4118ca5f2c client_v2: stop locations grid flickering as cards load
The locations page reflowed the whole grid every time a location's spool
query landed, on both axes:

- Vertical: each card started empty (~110px) and jumped to full height when
  its first page arrived, shoving cards below it down (bottom cards moved
  ~1162px on the 4158-spool stress DB). Reserve the body's final height up
  front from the location's spool count (known from the aggregate before any
  spool row is fetched), clamped to the same cap as max-height.

- Horizontal: a .shelf is a flex item, so its default min-width:auto grew as
  chips loaded, dropping the grid from N to N-1 columns and re-wrapping (cards
  jumped up to ~1575px sideways). Pin min-width:0 so column count depends only
  on flex-basis and available width, not content.

Measured with a per-animation-frame position sampler: horizontal travel now
0px and vertical ~4px (the loading bar) across 1440/1600/1920/2560 widths.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-20 09:06:25 +02:00
Donkie 2f25e76f5e client_v2: lighten print tab spool load, keep Recent complete
The print tab loaded up to 1000 spools on open — an extremely heavy query.
Preload only the latest 50 instead and move the search box to the backend
(/search for spool-native fields plus a single filament.id expansion so a
filament/vendor-name query still turns up spools), resolving results through
the reactive inventory cache so live edits/deletes reflect.

To keep the "Recent" quick-select accurate past the first 50, loadInitial now
pages newest-first while the batch is still inside the 24h window (batches of
100, capped at 1000, id-deduped), so every spool added today is loaded.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-20 08:28:50 +02:00
Donkie a733366be9 client_v2: abort in-flight list queries on navigation
Spamming between the Library and Locations pages queued up a heap of
spool queries that kept running after their view was gone: the results
were discarded, but the requests still cost bandwidth and backend work,
and piled up behind the browser's per-host connection limit.

Reads now take an optional AbortSignal (getList/getJson, SpoolQuery and
GroupQuery gain a `signal`), and every list view cancels what it no
longer needs on effect-cleanup / unmount: FilamentList, GroupRow,
PrintLayoutPanel and the locations page — whose MAX_INFLIGHT queue also
drops turns still waiting when the page went away. Writes deliberately
keep no signal, since a request that has left the browser has already
changed the server. isAbortError() takes the signal as the reliable
cancellation test, because an aborted fetch does not always reject with
a DOMException named AbortError.

Measured on a ~4900-spool backend, leaving the Library mid-load went
from 17 row queries still completing after navigation to 0 (all
cancelled), with no console noise and no stuck cards on return.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-20 08:01:40 +02:00
Donkie 505faf3b35 Load entity extra-fields with selectin instead of joined
The spool listing already joins spool -> filament -> vendor, so joined
eager loading of all three `extra` collections multiplied their rows
together. With ~12 spool, ~9 filament and ~9 vendor extra fields, a
single GET /spool?limit=30 made the database materialize ~35k wide rows
(one per field combination) and then spent most of its time in
SQLAlchemy de-duplicating them back into 30 objects.

Switch the three `extra` relationships to lazy="selectin" so each
collection is fetched in its own WHERE id IN (...) query and nothing
multiplies. Measured on a 4911-spool SQLite database, GET /spool?limit=30
drops from 431 ms to 12 ms and limit=100 from 1358 ms to 23 ms, with
byte-identical responses across the spool/filament/vendor/group/search
endpoints and all 371 integration tests green on SQLite, PostgreSQL,
MariaDB and CockroachDB.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-20 08:01:22 +02:00
Donkie e2a08c0cfd client_v2: load the locations page lazily instead of all at once
The page fetched every spool up front (limit 1000, sorted by location) and
re-ran that query on every live spool event. On a 4158-spool collection that
was 14.2 s and 1.82 MB, and it silently truncated: 76% of the spools never
appeared, so the per-card counts were wrong too.

One aggregate query (/spool/group?group_by=location, 10 ms / 2.1 KB) now
supplies every location and its exact count, and each card fetches its own
spools 30 at a time — starting when it scrolls into view, continuing as its
list is scrolled, capped at three requests in flight so a screenful of cards
doesn't queue behind the browser's per-host connection limit. Card bodies get
a max height and scroll internally. Live spool events are applied to the
loaded cards directly, with a single debounced aggregate refresh to
re-authorize the counts, rather than reloading everything.

Because a card now holds a page rather than the whole location, its saved
custom spool order can only speak for what is loaded; ids from the previous
order that aren't loaded are carried over behind the loaded ones instead of
being dropped.

Also fixes shelf reordering, which never worked under Svelte 5. The grid's
drag zone was armed by setting dragDisabled from the grip's own mousedown
handler, but svelte-dnd-action only attaches its mousedown/touchstart
listeners while dragDisabled is already false, and Svelte 5 flushes state
changes asynchronously — so the action updated after the press had passed and
the drag was never seen. Arm it from pointerdown (which precedes both
mousedown and touchstart) via flushSync so the listener is in place in time.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-19 14:39:42 +02:00
Donkie d580924d13 client_v2: add field help toggles to the inspector details
Adds an optional `help` prop to <Field> that renders the same ⓘ toggle and
popup the add-spool form uses, and wires up the existing fieldsHelp strings
for location, lot nr, price, article number, weight and spool weight in the
spool/filament/vendor inspectors.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-19 10:26:31 +02:00
Donkie 4e4313204e client_v2: add weight and spool weight to the filament inspector
Both fields were missing from the filament inspector's specs grid, and
filamentPatchToApi dropped them, so they couldn't be edited at all.

Spool weight is optional: blank sends an explicit null to clear the tare
weight, while net weight falls back to its current value when emptied.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-19 10:20:43 +02:00
Donkie c90cf9eb43 client_v2: fix crash inspecting a spool of a colorless filament
A filament with neither color_hex nor multi_color_hexes maps to an empty
colors array, and the spool inspector's color-hex row indexed [0]
unconditionally, throwing on render. Show the usual "—" placeholder
instead.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-19 10:18:35 +02:00
Donkie 5bd4fa594d client_v2: don't link a missing manufacturer in the breadcrumbs
A filament with no vendor resolved through inventory.vendorOf(), whose
placeholder shows as "?" — rendered as a clickable crumb leading to a
broken vendor page. The inspectors now resolve the vendor directly and
fall back to a muted, non-navigable "No manufacturer" crumb, and drop
the "?" from the filament subtitle and spool title as well.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-19 10:18:26 +02:00
Donkie 06eff55ccb client_v2: finish spool archiving in the library
The archive button wrote the flag but nothing reflected it afterwards:
the spool inspector showed no archived state and offered no way back,
and a filament's spool list silently dropped its archived spools.

- Spool inspector: ARCHIVED badge next to the id, and the button toggles
  between archive/unarchive. The optimistic cache patch is now rolled
  back when the PATCH fails instead of leaving a wrong local state.
- Filament inspector: "Show Archived" switch feeding allow_archived into
  the scoped spool query, off by default as in the library list.
  Archived rows are dimmed and labelled.
- archiveSpool(id) -> setSpoolArchived(id, archived).

Reuses existing locale keys, so no new strings for Weblate.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-19 10:12:59 +02:00
Donkie 43034dce50 client_v2: show help text on an empty locations page
When there are no saved locations and no spools, the page rendered as
just a header and an empty grid. Show locations.noLocationsHelp centered
instead, and hide the drag hint since there is nothing to drag.

Emptiness is only decided once both the spool list and the locations
setting have loaded, so the text doesn't flash on the way in.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-19 10:03:59 +02:00
Donkie 5bfaf9c5b2 client_v2: reuse v1 QR link strings in the label inspector
The QR element inspector had its own labels.enc*/labels.hintEnc* strings
duplicating printing.qrcode.useHTTPUrl.*, which already ships translated.
Switch to the existing keys and drop the five orphans from the en locale.

Adds the encoding preview the old print dialog had (useHTTPUrl.preview),
via a new qrTemplate() that returns the encoded string with a literal
{id}; qrContent() is now a substitution on top of it.

Reworded useHTTPUrl.tooltip in en only - same meaning, parallel sentences
per dropdown option. Existing translations of it still read the old
phrasing; v1's nested copy is untouched.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-19 10:01:36 +02:00
Donkie c72036f650 client_v2: save labels as images, with a configurable DPI
Adds a third mode to the label print panel, alongside Sheet and Label
printer: Image files. It saves one PNG per selected spool at the design's
label size, using the same Konva rasterizer that printing uses. Sheet
geometry and the copy count are ignored there — an exported image is meant
for a label printer or an image editor, where a tiled A4 page would just
have to be cut apart again, and copies would be byte-identical files.

Up to ZIP_THRESHOLD (5) labels download as individual PNGs, spaced apart
because browsers throttle bursts of programmatic downloads; past that they
arrive as a single zip built with fflate, dynamically imported so it stays
out of the main bundle and stored rather than deflated since PNG is already
compressed.

Also adds a per-design DPI setting that applies to printing as well as
export. Printing rasterizes through the same path, so a 300 dpi raster sent
to a 203 dpi thermal printer gets resampled by the driver either way — that
resolution mismatch, not the file format, is what issue #730 was actually
describing. Designs saved before this pick up the default via the
DEFAULT_LAYOUT spread in api/labelDesigns.ts; the v1 migration sets it
explicitly.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-19 09:53:35 +02:00
Donkie 593b653841 Match every whitespace-separated term in search
The search treated the whole query as one substring match, so "bambu
petg-cf" had to appear verbatim in a single field and returned nothing,
even though "bambu" and "petg-cf" each matched on their own. Vendor-name
matching also ran as a separate query, so a term could never be satisfied
by the vendor while another was satisfied by the filament.

Split the query on whitespace and require every term to match some
searchable field, letting different terms match different fields:

- Filaments search vendor.name in the same pass as their own fields via an
  outerjoin, replacing the separate vendor query. This is what makes
  "bambu petg-cf" work.
- Extra-field matching moves from a follow-up query to a correlated EXISTS
  inside each term's OR-clause, so extras participate in the AND too.
- Ranking generalizes to a per-field-kind base rank (native 0, vendor 2,
  extra 4) with prefix hits beating substrings. A row ranks by its worst
  term, so rows matching everything strongly sort first; the reported
  match_field is the single best hit.

Single-term queries behave as before. Standard SQL only (ilike, correlated
EXISTS), so this holds on all four supported databases.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-19 09:31:02 +02:00
Donkie 3f3f872310 client_v2: make the mobile inspector drawer draggable
The bottom sheet could only be dismissed by tapping, which felt static
next to a native mobile drawer. The grab handle now responds to drags:
the sheet tracks the pointer 1:1, snaps back below ~30% travel, and
dismisses past it or on a downward flick. Upward pulls are resisted
since there is nothing above to reveal, and the scrim fades in step with
the drag.

Pointer tracking lives on window rather than the sheet element — an
upward drag moves the pointer onto the scrim, and element-bound handlers
would stop firing mid-gesture.

Dragging from inside the scrollable body is only picked up when the body
is already at the top and the pull is downward, so ordinary scrolling is
untouched. On touch Chrome that gesture usually goes to the scroller
instead (see the note in the source); the handle, which is
touch-action:none, is the affordance that always works.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-19 09:21:31 +02:00
Donkie 0e5573bc1f Added save/error toasts 2026-07-19 09:06:04 +02:00