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
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
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
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
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
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
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
The info endpoint called externaldb.get_external_db_name(), but the
api-layer externaldb module never re-exported that name from
spoolman.externaldb, so every /info request raised AttributeError ->
HTTP 500. Import the function directly from the core module and call it.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
Add a shared ColorEditor component (single/multi toggle, per-color
pickers + hex fields, add/remove colors, illustrated coaxial vs
longitudinal cards that preview the actual colors) and wire it into
both the add-spool create form and the library filament inspector,
which also gains its first color picker.
Colors and multi_color_direction round-trip through the API via a new
colorFieldsToApi helper that keys single- vs multi-color by count and
nulls out the other fields, so switching between them stays consistent.
Make color type filterable in the library toolbar (Single / Coextruded
/ Longitudinal) backed by a new additive filament.multi_color_direction
query param on the spool and spool/group endpoints; the Single option
sends an empty value to match single-color (NULL) filaments.
Also tidy a few inspector field labels to reuse the canonical
filament.fields.* / vendor.fields.* message keys.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
Build the new Svelte client (client_v2) in CI, bake it into the Docker
image alongside the legacy React client, and serve it by default. Set
SPOOLMAN_LEGACY_CLIENT=TRUE to fall back to the old client.
SinglePageApplication is generalized to serve either client: the React
client keeps its index.html + base-path rewriting, while the Svelte
client uses a 200.html SPA fallback, serves prerendered per-route pages,
and serves relative-path assets verbatim. The tweaked-HTML store is moved
off self.html, which StaticFiles uses as its HTML-mode flag.
The Playwright frontend suite targets the React DOM, so it's pinned to
the legacy client via SPOOLMAN_LEGACY_CLIENT for now.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
The external filament catalog defaults to SpoolmanDB but operators can
point EXTERNAL_DB_URL at their own catalog. The UI hardcoded "SpoolmanDB"
in a few places, which is misleading in that case.
Add an EXTERNAL_DB_NAME env var (default "SpoolmanDB"), expose it via
GET /info, and have client_v2 read it into a serverInfo store. Badges and
the results header now show the configured name, and the localized
strings that reference the library take it as a {name} parameter.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
Extend the cross-entity /search filament results to also include filaments
whose vendor name matches the query, so searching a manufacturer surfaces its
filaments (not just the vendor itself). Vendor-name matches rank below any
native-field match and report match_field "vendor.name". The extra-field
annotation block is factored into a helper to keep _search_filaments within the
complexity budget. The client search bar labels the new match_field as
"Manufacturer".
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
The AddSpoolModal local catalog search only matched the filament name
(GET /filament?name=). Route non-empty queries through the same /search backend
the library search bar uses, so the picker also matches material, article
number, comment, extra fields and colour. Empty queries still browse the
catalog (sorted by name) so the picker has content on open.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
The external SpoolmanDB catalog (filaments.json, thousands of entries) was
served whole via GET /external/filament and filtered in the browser, so every
client downloaded the entire catalog just to search it.
Add a backend GET /external/filament/search?query=&limit= endpoint that filters
the catalog server-side (same word-AND, case-insensitive semantics as the old
client filter), memoizing the parsed catalog by file mtime. client_v2 now calls
it instead of downloading the whole file. The existing /external/filament
endpoint is unchanged, so external API consumers are unaffected. The small
materials.json is still fetched whole for new-filament presets.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
Rework the Locations page onto svelte-dnd-action so that container
reordering, reordering spools within a location, and moving spools
between locations all update live while dragging, matching the old
client. Native HTML5 drag couldn't do live cross-card moves because the
dragged node is destroyed when it changes each-block.
Per-location spool order is persisted in the `locations_spoolorders`
setting (keyed by location name, "" for no location), shared with the
old client. Shelf dragging is gated to the grip handle via the library's
dragDisabled pattern so it doesn't hijack the nested spool zones.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
The grip handle on each location card is now a drag source. Dragging a
card over another live-reorders them, and the order persists to the
server-side `locations` setting (an ordered JSON array) — the same store
the old client uses, so ordering survives reloads and is shared across
clients. The "No location" bucket participates like any other card, with
its slot tracked via an empty-string marker in that array.
Added locations now persist to the setting too (not just locally), so an
empty location gets a delete button to remove it again, and renames keep
a card's saved position.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
Both the design and print tabs used fixed multi-column grids that never
collapsed, cramping the columns on narrow viewports. Add 860px/560px
breakpoints so the inspector/preview drop below and everything stacks on
phones.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
The 'Group by' control was styled as a .link-btn, which the mobile media
query hid outright, removing all grouping control on small screens. Keep
both Group/Sort controls visible on mobile in a compact form (drop the
'Group by:'/'Sort by:' prefixes) and anchor the group menu to the right
edge so it stays aligned as the buttons shrink.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
Replaces the floating FAB, which overlapped content in the mobile
library view, with a compact accent icon button in the header row
next to the scan button.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
The label print picker now defaults to newest-first with a Newest/# sort
toggle, shows each spool's registration date, highlights spools added in
the last 24h, and adds a one-click "Recent (N)" quick-select so printing
labels for a freshly added batch is a single action.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
Swap all emoji and text-glyph icons (✕ + ☑ ⚖ 🏭 🗓 ⌄ ↑↓ ‹› ▾▸ ◱ T ▧ ▭ ⠿ →)
and the custom scan SVG for Lucide icons across the toolbar, library,
add-spool, labels, locations, settings, datetime and QR scanner UIs.
Adds the missing Archive-button icon and strips the arrow from the
"Open filament" string in favour of an ArrowRight icon. Global .lucide
alignment rule in app.css. Purpose-built NumberInput stepper carets kept.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
The button showed only the number of selected spools, but printLabels
emits one label per copy per spool. Multiply by layout.copies so the
count matches the actual output.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
The custom date/time picker used Intl only for month/weekday names but
hardcoded a Monday-first week and a 24-hour clock. Drive both from the
active locale via Intl instead:
- Week start comes from Intl.Locale weekInfo.firstDay (Sunday-first for
zh-TW/pt-BR/ja-JP/pt-PT/ta-IN/th-TH, Saturday for fa-IR, Monday
otherwise), reordering the weekday header and calendar grid. Falls
back to Monday on browsers without week info.
- Locales that prefer a 12-hour clock get a 12/1..11 hour dropdown plus
a localized AM/PM meridiem selector; the emitted value stays a 0-23
hour so nothing downstream changes.
- Trigger label formats its time part through Intl too.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
Sheet printing: the label size is now the fixed source of truth (set in the
designer), and the sheet lays those fixed-size labels on a user-chosen number of
columns anchored at the top-left margin, warning when the grid is wider than the
page instead of silently overflowing. Adds v1-style safe-zone margins that inset
only the outer labels' content, and a "fit width to columns" button.
Migration: first-time transfer of the v1 client's print_presets into v2 label
designs (layout ~1-1, QR+text elements, template field-paths rewritten), gated on
the label_designs setting never having been written.
Fields: carry vendor comment/externalId/registered and filament
comment/articleNumber/registered through the domain model, API mapping, patch
writers, label resolvers + designer palette, the filament/vendor inspectors, and
the add-spool new-filament form, so every printable field is also viewable and
editable in the app.
SvelteKit deprecated the base/assets/resolveRoute exports from $app/paths
in favour of resolve() and asset() (since 2.26). Migrate all usages:
navigation targets use resolve(), static assets use asset(), and the few
spots that need the bare base string derive it from resolve('/').
Behaviour is unchanged under our paths.relative runtime base-path setup:
resolve()/asset() read the same runtime base, and with no configured
assets path asset() falls back to base, matching the old base + path.
Give NumberInput a `unit` prop that renders inside the input (left of the
steppers), plus `dense` sizing, a `disabled` state, a numeric/commit
`onchange` mode, and an `onclear` hook for nullable fields. Fix a
min-content overflow so the flex control shrinks inside grid/flex cells.
Apply the unit input to:
- add-spool form (density, diameter, temps, weights)
- label designer, element inspector, print layout panel (mm)
- library adjust-weight field, and the filament/vendor/spool inspector
detail grids (diameter, density, temps, price, empty-spool-weight)
Move the units out of the English label strings into the inputs; locale
keys are kept so Weblate translations aren't orphaned.
Adds a color-theme preference (system/light/dark) in Settings, persisted
to localStorage and defaulting to the OS preference. An inline script in
app.html resolves the saved theme before first paint to avoid a flash of
the wrong theme; the theme store keeps <html data-theme> and the
theme-color meta in sync reactively (including live OS changes).
The light palette is applied purely by overriding the design tokens under
:root[data-theme='light']. Hardcoded neutral/accent colors scattered
across components were converted to tokens (adding --surface-sunken,
--accent-muted[-2], --track, --swatch-border[-hover]) so both themes stay
in sync.
Give the shared on-screen Swatch a physical color-chip look (diagonal
gloss sheen + hairline definition ring) instead of a flat rounded rect.
The printed/canvas label swatch stays flat but gains the same definition
ring so light colors read on white label stock.
The Print-tab layout (margins, spacing, paper, copies) lived in a single
global localStorage key, separate from the design. Editing a field never
dirtied the design, so the Save button stayed disabled, and every design
shared one layout.
Move PrintLayout onto LabelDesign so each design carries its own layout:
editing any Print field now dirties the design and enables Save, and the
layout is persisted/recalled with the design. Old designs backfill from
DEFAULT_LAYOUT on load.
In the Group: Filament view, used SpoolRows shed their filament identity
(the sticky header already carries it), leaving a bare column grid. The
collapsed UnusedRow, still shaped like a filament-led row, clashed with
them. Under the filament group it now renders as a quiet collapsible
divider ("N unused · Xg each" + hairline rule) that introduces the unused
pile; expanding lists the spools as ordinary rows aligned with the used
siblings. Other group axes keep the filament-name-led summary, where the
name distinguishes different filaments' unused piles.
Archived spools were excluded by the backend by default and the client
never opted in, so they were invisible with no way to view them.
Treat "show archived" as a filter: a checkbox at the bottom of the
toolbar's Filter menu, surfaced as a dismissible "Archived" chip among
the active-filter chips when on. It lives in the URL state (arch=1)
alongside group/sort/filters and drives allow_archived=true on the
/spool and /spool/group queries, so both flat and grouped views (and
group aggregates) include archived spools. Archived rows are dimmed and
carry an "Archived" tag. Default stays off, preserving prior behaviour.
Also split the toolbar into a wrapping filters region and a pinned
Group/Sort control cluster, so accumulating filter chips wrap onto new
rows instead of pushing Group/Sort down.
Replaces native <datalist> inputs, whose dropdown wouldn't open on focus
and couldn't be styled. The new Combobox opens its list immediately on
focus/click, filters as you type, supports keyboard nav, commits on Enter,
and still allows entering brand-new free-text values. Its dropdown is
fixed-positioned so it isn't clipped by scrollable modal bodies.
Used for the SpoolInspector location field (the original request) and the
vendor, material, and location inputs in AddSpoolModal.
Replace native datetime-local inputs for first/last used with the
custom DateTimeField picker already used in the library inspector,
giving consistent, fully-clickable date+time selection across browsers.
Corrected offline fallback densities in AddSpoolModal to match
SpoolmanDB's materials.json: ASA 1.07->1.05, Nylon 1.14->1.52,
PC 1.2->1.3, HIPS 1.04->1.03. These short keys never get overridden
by SpoolmanDB's longer names at runtime, so the values must be
correct on their own.