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 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
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
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
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".
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.
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.
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.
AddSpoolModal closes on any Escape reaching <svelte:window>, but Combobox and
DateTimeField let their own Escape keep propagating. Dismissing a location,
vendor or material dropdown — or the date popover — inside the modal therefore
discarded the whole form. Regression from bf3896b, which moved the modal's
keyboard close onto the window.
Both now stopPropagation() after closing their own popup. DateTimeField's
listener is on document, which is still ahead of window in the bubble path.
reconnectAllNow() skips only OPEN/CONNECTING sockets, so a wake that fires
while a socket is still CLOSING opens a replacement. The old socket's onclose
then unconditionally cleared sock.pingTimer and nulled sock.ws, killing the
replacement's keepalive and leaving a live socket the code believes is down.
Guard the handlers with an identity check: onopen/onclose only touch the shared
ResourceSocket state while they are still the current socket, and each socket
now clears its own keepalive interval so a superseded one can't leak it.
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>