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).
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.
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.
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.
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.
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.
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).
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.
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.
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.
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>
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>
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>
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>
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.
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
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.
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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 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
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
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