The README carries no config reference at all -- env vars and deployment
guidance live in the Wiki -- so documenting SPOOLMAN_CORS_ORIGIN and the
reverse-proxy host requirement here gave them two homes to drift apart in.
Every workflow pinned node-version '20'. Node 20 reached end-of-life on
2026-04-30, so for three months CI has been building the artifacts that
go into released images on a runtime that no longer gets security fixes.
Vite 8 also floors at ^20.19, which setup-node's '20' happens to satisfy
today only because it resolves to the latest 20.x -- a floor we were
clearing by accident is not one worth keeping.
24 rather than 22: it is Active LTS until 2026-10-20 and supported
through 2028-04-30, so this pin outlives the 22 line by a year. Node 26
does not ship until October.
The legacy client declared engines "20.x", which would have made every
npm ci in that package print EBADENGINE against the new runner. It is
widened to the floor of the Vite 7 it already runs on, so it now
describes what the package actually supports instead of naming a single
line.
Verified locally on Node 24.13.0: lefthook ci green, both clients
install clean and build, client_v2's unit tests and the
tests_frontend_v2 Playwright suite pass. tests_frontend needs the built
image so it is left to CI; its only dependency declares node >=18.
The setup-node pin itself is untouched and still on v4.0.3, whose action
runtime is the other deprecated Node 20 in the logs -- GitHub already
force-runs it on 24. That is an action upgrade, and a separate change.
Dependabot #982 opened the door: an esbuild advisory that can only be
closed by going forward, since the fix is upstream of us in
vite-plugin-svelte and vitest. It could not merge on its own, though --
Vitest 4 no longer augments Vite's defineConfig, so the `test` block in
vite.config.ts became a type error and the style job went red on a file
Dependabot is not allowed to touch. The config now takes defineConfig
from vitest/config, which is where that overload lives.
With the chain forced open anyway, everything else that had drifted goes
with it: SvelteKit, Svelte, paraglide, Playwright, prettier-plugin-svelte
4, globals 17 and ESLint 10. TypeScript stays on 5.9 -- 7.0.2 is out, but
typescript-eslint pins <6.1.0 and svelte-check ^5 || ^6, so it is
peer-blocked on both sides rather than merely untested.
Vite 8 replaces Rollup and esbuild with Rolldown, so the toolchain is now
one Rust binary plus lightningcss. Measured against a clean checkout of
the old tree: the production build drops from 7.7s to 4.6s, shipped
JavaScript from 383 KB gzipped across 42 chunks to 360 KB across 36. Dev
cold start is unchanged at ~2s, as expected -- dev serves unbundled ESM
either way. The armv7 image is not exposed to Rolldown's native binaries:
build-client runs on amd64 and the arch jobs only download its artifact.
ESLint 10 turns on no-useless-assignment, which caught three initialisers
that every branch overwrites; they become bare declarations. The
allowScripts block pinning esbuild@0.21.5 goes too, as esbuild is no
longer in the tree at all and nothing in the repo reads that field.
The lockfile is regenerated rather than patched: the old one pinned
vite-plugin-svelte 4 firmly enough that npm could not resolve a path to
7 without it.
Verified on Node 24: lefthook ci green, 50 unit tests, a11y audit, and
the full tests_frontend_v2 Playwright suite (21/21) against a real
backend serving the new build.
The library screenshot shows one of the client's four pages. The two
that are hardest to guess at from a feature list -- spools as draggable
cards grouped by location, and labels built from arbitrary spool fields
-- now sit under it in a two-column table, so they cost one screen's
height between them rather than one each.
They are captured at a narrower viewport than the library shot rather
than scaled down from it: at half width a 1440px page reduces to
unreadable, whereas 900px reflows the dashboard to two columns and the
designer to a tighter grid, so the text survives the downscale. Both are
cropped to a 3:2 aspect and have the nav bar removed -- it is already in
the shot above -- so the pair sits level.
Fixed along the way, since it would otherwise have been immortalised in
the dashboard screenshot: the card separators lost a space where they
met an {#if} boundary, which Svelte trims, rendering "Bambu Lab -Bambu
Green" and "1 kg- Last used 2 days ago". The optional parts are now
built as one expression, which has no block edge to trim.
demo_seed gains a note on why label designs aren't seeded: they're a
blob of a client-side schema, so writing one from Python would duplicate
it. The designer's own "Create your first label" is the honest route.
Grouped by filament, a row had nothing of its own to put in the bold
title slot -- #id, fill, weight and location all have columns -- so it
borrowed the comment when one was set, and dropped the usage status to a
quiet second line behind it.
That assumed comments are short labels. The field is a free-form
textarea: people keep order URLs, multi-line notes and pasted receipts
there. Whatever is in it got the widest, loudest slot in the row, pushed
out "used N ago" -- the one thing every row can say -- and, since .name
is nowrap, ran any newlines together into one line before ellipsing
mid-word.
Rows now always show the usage status. The comment is unabridged in the
inspector, which is the only place it was ever readable in full.
The screenshot is regenerated against the new behaviour, and demo_seed's
comments go back to natural length -- they were written short only to
survive the row.
The old one was seeded with stress_seed.py, which builds a filament name
from one random pool and its color hex from another, so the picture
contradicted itself: "PETG Copper Bronze" had a blue swatch and "PVA
Sparkle Silver" a tan one. That is the first thing a visitor sees.
scripts/demo_seed.py imports every filament from SpoolmanDB instead,
issuing the same request the client's Add-spools flow does, so names,
colors, densities and temperatures come from one source and agree with
each other. Twenty filaments across eight real vendors are pinned by
external id, chosen for material spread and a varied swatch column; if
one leaves the catalog the script fails with the list rather than
quietly drifting.
The spools on top of them are still made up, because that half really is
per-user data -- partial weights, locations, lot numbers, prices,
first/last-used dates, a few archived and a few still sealed -- plus the
handful of extra fields a real user defines, so the inspector shows
content where it used to show its empty state.
Two details are shaped by how the list renders rather than by realism:
spools consume a whole number of grams, since a decimal wraps onto a
second line in the narrow weight column, and comments stay under ~20
characters, since the list shows a comment in place of "used N ago" and
truncated the longer ones mid-word.
The type check assumed $lib/paraglide existed, but it's gitignored
output of the paraglide vite plugin, so it only appears once something
runs vite. svelte-check doesn't, which left the job resolving ~40
imports of $lib/paraglide/{messages,runtime} against nothing on any
fresh checkout -- CI every time, and a new clone locally. It went
unnoticed because the job had been dying at exit 127 before it could
get far enough to report, and because a working tree that has ever run
dev or build has the directory sitting there.
The compiler ships a CLI, so this costs a compile rather than a full
vite build. Its flags live in a package.json script that `check` and
the hook both call, leaving one copy to keep in sync with the plugin
options in vite.config.ts.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
lefthook's ci hook grew a client_v2 group, but the style job only ran
npm ci in client/, so all three of its jobs died at exit 127 on missing
./node_modules/.bin binaries -- eslint, prettier and svelte-kit. The
hook is invoked from the repo root and the paths are hardcoded (for
Windows' sake), so nothing resolved them from client/'s tree. That has
been failing every run since the group was added, on a branch where
client_v2 is the client that ships, so its lint, format and type checks
were the ones not running.
client_v2/docs is gitignored scratch space that prettier still walked,
which fails the hook locally on files CI never sees; the ignore files
now agree.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
CodeQL flagged esc() in the Playwright global teardown: it replaced the
markdown cell separator with an escaped one but left backslashes alone,
so an input ending in a backslash produced a doubled backslash followed
by a bare pipe -- the backslash escaped itself and the pipe went on to
split the row. axe help strings and CSS selectors are arbitrary page
content, so that is reachable, if only ever in a local audit report.
Escaping both metacharacters in a single pass fixes it: nothing the
replacer emits is re-examined, so an escape can't be consumed by the
character before it. Newlines get flattened at the same time, since a
newline ends a table row just as effectively as a stray pipe did.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
The merge brought master's version bump, which ran before bump.py knew
about client_v2 -- so client/package.json says 0.25.0 and client_v2 was
left one release behind. Set it to what the bump would have written.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
Brings the branch up to 0.25.0 and picks up cosign image signing, the
armv7/arm64 smoke tests, the armv7 greenlet/libstdc++ fix, the
PUID/PGID and Kubernetes SPOOLMAN_PORT entrypoint guards, and the
externaldb unwritable-path fix.
The only conflict was publish-images' needs list, where both sides had
added a job: test-frontend-v2 here, test-multiarch-smoke on master.
Resolved as the union, so nothing publishes until every test job has
passed. The rest of the graph is unchanged, and the new smoke tests
inherit client_v2 coverage for free -- build-arm64 and build-armv7
already download the client_v2 artifact, so booting those images to
/health now also proves the default client is present in them.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
The screenshot was the 2023 React client -- a different application from
the one a new visitor now installs. The replacement is client_v2's
library with a spool's details open beside it, which is where anyone
using Spoolman spends their time, and it lives in the repo rather than
on user-content URLs so it can be regenerated with the client.
The feature list gains search/group/filter, which client_v2 makes a
first-class part of the library rather than per-column table filters.
Installation now mentions SPOOLMAN_LEGACY_CLIENT. Someone whose setup
the new client misbehaves on will look here before .env.example, and
the useful thing to know is that both clients ship in every release and
share the API, so falling back costs nothing.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
Browser coverage was smoke plus CRUD, mirroring the old React suite --
which left the subsystems with the most new code in them completely
untested. Everything added here needs a real deployment to mean
anything, which is why it lives in this suite and not in vitest.
search.spec.ts drives the top-bar search (a filament, a manufacturer
and a location each land in their own section; spools match on their
own text, not their filament's, so a location is what finds one) and
the toolbar's group / filter / sort. Group, sort and filters live in
the URL, so each is checked to survive a reload. Sorting a grouped list
by something the backend cannot order groups by flattens the view, and
that is asserted rather than left as a surprise.
labels.spec.ts round-trips a design through the label_designs server
setting and exports a label for a real spool through the "Image files"
mode, asserting a non-trivial PNG comes back -- the only way to prove
the QR encoding, template substitution, logo and mm-to-pixel maths
actually ran. The print modes are left alone on purpose: they open the
browser's print dialog, which would stall the session.
settings.spec.ts separates the two persistence models the page mixes
with no visual cue -- currency, rounding and base URL on the server,
theme in the browser -- and takes an extra field from definition to the
library's filter menu and back out again. pwa.spec.ts checks the
deployed manifest rather than the source file: served at the deploy
root, with a type browsers accept, all icons resolving, all URLs
relative.
The add-spool flow moves out of crud.spec.ts into a helper so each spec
creates its own data instead of leaning on what an earlier one left
behind, and filters pin assertions to that data -- the library is
paginated, so an unfiltered assertion about a specific row is a coin
toss on an instance with real data in it. Anything with instance-wide
reach is put back afterwards. Verified against both an empty database
and one holding ~50 seeded spools.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
The bump script has only ever touched client/package.json, so
client_v2 has sat at 0.0.1 since it was created. Nothing reads it --
the footer takes the version from GET /info -- but both clients ship in
every release, and a package that claims 0.0.1 while the release says
0.25.0 is confusing to anyone who looks.
The indent has to be per-client: each tree is formatted by its own
prettier config and client_v2 uses tabs, so writing 2 spaces back would
make `npm run lint` fail on the release commit itself.
Also brings client_v2 up to the current version so the first bump after
this is a normal increment rather than a jump from 0.0.1.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
It has been sitting untracked in client_v2/ -- the wrong place twice
over: it is a backend tool that talks to the API over HTTP and knows
nothing about the client, and client_v2 is excluded from ruff, so
nothing was checking it.
scripts/ already holds the repo's operational scripts, and a poe task
puts it next to the other things you run against a dev instance. The
docstring gains the warning the flags deserve: --purge deletes every
vendor, filament and spool on the target, not only the generated ones.
Ruff now sees it, so scripts/ gets per-file ignores for the rules a
generator legitimately breaks -- stdout is its user interface, its data
is deliberately random and full of literal weights and diameters, and
the generators are long by nature. Type annotations and everything else
stay enforced.
Also fix --skip-fields: it skipped creating the extra-field definitions
but still generated values for them, so every POST failed with "Unknown
extra field" and the run produced nothing.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
The clients are build artifacts and are not committed, so a source
checkout has none until `npm run build` has been run in the client
directory -- and a `git pull` that brings new client sources does not
bring a bundle. Spoolman then refuses to start with StaticFiles' own
message, "Directory 'client_v2/build' does not exist", which says
nothing about what is wrong or how to fix it.
This has always been true of client/dist, but it bites harder now: an
existing source install that upgrades into client_v2 has never built
this directory even once, so it hits the wall on the upgrade rather than
on a fresh install. Docker images and spoolman.zip both ship the bundles
pre-built, so neither is affected.
Check before handing the directory to StaticFiles and name the command
to run, derived from the client directory so it is right for whichever
client is mounted.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
The legacy client got its manifest and icons from vite-plugin-pwa.
client_v2 has no such plugin, and nothing replaced them: static/ held
only favicon.svg, spoolman.svg and the Ko-fi logo. So the upgrade
silently un-installs the app for everyone who had added Spoolman to a
home screen -- no manifest means no standalone display mode, no
home-screen icon, and no install prompt ever offered again.
The manifest and icons are plain files in static/ rather than plugin
output. The icons are the v1 set, unchanged, so an already-installed
app keeps the icon it had; name, colors and display mode are v1's too,
and start_url resolves to the same URL at a root deploy, which keeps the
app identity stable across the upgrade instead of orphaning the old
install.
Every URL inside the manifest is relative to the manifest itself, so the
one file is correct under any SPOOLMAN_BASE_PATH with no rebuild. Only
the document's link to it needs help: the prerendered pages emit
"./manifest.webmanifest" and resolve fine, but the SPA fallback (200.html,
which serves deep routes like the /spool/show/<id> a printed QR points at)
carries absolute paths, so the existing base-path fixup gains two
patterns for the manifest and the touch icon.
Also register the .webmanifest media type. StaticFiles takes Content-Type
from the stdlib mimetypes registry, whose built-in table only learned
this extension in recent Pythons and otherwise falls back to the host's
/etc/mime.types -- absent in slim images. We support 3.10+, so leaving it
to the environment means some installs serve the manifest as text/plain.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
The old React client injected a vite-plugin-pwa registration into its
index.html, so every visitor who ever opened Spoolman has an active
worker at the deploy root -- not just people who installed it to a home
screen. Its precache holds a complete copy of the old app shell, and
`createHandlerBoundToURL("index.html")` serves that shell for every
navigation.
Upgrading does not touch any of that. client_v2/build ships no sw.js and
SinglePageApplication 404s missing non-.html files, so the browser's
update check fails -- and per the service worker spec a failed update
leaves the registration intact. The result is the old React UI, fully
working against the new backend, with nothing to indicate the upgrade
happened. Chrome has historically unregistered on a 404, but that is
browser-dependent and not something to rest a rewrite launch on.
So answer the update check instead of 404ing it. static/sw.js takes over
(skipWaiting, so it does not queue behind the outgoing worker in
"waiting" -- a state that only clears when every tab is closed, which is
exactly the user who is stuck), drops every cache, unregisters itself,
and reloads the windows it controls, which unregister alone leaves
sitting on whatever the old worker handed them. static/ lands in the
build root, so it answers at `sw.js` relative to the deploy base path --
the exact URL the old registration polls, base path or not.
The test stands in a worker with the two behaviours that do the trapping
(a precache and a navigate handler answering from it), because only one
client is mounted at a time and the deployment under test already serves
the real sw.js at the legacy registration's URL. It proves the stale
worker does keep serving the old shell, then swaps the real one back in
and asserts the new UI returns with no reload from the test. Removing
build/sw.js fails it on the user-visible symptom: still on the old shell.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
The suite has been red since 2165121 turned the locations page into the
dashboard: two of its three tests click a top-bar tab named "Locations",
which no longer exists, and spent 30s timing out on it.
The dashboard groups by location out of the box, so the CRUD test's last
step still works as written once it goes to the right tab -- the new
location is a card of its own.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
Picking the empty option of a single-choice extra field did nothing --
in either client, on spools, filaments and vendors alike. Emptying an
integer, range, datetime or multi-choice field was just as stuck. There
was no way to get a value back to unset once it had one.
Nothing in the API could express it. A key left out of the map means
"leave it alone", and every value that could stand in for empty is
rejected by the field's type check: "" is not one of a choice field's
choices. So `extra` values now accept null, meaning the entity has no
value for that field -- no row is stored, which clears whatever was
there. Null was a 422 before it reached any logic, so nothing that used
to work behaves differently.
A filament's and a vendor's extra were also REPLACED wholesale by a
patch rather than merged per key, unlike a spool's since 28f6f8e
(v0.22.0). Anyone patching one field of a filament -- as client_v2 did
-- silently destroyed the rest of its custom fields; anyone relying on
the replacement to delete a key can now say so with null. All three
entities merge per key now, and the field descriptions say so instead
of saying nothing.
client_v2's saver was where the frontend half died: an emptied input
produced no patch at all. It sends the cleared key as null now, and
keeps sending only the keys that changed, which is what stops two people
editing different fields of one entity from overwriting each other. It
also captures the entity id when the edit is made -- the inspectors are
reused across selections rather than remounted, so a debounced patch
could otherwise land on whatever you clicked next.
The legacy client cleared a filament's or vendor's field by omitting it,
which the merge would now ignore, so its form sends null for a field
left empty. That also buys it clearing on spools, which merged (and so
ignored the omission) all along.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
With no custom spool fields defined, the group-by menu has a single
entry and a note explaining that adding a text or choice field to spools
in Settings gets you more views. It told you where to go and then left
you to find it: three pages away, in a section that has to be scrolled
to and whose entity tab has to be switched.
The note is the link now, straight to the spool tab of the extra-fields
manager -- the same /settings?fields=spool#extra-fields deep link the
inspectors use, so it selects the tab and scrolls to the section.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
The header packed the title, the group-by button, the drag hint and
"New group" into one non-wrapping row. On a phone there is nowhere near
enough width for that, so the hint -- the longest and least urgent thing
in the row -- was squeezed into a column one word wide, six lines tall,
with the group-by button stranded beside it (baseline alignment lines it
up with the first line of a paragraph that is now a column).
The row wraps now, and below 640px the hint takes the whole of the
second line while the three controls share the first. Alignment moves
from baseline to centre, which is what looks right once the items are
different heights rather than all being text. On a desktop the row is
unchanged: everything still fits on one line.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
The designer's canvas zoom was solved against a hard-coded 500x340 px
viewport, so on a phone a perfectly ordinary 50x25 mm label rendered 500
px wide inside a ~300 px stage and lost both its edges. Nothing recovers
from that: there is no zoom control, and the stage's scrollbar only
reaches what you already can't see.
The stage now measures itself and the fit uses the width it really has.
The measuring box is a full-width child whose own width comes from the
grid track, never from the canvas inside it, so feeding it back into
pxPerMm can't oscillate. The 2 px/mm floor stays for an absurdly large
label, and .stage-area keeps scrolling for that case.
Two things had to give way for the label to actually get the width. The
"Preview with" row was pushing the stage wider than the column (its
select had a fixed max-width and no room to shrink), which scrolled the
label out of view sideways; it now shrinks with the stage. And below
560px the stage drops most of its padding, which is worth ~28 px of
label on a 393 px screen.
The print tab's preview had the same fixed 260 px assumption and gets
the same treatment -- it fit a 393 px phone by 12 px, and didn't fit a
320 px one at all.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
The extra-fields section only rendered once you had defined a field, so
the feature was invisible to anyone who hadn't -- and nothing in an
inspector said where the fields you DO see come from.
Spool, filament and vendor details now always show the section, with a
"Manage fields" link beside the header and a line saying there are none
yet. Forms and read-only mirrors keep the old hide-when-empty behaviour:
the add-spool modal and the filament block inside the spool inspector
would only be advertising an empty section someone else owns. The
section waits for the definitions to actually load, so "none yet" is an
answer rather than a flash.
The link has to land somewhere specific, so the manager's entity tab
moves into the URL:
/settings?fields=filament#extra-fields
which selects the tab and scrolls the page's own scroller to the
section, on a cold load as well as a client-side one. The tabs write
that URL as you switch, so the address bar always names the tab you are
looking at and it can be shared or bookmarked.
Switching entity now also closes an open field editor. It edits one
entity's field, and leaving it open across a tab change meant a save
could land on the wrong entity.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
A filament you own thirty spools of pushed the rest of the library off
the screen with no way to shut it, and nothing said the rows under a
header were ALL of that group's spools rather than the first few.
The group header answers both. Its right-hand meta gains a second line
under the weight -- mirroring the title/subtitle opposite it -- and a
disclosure sits at the trailing edge:
[swatch] PLA universal filament PLA 1.7 kg [v]
Clas Ohlson . 1.75 mm 13 spools
Putting it in the header rather than in a strip of its own is what makes
it work: the header is sticky, so the fold stays reachable while you are
scrolled deep inside a long group -- which is exactly when you want it.
The row is two sibling controls, not one nested in the other: the body
still opens the filament/vendor (inert for material/location groupings,
as before) and the disclosure is a real button, so both are keyboard
reachable. The glyph rotates, not the button, which would swing its box
past the edge of the row.
A collapsed group doesn't fetch at all. A page of groups costs one
request per visible group, so this is most of what collapsing buys on a
big library; collapsing mid-flight aborts through the existing effect
cleanup, and expanding keeps what was already loaded so the rows paint
at once and refresh behind it.
Collapsing also has to leave you where you were. Folding a long group
shrinks the scroller and the browser clamps it to the shorter content,
which used to leave you somewhere else in the library entirely, so the
group is pulled back into view afterwards (`nearest`, so it is a no-op
when it never moved). Measured on a 160-spool instance: scrollTop
1200 -> 1197 while the content shrank 346px.
The collapsed set is remembered per browser in localStorage, keyed
`field:key` so it works for vendor/material/location groupings too, not
just filament. Only collapsed groups are stored, so expanded stays the
default: a library that grows a new filament shows it, and a key that no
longer matches anything is simply never asked about again.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
Spool Weight asked beginners for a number they have no way of knowing, so
its help now names the usual ranges and offers two one-click starting
values. Location read as abstract metadata rather than the shelf the
spool goes on, so it gets an always-visible hint (not another popup —
testers didn't open those) and examples that look like places.
The default design was a QR and one text block holding name, material and
spool id. It printed, but it looked like a debug dump: no manufacturer
anywhere, no color anywhere, and no visual hierarchy, because a text
element carries one size, weight and color for its whole template.
Lay out the right-hand column as four elements instead of one:
Clas Ohlson <- manufacturer, 2.5mm grey
PLA universal <- name, 3.2mm bold, the only wrapping block
filament
PLA . #2 <- material . id, 2.5mm
[============] <- color swatch, bottom-aligned with the QR
Splitting them is what buys the hierarchy; it also lets the manufacturer
sit in a conditional block, so a filament with no vendor prints nothing
there rather than the bare "?" a plain placeholder resolves to. The
material does the same inside the id line, taking its " . " separator
with it.
DEFAULT_SPOOL_TEXT / DEFAULT_FILAMENT_TEXT now hold just that id line,
which is the only part that differs between the two label kinds. That
would have quietly broken the spool/filament toggle for designs already
saved: setDesignKind retargets a text block only when it still matches
the *other* kind's default, and every existing design carries the old
three-line template. So the comparison runs over a list of known default
pairs, newest first, with the pre-redesign pair kept as the second entry.
Also explain "Skip Items", which reads like a mystery until you know
sheets are reusable. The hint goes full-width under the row rather than
in the field's own column, where at three columns it stacked seven lines
deep, and opens with the field name like helpMargin and
helpPrinterMargin already do.
Verified in the browser against a real spool, and the kind-switch
retargeting -- current default, pre-redesign default, and a user-edited
template that must stay untouched -- is covered by unit tests.
Renaming a dashboard card only worked for Location, which has a
server-side rename that moves every spool in one statement. A custom
field had no equivalent, so the card name was read-only unless the group
was empty -- renaming it from the client would have meant patching each
spool one at a time, and only the ones the board had actually paged in.
Generalise the location rename to the spool's other string fields:
PATCH /spool/field/{field} {"value": "Shelf A", "new_value": "Shelf B"}
-> {"spools_updated": 6}
where field is `location` or `extra.<key>`. Archived spools are included
-- the value is the value, and skipping them would leave half the spools
behind. Renaming onto a value already in use merges the two, though the
dashboard still refuses that so it can't happen by accident.
The old value travels in the body rather than the path, unlike
/location/{name}. Field keys are ^[a-z0-9_]+$ and so are path-safe, but
values are not: a location like "Rack A/B" needs an encoded slash, which
proxies routinely mangle. No reason to carry that flaw into the general
form.
Only fields the spool owns can be renamed. material/vendor/filament are
refused because they belong to another entity, and rewriting them "for
these spools" would change filaments that other spools share -- the same
rule that decides what the dashboard can group by, so grouping and
renaming now share one validator.
Like rename_location, no spool event is broadcast per row: the change is
one statement over what may be hundreds of spools, and that fan-out is
what the websocket layer avoids elsewhere.
Verified on all four databases, and in the browser on a 40-spool group
of which the board had loaded 30 -- all 40 moved, and the card renamed in
place without losing its position.
The locations board could only ever answer one question: where is this
spool. The same card-and-drag interaction works for any field a spool
carries, so make the field a view mode and keep the rest.
Location is still the default view and behaves exactly as before. The
other views come from the user's own spool fields: any single-value text
or choice field now appears in a Group-by menu. A choice field renders a
card per choice, including the empty ones, which turns a "Status" field
into a kanban board.
Which field a card belongs to is decided by one small interface
(src/lib/dashboard/fields.ts), so the page never special-cases a field.
A field only qualifies if the SPOOL OWNS IT, because a drag moves one
spool and must mean exactly one change. That rules out material and
vendor, where writing one spool's card would silently rewrite every other
spool of the same filament, and lot number, which is stamped on by the
manufacturer and would be recording something untrue. Those stay library
filters. It also rules out numbers, dates, booleans and multi-choice,
whose values don't name a card you can drag into.
Backing changes:
- /spool/group grows group_by=extra.<key> for a spool's custom fields.
The field table is aliased so it stays a distinct FROM element -- the
extra-field filters reference the un-aliased table inside correlated
subqueries, and without the alias those would correlate to this join
instead of standing alone. Grouping is rejected for field types whose
value isn't a single repeatable string. group_by is no longer a
Literal, so an unsupported value is a 400 rather than a 422.
- The saved layout is per field: dashboard_groups and
dashboard_spoolorders replace the location-only pair. The old
`locations` / `locations_spoolorders` are read once as a seed for the
location view and never written back, so an upgrade keeps the shelves
it had and the old client keeps working.
- The view mode lives in the URL (?by=) so a board can be bookmarked and
shared, and is mirrored to localStorage so a bare /dashboard resumes
the last one looked at. The URL wins when it says something; a stale
?by= naming a deleted field falls back to location.
Verified against SQLite, PostgreSQL, MariaDB and CockroachDB -- Postgres
caught a real difference here, it sorts NULLs last where SQLite sorts
them first, so the new group assertions are keyed rather than ordered.
Buying the same filament in several colors meant re-entering every spec
through the add-spool modal for each one. Add a duplicate flow reachable
from three places, all sharing one startDuplicate() path:
- a Duplicate button in the filament inspector header
- a "duplicate" link on the chosen filament in add-spool step 2, for when
you found the right product but the wrong color
- "Add & new" after creating a filament, which now hands back the same
form pre-copied instead of an empty search
The copy carries manufacturer (reusing the existing vendor), material,
specs, weights, price, comment and custom fields. Color is cleared and
the article number dropped since it is a per-color SKU; external_id never
copies. The name is kept as a starting point with the caret at its end,
plus a nudge that stays until it is changed.
Also fixes two things this surfaced in the add-spool modal: the advanced
block had its Extruder Temp and Bed Temp labels swapped (stored data was
always correct, only the labels lied), and the name field showed a red
"Required" before the user had typed anything, which left no room for the
new naming hint. The error now waits for first input or blur; validation
and canSubmit are unchanged.
Vendors list the same filament in several sizes, so "Polymaker · PLA"
rows for the 1 kg and 250 g variants were indistinguishable in both the
catalog and SpoolmanDB result lists. Show the net weight on each row,
and on the chosen-filament card so the pick can be confirmed on step 2.
The row weight sits outside .res-name rather than being appended to the
vendor/material line: that element truncates with an ellipsis, which
would drop the one field that tells two matching results apart. The
chosen card's .cs line wraps instead, so a plain append is safe there.
A local filament's weight maps to 0 when unrecorded, so both row sites
guard on it rather than rendering "0 g".
Mirrors tests_frontend, but drives the Svelte client instead of the legacy
React one: same production image, same PostgreSQL-backed compose stack, same
"only the first navigation uses a URL" discipline.
- smoke.spec.ts clicks through every top-bar tab, asserting each route's title
and the app shell, with no browser console errors; a second test proves the
library reached the API rather than falling into its offline state.
- crud.spec.ts walks the first-use happy path. client_v2 folds the legacy
client's three create forms into one "Add spools" modal, so a manufacturer,
a filament and a spool are created by a single submit, then verified back in
the library list and on the locations page.
Two client_v2-specific helpers earn their keep: the top bar renders its nav
twice (desktop + mobile, one hidden by CSS) so nav locators need narrowing to
the visible one, and NumberInput's accessible name picks up its stepper and
help buttons, so numeric fields are found via their form item instead of
getByLabel.
The suite publishes on host port 8001 so it can run alongside the legacy one.
CI gains a test-frontend-v2 job cloned from test-frontend, gating
publish-images alongside it.
Also fixes tests_frontend/run.py: it only built client/dist, but the Dockerfile
copies client_v2/build unconditionally, so the image could not be built from a
clean checkout.
White on the dark theme's --accent (#be682f) is 4.03:1, so every solid accent
button failed WCAG AA -- the a11y audit flagged .btn.primary and .seg-btn.active
as serious. No foreground fixes it: on #be682f the best any color reaches is
5.21:1 (pure black), and every readable one scores worse than white.
So the fill becomes its own token rather than the accent moving. --accent-fill is
#b2612c, the minimal same-hue, same-saturation darkening that clears 4.5:1 (4.53)
-- 3.3 L* below --accent and imperceptible beside it. --accent is untouched and
still drives links, borders, washes and focus rings, where it sits on a dark
surface and passes comfortably.
Worth being precise about what is and isn't the brand color here: Spoolman orange
is #dc7734 (static/spoolman.svg, and the v1 client's colorPrimary). It appears
nowhere in client_v2/src -- both themes already ship a darkened adaptation of it,
7.5 L* down in dark and 12 L* in light, the latter labelled as such in app.css.
White on the brand orange itself would be 3.12:1. So this is a third step along an
adaptation already two steps in, not a new brand color, and the logo is untouched.
The fill DARKENS on hover (--accent-fill-hover #9b5526, 5.64:1) where --accent
lightens; lightening would drop straight back below AA. The light theme already
darkened on hover, so the two themes now agree. Light's own fill tokens just
mirror its existing accent pair (4.72:1 / 5.88:1) -- they exist so both themes
resolve the same variables, not because light needed changing.
Applied to every white-on-accent surface, not just the two axe caught: primary
buttons, the extra-fields manager's primary button, the mobile add button, the
spool inspector's active mode button, the settings segmented tab, and the date
picker's selected day and Done button. Toggle's track and the locations loading
bar keep plain --accent -- no text sits on them.
Audit is now clean on all six surfaces in both themes: zero axe violations.
The a11y audit reports 14 `label` and 2 `select-name` violations on the inspector
drawer, both critical, in both themes. Every editable field in the filament, spool
and vendor inspectors is affected: a screen reader announces the whole panel as a
column of unnamed edit boxes, so it is unusable there.
The cause is structural. AddSpoolModal wraps each control in a <label>, which names
it implicitly -- that surface has no violations. The inspectors instead use
FieldGrid, a two-column grid where <Field> emits the label as one cell and the
control as its sibling. Nothing connects them.
<Field> now publishes the id of its label text through context, and the generic
controls that can sit in a value cell -- EditableField, NumberInput, Combobox --
point aria-labelledby at it. Outside a <Field> the context is undefined and the
attribute is omitted, so the same components keep their implicit labels in
AddSpoolModal. aria-labelledby rather than <label for>: a value cell can hold two
controls (a range's min/max) or no labelable one at all (ColorEditor), where a
`for` would have to pick exactly one and would dangle when there isn't one. The id
goes on an inner span around just the label text, because the ⓘ help toggle is a
sibling and would otherwise fold "Help" into every name.
ExtraFieldInput names its inputs from field.name directly, since it already knows
it; range inputs get "<field> min"/"<field> max" so the pair is distinguishable.
This also fixes its boolean Toggle, which announced the type ("Boolean") rather
than the field, so every boolean extra field had the same name.
Audit is clean on all six surfaces in both themes. Verified the names resolve to
the visible labels rather than merely existing -- Name, Material, Diameter,
Density, ..., Integer range min, Integer range max, Single choice.
The list coalesces websocket events because one revision bump refetches the group
page and every visible GroupRow, so a burst -- a bulk import, a printer streaming
use updates, the initial sync -- would otherwise fire that whole fan-out per event.
It did so with a trailing debounce, which charges the 300 ms to every event rather
than to bursts. The overwhelmingly common case is a single change: one edit in
another tab, one printer reporting one spool. Each of those waited out the full
window before anything moved, which is exactly the latency a live view should not
have, and the burst it was paying for never arrived.
Same window, leading edge instead: the first event refetches immediately and opens
a cooldown, and anything arriving during it is folded into one catch-up refetch when
the cooldown ends. An isolated change is now instant; a burst still costs one
fan-out per window rather than one per event. The magic number is named
COALESCE_MS and the comment says what the window is trading off.
UnusedRow's sameWeight check only asks whether the collapsed spools agree, so a
group where they all agree on nothing takes the "Y g each" branch. mapSpool falls
back to 0 when a spool has no initial_weight and its filament has no weight either,
which makes that branch print "0 g each" and the divider "N unused - 0 g each" for
spools that are in fact full.
Treat a weight of 0 as unknown and fall through to the mixed variants, which state
the count and location and simply omit the quantity -- the same thing already done
when the spools genuinely differ.
18214ee fixed a real WCAG AA failure by brightening --text-muted/--text-dim/
--text-faint (dark) and darkening them (light), but it moved all three to nearly
the same place: 0xa0/0x97/0x90 in dark and 0x5f/0x66/0x6d in light, about 2.6 L*
apart. Three tokens exist to express three levels of emphasis; at that spacing they
render as one gray and the hierarchy they encode is gone.
The squeeze came from anchoring the whole ramp above the AA floor rather than on it.
#959595 is the darkest gray that clears 4.5:1 on the lightest dark surface
(--surface-raised #2e2e2e), so faint now sits exactly there and muted/dim step up
from it in even ~7 L* increments -- the same perceptual spacing the pre-AA ramp had.
Light mirrors it from the #676767 ceiling over --surface-sunken #e8e6e2, with
tighter ~4.5 L* steps because --text-2 #444444 caps the range.
Contrast against the worst-case surface in each theme is 7.0/5.6/4.5 (dark) and
6.3/5.4/4.5 (light). Every one of the six values moved further from its background
than 18214ee left it, so this cannot regress the audit it was fixing. The comments
now record where the floor comes from, so the next person retuning these knows
which end is fixed and why.
5eb660f turned svelte/no-navigation-without-resolve off globally because every hit
at the time was a false positive: the href had already been resolved by a params.ts
helper or handed in as a pre-resolved prop. Switching the rule off repo-wide also
removed the only automated guard against the real bug it exists to catch, and that
bug is one nobody developing at the root path can see -- a missing resolve() works
perfectly until an operator deploys under SPOOLMAN_BASE_PATH.
The rule is on again. The eleven known-good sites are silenced where they live:
- Ten .svelte files carry a file-level disable inside their <script>, with the
reason attached via the `--` description syntax. Per-line disables don't work for
most of them -- the rule anchors on the href attribute, which prettier puts on its
own line inside a multi-line element, where an HTML comment can't go.
- params.ts's two goto() calls take eslint-disable-next-line each. Both targets are
base-path-independent already: a bare `?query` resolves against the current URL,
and the other is built from resolve('/').
A new file, or a new component that builds an href itself, is caught again. Verified
by adding a raw <a href="/spool/show/1"> and watching the rule fire.
_load_filaments memoizes the parsed catalog on the cache file's st_mtime alone.
Two writes landing in the same mtime tick therefore look identical to the cache and
the second one is never picked up -- the process keeps serving the stale parse until
some later sync happens to change the stamp.
The window is small on Linux, where st_mtime is nanosecond-resolution, but it is not
theoretical: Spoolman's data directory is routinely a bind mount, and filesystems
with 1-second mtime granularity are common there. A catalog refresh triggered close
after another write would be silently dropped.
Including st_size makes the key sensitive to any rewrite that changes length, which
is the realistic shape of a catalog update.
load_and_rewrite_fallback_base_path does blind string replacement on 200.html to
insert the operator's base path into SvelteKit's absolute asset references and its
hardcoded `base: ""`. str.replace on a pattern that isn't there is a silent no-op,
so a SvelteKit upgrade that changes any of those three emission shapes would ship a
fallback document whose assets 404 and whose client-side router builds URLs without
the base path.
That failure is invisible in development and in CI, both of which run at the root
path where this code doesn't execute at all. It would surface only on an operator's
reverse-proxied instance, as a blank page on exactly the routes the rewrite exists
to serve -- direct loads of /spool/show/<id>, the target of printed QR labels.
Check each pattern is present before rewriting and raise naming the ones that are
not. A build/server mismatch is a packaging error, so refusing to start with an
actionable message beats serving a broken page.
Comments only. 2924850 removed the filament->spool fan-out, but neither comment
named remaining_length, which is the field 6ade405 added the fan-out for. That
omission is how someone re-adds it.
Spell out that remaining_length is derived from density and diameter and is not
cached client-side -- SpoolInspector computes it from the $derived filament, so
the filament event alone refreshes it -- while the stored initial/remaining
weights are the part that genuinely needs reweighSpoolsOfFilament.
client_v2 had no unit tests and no test script; the only spec was the Playwright
a11y audit, which is a separate run.
Adds vitest (`npm test`) configured for src/**/*.test.ts, and starts with the two
files the review called out.
labels/migrateV1.ts is the priority: it rewrites label templates and print presets
users authored in the v1 client, runs unattended at first load, and its output is
what they see afterwards -- a silent mistranslation quietly damages work they
can't recover. Covers the whole path-rewriting table, the bare/wrapped/extra-field
token forms, that a path with no v2 equivalent is left alone rather than mangled,
and the guards that keep a hand-edited preset from yielding a zero-size canvas.
utils/format.ts is second: it formats every weight, length and percentage on
screen. Pins the deliberate rounding rules -- notably that weightAuto floors kg so
a displayed "1.1 kg" never overstates what is on the spool -- and checks
lengthMeters against the known ~335 m for 1 kg of 1.75 mm PLA.
40 tests.
The var is the operator's rollback path if the new client misbehaves, but it was
only discoverable by reading env.py or the Dockerfile. Nothing else documents
Spoolman's env vars, so .env.example is the place.
Two gaps in the new /search endpoint, neither yet released:
Archived spools were always included, unlike GET /spool. /search now takes
allow_archived (default false) and applies the same false-or-null clause
spool.find uses, so the two endpoints agree on what "archived" hides. The
exact-id shortcut honours it too -- otherwise searching an archived spool's id
surfaced it regardless.
Candidate selection ordered by id ASC before LIMIT 200, so on a database with
more than 200 matches for a term only the 200 lowest ids were ever ranked and a
newly added spool could not be found at all. Ordering descending makes the newest
rows the ones that get ranked, which is the useful bias for an inventory that
only grows. Ranking still re-sorts the survivors by match quality, so this
changes which rows are considered, not how results are ordered.
Integration tests cover both defaults and both opt-ins.
filament.update re-broadcast every spool of the filament (archived included)
after commit, so a filament shared by hundreds of spools turned a single PATCH
into hundreds of ORM loads and websocket frames inside one request. This is the
unbounded fan-out flagged in review; the same reasoning is why no equivalent was
ever added for vendor.update.
Subscribers now treat a filament event as invalidating the spools that reference
it. In client_v2 the cache is already normalized -- spools hold a filamentId and
resolve their filament through filamentById -- so nearly everything a spool
displays was live regardless. The exception is a spool with no initial_weight of
its own, whose initial/remaining weights the server derived from filament.weight.
The cache now recomputes exactly those, mirroring Spool.from_db's formula
including its clamp at 0, which needs used_weight and the initial_weight override
carried on the cached spool.
Reverts the behavior added in 6ade405 (unreleased). Its integration test is
repurposed to pin the decision from both sides: no spool event reaches a /spool
subscriber, and the filament event subscribers rely on is still emitted.
Spool.from_db reports remaining_weight as max(initial - used, 0), but
find_groups' remaining_expr omitted the clamp. A spool used past empty
therefore contributed a negative number to total_remaining_weight, so a
group's total no longer matched the sum of the spools it contains — two
500 g spools, one with 200 g used and one with 800 g, reported 0 g instead
of 300 g.
Mirror the clamp with CASE rather than func.greatest, which is not portable
across all four supported databases. Covered by a new integration test.