Skip to content

feat(ui): printer photos - #176

Merged
ChristopherHoffman merged 24 commits into
mainfrom
feat/printer-images
Oct 5, 2026
Merged

ChristopherHoffman merged 24 commits into
mainfrom
feat/printer-images

Conversation

@ChristopherHoffman

Copy link
Copy Markdown
Contributor

Lets users attach photos to their printers, and shows a printer's default photo everywhere a printer is named — so a multi-printer user can tell their machines apart at a glance.

Needs HoffmanEngineering/3d-print-log-api#114.

What's here

The editor. The filament images panel is generalized into EntityImagesPanelComponent, parameterized by an EntityImageTarget — an ID and the gateway that understands it, travelling together so a printer ID can never be handed to a material's endpoints. FilamentImageComponent becomes SignedImageComponent. Printer detail gets the panel, with the create flow sequenced so the new ID is written back onto the form before the uploads run — without that, retrying after a failed upload creates a second printer.

The cross-cutting surfaces. PrinterAvatarComponent renders the default photo on the print list, print cards, the grouped view, print detail, the printer picker, maintenance and analytics. All seven read one per-user map through PrinterThumbnailStore rather than adding a signed URL to five DTOs. The store's endpoint is authenticated-only, so an anonymous visitor gets an empty map and therefore no avatars — a structural guarantee rather than a guard repeated on seven surfaces.

Upload failures are classified. The panel prechecks size and type before uploading, and separates a 400 (these bytes will never be accepted — no retry offered) from anything else (retry offered). The callers navigate on allSucceeded rather than on an empty failure array, so a permanent rejection cannot look like success.

Three bugs found after the first pass

An adversarial review plus a real browser run turned up three things worth calling out, each now covered by a test that fails without the fix:

  1. The avatar took the print list down. thumbnailFor() started its fetch inline, which writes a signal — and the avatar reads it from a computed(), where Angular forbids that (NG0600). The error propagated out of the table cell holding the avatar, so /prints rendered with no rows at all. Every test passed straight through it, because they all stubbed the store. The read now schedules the fetch on a microtask, and the avatar spec gained a suite that renders against the real store.

  2. Deferred writes were cancelled by the navigation they triggered. uploadStagedImages reported success before the set-default and reorder requests landed; the caller then navigated away, destroying the panel and cancelling them through takeUntilDestroyed. The photo a user starred while it was still staged was silently dropped. Those writes are now chained into the returned observable.

  3. A failed thumbnail could never recover. SAS signing is bucketed to six hours server-side so a re-signed URL is byte-identical — deliberately, so the browser image cache can hit. The avatar suppressed the exact URL that failed, so inside a bucket the refreshed value was the value it was suppressing. The store now exposes a generation bumped by each completed fetch, and suppression is scoped to it: one attempt per refetch, and a genuinely dead blob re-suppresses.

Also fixed: SignedImageComponent gained an explicit fit input. The printer list's square thumbnail rendered letterboxed because its ::ng-deep override tied on specificity with the component's own rule — a caller cannot win that race with CSS alone.

Screenshots and docs

The home and docs captures are regenerated with printer photos, and three demo printer photos are committed. The fixture's makes and models were changed to match them — a screenshot of a Snapmaker labelled "(Bambu Lab A1)" is worse than no photo at all.

The five affected capture targets gain imagesLoaded('app-printer-avatar'), which waits for decode rather than for a DOM node the way imagesRendered does — a still-loading <img> already satisfies the old gate, which is the exact race that loses a photo from a capture. first-print-form is deliberately not gated: its avatars live in mat-options that only exist while the select overlay is open, and a figure of an open dropdown is not what that tutorial step shows.

src/content/docs/printers.md gains a Photos section, including that printer photos are never shown on a print's public page.

Testing

  • npm run test:brief — 1748 passing.
  • npm run lint:brief clean; npm run test:scripts 415/415; node scripts/validate-docs.mjs passes.
  • npx cypress run --spec cypress/e2e/prints/public-print-anonymous.cy.ts against a local API in E2ETesting — 3/3. It now asserts a logged-out visitor renders no avatar and issues no /api/Printers/thumbnails request at all.
  • Both capture sets regenerated and inspected by eye.
  • Manual browser pass against a local API: created a printer with two staged photos, starred the second, confirmed both uploaded and that the starred one became the list thumbnail and the picker avatar.

One thing to flag

AGENTS.md says user-facing docs live in src/documentation. That's stale — authored pages are src/content/docs/*.md, and src/app/documentation/generated/ is build output. Not fixed here to keep the diff on-topic.

