Turning filter values into checkboxes that leave the menu open broke the
`addFilter` helper in two ways, and with it the grouping test that uses it
to pin the list down to its own spools.
An explicit `role="menuitemcheckbox"` replaces a button's implicit role, so
the helper's `getByRole("button")` no longer matched a value row at all;
and having picked one, the helper handed back a toolbar with the menu still
covering it, which the next call could not open through. It now asks for
the role the row actually has, and closes the menu itself, restoring the
postcondition every caller was already relying on.
The row's accessible name needed a real space rather than the margin that
had been standing in for one: without it a filament read as one
run-together word, "Galaxy BlackPLA".
Narrowing a library to three filaments meant opening the filter menu,
stepping into Filament, picking one, and doing all of it again twice
more. The menu closed on every pick, and nothing in it said which values
were already on — the chips that say so sit in the toolbar, behind the
menu that covers them.
A value row now carries a checkbox and its state, the same way the "Show
archived" row already does, and picking one leaves the menu open. The two
go together: once the values are marked, the list reads as a multi-select,
and closing after a single pick is the surprising half. Escape and a click
outside still close it, and picking a filter property or a date range still
does too, since neither is a set.
Nothing has to be preserved by hand for this to work. Each toggle is a
query-only navigation that keeps the component mounted, and it already
asked to keep focus and not to scroll, so the search box stays focused and
the list stays where it was.
The Ctrl-modifier the issue suggested is deliberately not implemented: a
checkbox list that stays open needs no hint text to explain itself.
Closes#1090Closes#1089
The filament filter listed nothing but a label, which is the manufacturer
and the name. That is not enough to pick from: the colour is the thing
you actually recognise a filament by, and two filaments may legitimately
carry the same name in different materials, leaving the menu offering two
entries that read identically.
Each option now carries whatever it has beyond its label, so the menu
draws a swatch and the material where they exist and stays a plain list
of names where they don't (locations, lot numbers). The material sits
with the label rather than off at the row's right edge, since it is there
to qualify the name it follows. None of it costs a request: the option
list is built from whole filaments already fetched.
The search box narrows on the material too. It is on screen, so a query
that visibly matches a row has to keep it — and since terms match in any
order, "prusament petg" now finds one filament out of two same-named
ones.
Closes#1087Closes#1088
The change-filament dialog could only pick a filament that already existed,
in the catalog or in SpoolmanDB. But the only way to create one anywhere in
client_v2 is the add-spools flow, which always mints at least one spool — so
refilling a shelf slot with a roll nobody had catalogued meant adding a
throwaway spool and deleting it again, handing out exactly the new spool id
the feature exists to avoid (issue #1010).
The dialog now offers the same third path the add-spools flow does. Picking
it swaps the search for the new-filament form and applies as one action:
the filament (and its manufacturer) are created, then the spool is repointed
at it. The spool keeps its number, location, dates and usage history.
The form itself moves to NewFilamentCards, shared with AddSpoolModal so both
dialogs keep the per-record card layout from #1038, with its rules and its
mapping onto the API in lib/filament/draft.ts. The one difference is the
weight/spool weight/price trio: the add-spools flow writes those to the spool
it creates as well, and so keeps showing them in its spool block, while here
they belong to the filament alone.
Master reorganized the spool half of the add form into headline / fill /
where / money groups and added the roll-size presets. Kept that layout and
re-applied the required-field wiring on top of it: the data-field hooks, the
per-field touch handlers and the err() calls that decide when an error shows.
FIELD_ORDER follows the new reading order too, so Fill level now comes before
Spool Weight when the form picks which bad field to jump to.
Leaving Material blank sent you to Density instead. Density is only prefilled
for a material Spoolman recognises, so a blank material surfaced as "Density
is required" — an error on the field you didn't fill in because of the field
you did miss, and inside the collapsed Advanced block at that.
Material is now required on this form, so the error lands on the field that
actually needs attention and Advanced stays shut. Typing a material we don't
have specs for still asks for a density afterwards, which is the right order
to be asked in.
Client-side only: the API still accepts a filament with no material, so
existing ones are unaffected.
The summary panel above the add button was more machinery than the problem
needed, and it spent five translation strings restating what the inline
field errors already say. Drop it.
Pressing Add still reveals every outstanding error and still opens the
collapsed Advanced block when the problem is hiding in there; it just takes
you to the first bad field and lets the error under it do the talking. The
issue count on the Advanced toggle and the "* required" legend go too.
The change now adds no new translation strings at all.
An incomplete add-spool form was answered with a dead Add button, leaving
"which field haven't I filled in?" to be found by eye. Worst case the answer
was invisible: density is required but only prefilled for known materials,
and it lives inside the collapsed Advanced block, so the form could look
complete and still refuse.
Add stays clickable. Pressing it on an incomplete form reveals every
outstanding error at once, lists them above the button as rows that jump to
their field, opens the section hiding one, and puts the caret in the first.
The list stays as a checklist and shrinks as fields are fixed. Per-field
errors now appear once you leave a field rather than immediately, so a
freshly opened form is quiet.
Make the asterisk mean one thing: every required field carries it and
nothing else does. Count was missing one. A "* required" legend says what it
means, and the controls carry aria-required/aria-invalid so it is not
conveyed by a glyph alone.
The extra-fields editor in settings follows the same convention: asterisks
on key, name and choices, and a save error now outlines and focuses the
field it came from.
A `background` shorthand resets background-origin to the padding box, so
the slider's gradient was sized to the padding box but painted out under
its 1px border — and backgrounds repeat, so the strip under the border
showed the far edge of the next tile: a hairline of full-opacity colour
down the transparent end. It surfaced wherever the border doesn't cover
that strip exactly, i.e. Firefox at fractional display scaling.
Anchor every layer to the border box, and keep the colour gradient from
repeating at all.
The spool fields were one grid of interchangeable boxes, so the roll-size
shortcuts had nowhere to go but a second row under the weight field. Order
them by how easily they are answered instead: count and weight share the top
line, with the sizes beside the weight rather than under it; fill level and
location follow; spool weight and price drop to their own row as the numbers
that come off a product page; and lot number, usage dates and comment fold
into a 'More details' disclosure like the filament half above already does.
Net result is a shorter form than before the shortcuts existed.
The weight field in the add-spool modal now offers 250 g / 500 g / 750 g /
1 kg / 2 kg / 3 kg as one-click shortcuts, with the one matching the current
value marked. Closes#1051.
The legacy client could set a filament's alpha channel, and the API and
database have stored an 8-digit #RRGGBBAA code ever since the color_hex
alpha migration, but client_v2 had no way to edit it (#1059).
Each row of the colour editor gets an opacity slider and a percentage
field, both backed by the hex code's alpha byte, so a single- or
multi-colour filament can be marked translucent. Full opacity drops the
alpha channel again, keeping plain colours 6-digit for the API and for
ecosystem consumers, and the native RGB picker no longer wipes an alpha
that is already set.
Swatches layer a translucent colour over a checkerboard so it reads as
transparent rather than as a different, lighter colour.
The Library's sort and filter menus grow with the install. Every custom
extra field adds a sort field and a filter property, and a filter's
values are the whole library: every filament, every location, every lot
number. Past a point the only way through them was to scroll.
Each list now carries a search box once it is long enough to earn one,
so the group menu's five fixed entries stay as they were. Matching is a
plain substring test — these lists are on screen while you type, so a
fuzzy match that surfaced "Location" for "lot" would read as a bug —
widened only by folding diacritics and by letting whitespace-separated
terms match in any order, since a filament reads "<maker> <name>".
Escape clears the query, then closes the menu; Enter takes the first
entry still listed. The box takes focus as the menu opens, but only
where the pointer is fine: on a touch device that same focus would
raise the keyboard over the list it filters.
Closes#1045
The add-spool form can create a manufacturer, but offered no way to fill its
custom fields, unlike the filament and spool it creates alongside. Show them
in the manufacturer card, and send them with the POST that creates it.
The inputs only appear while the typed name is a new one: matching an
existing manufacturer links that record, and must never rewrite its values.
Fixes#1055
The two commits before this one invented `<field>_after`, `<field>_before`
and `<field>_unset` for the built-in timestamps. That was a third way to
ask a question this API already answers: extra-field filters have taken
`<start>|<end>` for datetimes and `<min>:<max>` for numbers since v0.26.0,
with an empty value meaning "unset", the same as it means for `location`
or `lot_nr`.
Two grammars for one question is bad on its own, but the split was worse
than cosmetic. Someone who learned `extra.opened_on=2025-01-01T00:00:00Z|`
and tried `last_used=2025-01-01T00:00:00Z|` got every spool back, because
an unknown query param is silently ignored.
So the eight parameters become three, named after their columns and
taking the shipped grammar:
/spool?last_used=2026-08-01T00:00:00Z| since
/spool?last_used=|2026-01-01T00:00:00Z until
/spool?last_used=<start>|<end> between
/spool?last_used= never used
Commas OR several of those, and quotes are accepted and ignored, so every
filter on the endpoint is spelled the same way. `_unset` disappears: the
empty value already means unset everywhere else here. No new convention
enters the API, and the next filterable column costs one parameter rather
than three.
Two consequences worth naming. Malformed input is now a 400 with a message
instead of FastAPI's automatic 422, matching the other unparseable filters
on this endpoint. And "has a timestamp" (the old `_unset=false`) has no
spelling any more, because the shipped grammar has none; nothing offered
it in the UI.
The client is unchanged apart from how it builds the request. The chip
value in the app URL keeps its own grammar, since it also carries relative
offsets and "never", neither of which the API takes.
Step 2 of the add-spool modal creates up to three records at once, but laid
them out as one flat run of fields, so there was no way to tell which record
a field would land on. The vendor combobox in particular sat among the
filament fields while quietly creating a second entity.
Lay the step out as one labelled block per entity: the manufacturer and the
new filament each get their own card, and the spool fields get a heading of
their own instead of just following on. Weight, spool weight and price are
written to both the new filament and the spool, so say so rather than let
the headings imply otherwise.
Closes#1038
A date range cannot express "no date at all": a NULL fails every
comparison, so no bound, however far back it reaches, selects the spools
that have never been used. That state needed a filter of its own.
Backend: /spool and /spool/group take first_used_unset / last_used_unset.
True matches only the spools missing that timestamp, false only those
that have one. Registered gets no such param — the server sets it at
creation and a spool never lacks one.
Client: the chip value `never` sits alongside the range grammar, and
Last Used / First Used offer it under their presets. It is labelled
"Never", deliberately not "unused": the list already calls a spool unused
when no weight has come off it, which is a different claim — a spool can
be unused and still carry a last_used date, or have used weight and no
date at all.
The library could filter by every discrete property a spool has but by
none of its timestamps, so "what have I used this week" and "what has
been sitting untouched for a year" were questions the UI could not ask.
Backend: /spool and /spool/group take first_used / last_used /
registered `_after` and `_before` bounds, each optional, inclusive, and
resolved against the UTC-naive form the columns store (an offset-aware
value is converted; a naive one is read as UTC). A spool with no
timestamp matches neither end.
Client: the Filter menu gains the three timestamps, each opening presets
(last 24 hours ... older than 1 year) plus a custom range whose empty
end means open. A range lives in the URL as a chip value, `<from>..<to>`,
where an end is a calendar day or an offset back from now. Relative ends
stay relative all the way into the URL and are resolved at request time,
so a bookmarked "last 24 hours" keeps meaning the last 24 hours instead
of drifting into "since the day I made it".
Most of the world writes 1,75 rather than 1.75, and `<input type="number">`
only accepts the separator the browser locale dictates: everything else it
reports as an empty value. A user typing a comma therefore watched a weight
or a diameter silently come out blank or, once the separator was dropped,
a hundred times too large. The same input type also accepts "e", "+" and "-"
anywhere in the text, since they are legal exponent syntax, and then reports
the whole value as empty too, so "1e" read back as nothing at all.
Numeric fields are now text inputs with `inputmode="decimal"`, guarded by a
shared `numericInput` action that filters at the keystroke: both separators
go in, letters cannot, and a pasted "12,5 g" is cleaned rather than refused.
Reading is centralised in `parseDecimal`, which takes either separator and
returns null instead of NaN for text that is not a number, so an unset field
stays distinguishable from a real 0. What the parent, and the API, receive is
always canonical.
The typed separator stays on screen: each field keeps a draft of the text and
gives it up only when the value moves somewhere that text no longer means, so
storing 1.5 does not rewrite a typed "1,5" between keystrokes. Arrow-key
stepping, which was native to the old input type, is now the component's own.
Covers the spool/filament/vendor inspectors, the add-spool modal (whose price
box was a bare text input accepting anything at all), extra fields of every
numeric type, the extra-field editor's order box and the low-stock threshold.
Folding the remembered grouping into parseLibraryState left the address
bar saying nothing while the list was grouped, and that gap broke the
group control: picking the default grouping serialises back to the very
same bare URL, and an unchanged URL means SvelteKit reuses its cached
load, so the choice never took effect. The load was also no longer a
pure function of the URL, which is what that per-URL cache assumes -
preloading the nav tab cached a redirect built from the old preference
and replayed it.
The page now navigates on arrival to the URL that spells the remembered
view out, replacing the history entry. The load goes back to reading the
URL alone, the view stays linkable, and every distinct view has a
distinct URL again. Only on arrival: a goto is a choice the user just
made and a popstate is Back, and overruling either would be wrong.
Group and sort live in the URL, so they survive a reload of a library
link but not a trip to Settings and back: every other route links to a
bare `/`, which handed back the shipped view and made a deliberately
chosen grouping something the user re-picked several times a day.
A URL that mentions neither grouping nor sorting now means "however I
had it last", restored from localStorage; one that mentions either still
wins outright, so a shared link shows its recipient what its sender saw.
Only the layout is remembered - filters, search and archived-visibility
hide spools, and one silently restored days later reads as missing data.
Fixes#1036
Label designs are authored in millimeters and rasterized at the layout's
DPI, but canvas.toDataURL() writes no pHYs chunk, so every reader falls back
to 96 dpi. A 50x25mm label exported at 300 dpi opened as a ~156x78mm image,
and printing it "at 100%" produced a label three times too big.
Stamp pHYs with the layout's density on the way out, replacing any existing
chunk and inserting after IHDR so it precedes IDAT as the spec requires.
Anything that doesn't parse cleanly as a chunk stream is passed through
untouched — a wrong DPI beats a corrupted download.
Verified independently with Pillow: the stamped files are valid PNGs and
read back as exactly 300 and 203 dpi.
AML is unaffected; it carries the label size in mm in its own header.