printer-detail, printer-list and printer-maintenance were all xdescribe with bare
scaffolds that could never have run: no providers, no resolver data. Each now has a
configured TestBed and assertions covering what the component actually does - resolver
handoff, the category-driven field enabling, the create-vs-update branch.

Left as-is otherwise; this is setup repair, not new behavior.
FilamentImagesPanelComponent becomes the shared EntityImagesPanelComponent, driven by
an EntityImageTarget rather than a FilamentService injection. The ID and its gateway
travel in one object: binding them as two independent inputs would re-erase the
generic, letting a printer ID pair with a material gateway.

FilamentImageComponent becomes SignedImageComponent, with alt now required - it is the
only place the subject reaches the accessibility tree, so a shared default would label
every image on the page identically.

Every behavioral test moved with the panel and still passes; the URL-shape assertions
moved to filament-image-gateway.spec.ts, because a URL assertion against a generic
panel would only be testing the test double.
…cceeded

persistDefault and flushReorder ran as independent subscriptions started from a
tap, so uploadStagedImages emitted before they landed. The caller navigates away
on that emission, which destroys the panel and cancels them through
takeUntilDestroyed - silently dropping the photo the user starred and the order
they dragged. They are chained into the returned observable instead.

A file the API refused with a 400 is also no longer re-posted by the parent Save
button, which called uploadStagedImages directly and so bypassed the hidden Retry
button. It is still reported, so a save that left it behind is not a clean success.
…refetch

SAS signing is bucketed to six hours server-side so a re-signed URL is
byte-identical, which is what lets the browser image cache hit. The avatar
suppressed the exact URL that failed, so inside a bucket the refreshed value was
the value it was suppressing and a transiently-failed photo stayed hidden until
the bucket rolled. The store now exposes a generation bumped by each completed
fetch, and suppression is scoped to it: one attempt per refetch, and a genuinely
dead blob fails again and re-suppresses.

Logout also clears the map directly rather than waiting for the store's next
read, so the previous user's photos cannot survive a session that outlives the
Auth0 redirect.
PrinterAvatarComponent reads thumbnailFor() from a computed, and thumbnailFor
started the fetch inline - which sets phase and, on the sync paths, map. Angular
rejects that with NG0600, the error propagated out of the cell template that held
the avatar, and the print list rendered with no rows at all.

The read now schedules the check on a microtask instead. Every stubbed-store test
passed straight through this, so the avatar spec gains a suite that renders
against the real store: it reproduces NG0600 without the fix.
The handset card view names the printer too, and was missed when the other print
surfaces got avatars - which the print-list-cards doc figure would have shipped
without one.
Adds three demo printer photos and points printers-summary.json at the machines
they actually show - a screenshot of a Snapmaker labelled "(Bambu Lab A1)" is
worse than no photo at all. printer-thumbnails.json resolves through the capture
harness rather than a blob host, so the images come from the repo.

The five affected targets gain imagesLoaded('app-printer-avatar'), which waits
for decode rather than for a DOM node the way imagesRendered does. first-print-form
is deliberately not gated: its avatars live in mat-option elements that only exist
while the select overlay is open, and a figure of an open dropdown is not what
that tutorial step shows.
The printer list's square thumbnail rendered letterboxed. Its ::ng-deep override
tied on specificity with the component's own `.signed-image img` rule, so which
one applied came down to stylesheet order - and the caller lost. A parent cannot
win that race with CSS alone, so fit is an input bound to an inline style
instead: contain for the carousel, cover for a fixed-size thumbnail.
Drops what a glance at the repo already answers: the module/service inventories,
the environment file list, and the boilerplate TestBed and LoggingService
snippets. 270 lines to 182.

Adds four things that were learned the hard way:
- NG0600 - a signal written during computed() evaluation throws out of the
  template that read it, so a lazily-fetching store getter takes down the whole
  subtree rather than logging a warning.
- Stubbing every collaborator hid exactly that: a green suite over a print list
  that rendered zero rows in a real browser.
- lint-staged does not format .html, so a template edit can silently flip a file
  to CRLF.
- A component input beats a ::ng-deep override, which only ties on specificity.

Also corrects the docs path: authored pages are src/content/docs/*.md, and
src/app/documentation/generated/ is gitignored build output.
@ChristopherHoffman
ChristopherHoffman merged commit 2eb0252 into main Oct 5, 2026
5 checks passed
@ChristopherHoffman
ChristopherHoffman deleted the feat/printer-images branch October 5, 2026 02:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant