Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
904223c
test(ui): re-enable the disabled printer component suites
ChristopherHoffman Sep 10, 2026
2b81894
feat(ui): expose the subscription image cap as maxImages
ChristopherHoffman Sep 10, 2026
e5485ba
refactor(ui): generalize the images panel behind an entity target
ChristopherHoffman Sep 10, 2026
06bca11
feat(ui): add printer thumbnail store with explicit fetch state
ChristopherHoffman Sep 10, 2026
7673e94
feat(ui): add printer image service methods, target factory and store…
ChristopherHoffman Sep 10, 2026
ff9e725
feat(ui): precheck uploads and separate permanent from transient fail…
ChristopherHoffman Sep 10, 2026
f6fe55c
feat(ui): add photos panel to printer detail with create-flow recovery
ChristopherHoffman Sep 10, 2026
8d5b9df
feat(ui): show default printer photo in the printer list
ChristopherHoffman Sep 10, 2026
754d528
feat(ui): add printer avatar component
ChristopherHoffman Sep 10, 2026
403baf9
feat(ui): show printer avatars on the print surfaces
ChristopherHoffman Sep 10, 2026
0cc525d
feat(ui): show printer avatars in maintenance and analytics
ChristopherHoffman Sep 10, 2026
1c18a08
test(ui): assert no printer photos or requests on anonymous public pr…
ChristopherHoffman Sep 10, 2026
1c22833
docs: document printer photos
ChristopherHoffman Sep 10, 2026
0652471
test(ui): stub the printer thumbnail map for captures and add an imag…
ChristopherHoffman Sep 10, 2026
a55d24a
style(ui): normalize line endings on the templates this branch touched
ChristopherHoffman Sep 10, 2026
39778a1
fix(ui): wait for deferred image writes before reporting an upload su…
ChristopherHoffman Sep 10, 2026
02cc03f
fix(ui): give a failed printer thumbnail a fresh attempt after every …
ChristopherHoffman Sep 10, 2026
d1a0a60
fix(ui): stop the thumbnail store writing signals inside a computed
ChristopherHoffman Sep 10, 2026
9df6477
feat(ui): show printer avatars on the print cards
ChristopherHoffman Sep 10, 2026
9f0cfcc
chore(ui): regenerate home and docs captures with printer photos
ChristopherHoffman Sep 10, 2026
8aebae5
test(ui): follow the thumbnail store's deferred fetch in its spec
ChristopherHoffman Sep 10, 2026
0aabfc3
fix(ui): give SignedImageComponent an explicit fit input
ChristopherHoffman Sep 10, 2026
18eb04d
docs: trim AGENTS.md and record what this feature cost
ChristopherHoffman Sep 10, 2026
0148868
Merge branch 'main' into feat/printer-images
ChristopherHoffman Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 53 additions & 141 deletions AGENTS.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions cypress/e2e/prints/public-print-anonymous.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ describe('Anonymous public print view', () => {
// Registered before the visit so the attachment fetch is captured.
cy.intercept('GET', '**/api/Prints/*/files*').as('attachments');

// The thumbnail map is authenticated-only, so a logged-out visitor must never
// even ask for it - that is what keeps a printer's photos off a public print.
cy.intercept(
'GET',
'**/api/Printers/thumbnails*',
cy.spy().as('thumbnailRequest')
);

cy.visit(`/prints/${print.id}?devUserId=anonymous`);

// 1) No bounce to home (#66).
Expand All @@ -41,6 +49,8 @@ describe('Anonymous public print view', () => {
cy.get('[data-cy-edit-btn]').should('not.exist');
cy.get('[data-cy-printer-link]').should('not.exist');
cy.get('a[href*="/filament/"]').should('not.exist');
cy.get('app-printer-avatar img').should('not.exist');
cy.get('@thumbnailRequest').should('not.have.been.called');

// 4) Cost absence is asserted but is NOT evidence of gating, and must
// not be cited as such. Material cost needs a recorded filament price
Expand Down
12 changes: 6 additions & 6 deletions cypress/fixtures/demo/filaments.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@
"loadedInPrinter": {
"id": 103,
"name": "Resin Station",
"make": "Anycubic",
"model": "Photon Mono M7 Pro",
"make": "HeyGears",
"model": "Reflex RS Turbo",
"isActive": true,
"category": null
},
Expand Down Expand Up @@ -200,8 +200,8 @@
"loadedInPrinter": {
"id": 101,
"name": "Living Room",
"make": "Bambu Lab",
"model": "A1",
"make": "Snapmaker",
"model": "U1",
"isActive": true,
"category": null
},
Expand Down Expand Up @@ -247,8 +247,8 @@
"loadedInPrinter": {
"id": 102,
"name": "Workshop",
"make": "Prusa",
"model": "MK4S",
"make": "Anycubic",
"model": "Kobra S1",
"isActive": true,
"category": null
},
Expand Down
21 changes: 21 additions & 0 deletions cypress/fixtures/demo/images/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,24 @@ and are committed here with permission.
Fetched via `node scripts/fetch-demo-images.mjs`, which calls
`GET https://api.3dprintlog.com/api/Prints/{id}/image/{imageId}`
with header `allow-anonymous-request: true`.

## Demo printer photos

Photos of the machines `printers-summary.json` names, shown by `app-printer-avatar`
wherever a printer appears. They belong to the repo owner and are committed here with
permission.

| File | Printer id | Machine |
| ---------------------------- | ---------- | ------------------------ |
| snapmaker_u1.jpg | 101 | Snapmaker U1 |
| anycubic_kobra_s1.jpg | 102 | Anycubic Kobra S1 |
| heygears_reflex_rs_turbo.jpg | 103 | HeyGears Reflex RS Turbo |

`fetch-demo-images.mjs` does **not** refetch these - they were placed by hand, downscaled
to the 720px-wide convention the print photos use. `PRINTER_IMAGE_MAP` in
`cypress/fixtures/demo/manifest.ts` maps each printer id to its file, and
`printer-thumbnails.json` points at `/api/Printers/{id}/thumbnail` so the capture harness
serves them from the repo rather than from a real blob host.

The fixture's makes and models were changed to match these photos. A screenshot showing a
Snapmaker beside "(Bambu Lab A1)" is worse than no photo at all.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added cypress/fixtures/demo/images/snapmaker_u1.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 36 additions & 0 deletions cypress/fixtures/demo/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
exists,
FixtureRoute,
imagesRendered,
imagesLoaded,
noPlaceholders,
rendered,
visible,
Expand All @@ -37,6 +38,13 @@ export const FIXTURE_ROUTES: FixtureRoute[] = [
url: '**/api/printers/summary*',
fixture: 'demo/printers-summary.json',
},
// Every surface that names a printer reads this map through PrinterThumbnailStore.
// Without a stub the capture run fails on an unhandled /api/** request.
{
method: 'GET',
url: '**/api/Printers/thumbnails*',
fixture: 'demo/printer-thumbnails.json',
},
{ method: 'GET', url: '**/api/Filaments?*', fixture: 'demo/filaments.json' },
// The add-print form offers to attach the print to a project. The demo set has
// no projects, and an empty list is the right state for a first-print figure:
Expand Down Expand Up @@ -91,6 +99,17 @@ export const PRINT_IMAGE_MAP: Record<string, string> = {
'1005': 'demo/images/cat-headbands.jpg',
};

/**
* The three demo printers in printers-summary.json, each with a photo of the machine the
* fixture names. `printer-thumbnails.json` points at `/api/Printers/{id}/thumbnail`, which
* `stubApi` serves from this map.
*/
export const PRINTER_IMAGE_MAP: Record<string, string> = {
'101': 'demo/images/snapmaker_u1.jpg',
'102': 'demo/images/anycubic_kobra_s1.jpg',
'103': 'demo/images/heygears_reflex_rs_turbo.jpg',
};

/** The five demo prints in prints-summary.json. */
const DEMO_PRINT_COUNT = 5;

Expand Down Expand Up @@ -135,6 +154,9 @@ const HOME_CAPTURE_TARGETS: CaptureTarget[] = [
// 959.98px) the card view carrying app-print-card is never rendered.
rendered('[cy-print-row]', DEMO_PRINT_COUNT),
imagesRendered('app-print-image'),
// Decoded, not merely present: a photo still in flight is exactly what a
// capture loses silently.
imagesLoaded('app-printer-avatar'),
rendered('app-filament-color-swatch', DEMO_MATERIAL_COUNT),
],
},
Expand All @@ -147,6 +169,9 @@ const HOME_CAPTURE_TARGETS: CaptureTarget[] = [
ready: [
rendered('[cy-print-row]', DEMO_PRINT_COUNT),
imagesRendered('app-print-image'),
// Decoded, not merely present: a photo still in flight is exactly what a
// capture loses silently.
imagesLoaded('app-printer-avatar'),
rendered('app-filament-color-swatch', DEMO_MATERIAL_COUNT),
],
},
Expand Down Expand Up @@ -183,6 +208,7 @@ export const HOME_CAPTURE_SET: CaptureSet = {
targets: HOME_CAPTURE_TARGETS,
fixtures: FIXTURE_ROUTES,
printImages: PRINT_IMAGE_MAP,
printerImages: PRINTER_IMAGE_MAP,
// The filter panel is hidden because the home crops want the data, not the
// chrome - at every width, now that these are captured at desktop size. The
// analytics tab's lone "Export this tab (CSV)" button goes for the same
Expand Down Expand Up @@ -223,6 +249,9 @@ const DOC_CAPTURE_TARGETS: CaptureTarget[] = [
ready: [
rendered('[cy-print-row]', DEMO_PRINT_COUNT),
imagesRendered('app-print-image'),
// Decoded, not merely present: a photo still in flight is exactly what a
// capture loses silently.
imagesLoaded('app-printer-avatar'),
rendered('app-filament-color-swatch', DEMO_MATERIAL_COUNT),
],
}),
Expand All @@ -248,6 +277,9 @@ const DOC_CAPTURE_TARGETS: CaptureTarget[] = [
ready: [
rendered('[cy-print-row]', DEMO_PRINT_COUNT),
imagesRendered('app-print-image'),
// Decoded, not merely present: a photo still in flight is exactly what a
// capture loses silently.
imagesLoaded('app-printer-avatar'),
// The Materials column, which the caption below this figure describes.
rendered('app-filament-color-swatch', DEMO_MATERIAL_COUNT),
],
Expand Down Expand Up @@ -278,6 +310,9 @@ const DOC_CAPTURE_TARGETS: CaptureTarget[] = [
ready: [
rendered('app-print-card', DEMO_PRINT_COUNT),
imagesRendered('app-print-image'),
// Decoded, not merely present: a photo still in flight is exactly what a
// capture loses silently.
imagesLoaded('app-printer-avatar'),
rendered('.material-chip', DEMO_MATERIAL_COUNT),
],
}),
Expand All @@ -299,6 +334,7 @@ export const DOC_CAPTURE_SET: CaptureSet = {
targets: DOC_CAPTURE_TARGETS,
fixtures: FIXTURE_ROUTES,
printImages: PRINT_IMAGE_MAP,
printerImages: PRINTER_IMAGE_MAP,
// Deliberately no extra CSS. The docs are documenting the filter panel and
// the tab actions the home crops hide, so hiding them here would document a
// product that does not exist.
Expand Down
5 changes: 5 additions & 0 deletions cypress/fixtures/demo/printer-thumbnails.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[
{ "printerId": 101, "thumbnailUrl": "/api/Printers/101/thumbnail" },
{ "printerId": 102, "thumbnailUrl": "/api/Printers/102/thumbnail" },
{ "printerId": 103, "thumbnailUrl": "/api/Printers/103/thumbnail" }
]
19 changes: 12 additions & 7 deletions cypress/fixtures/demo/printers-summary.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
{
"paging": { "currentPage": 1, "totalPages": 1, "pageSize": 10, "totalCount": 3 },
"paging": {
"currentPage": 1,
"totalPages": 1,
"pageSize": 10,
"totalCount": 3
},
"items": [
{
"id": 101,
"name": "Living Room",
"make": "Bambu Lab",
"model": "A1",
"make": "Snapmaker",
"model": "U1",
"isActive": true,
"wattageW": 350,
"printTimeInSeconds": null,
Expand Down Expand Up @@ -39,8 +44,8 @@
{
"id": 102,
"name": "Workshop",
"make": "Prusa",
"model": "MK4S",
"make": "Anycubic",
"model": "Kobra S1",
"isActive": true,
"wattageW": 120,
"printTimeInSeconds": null,
Expand Down Expand Up @@ -74,8 +79,8 @@
{
"id": 103,
"name": "Resin Station",
"make": "Anycubic",
"model": "Photon Mono M7 Pro",
"make": "HeyGears",
"model": "Reflex RS Turbo",
"isActive": true,
"wattageW": 120,
"printTimeInSeconds": null,
Expand Down
32 changes: 16 additions & 16 deletions cypress/fixtures/demo/prints-summary.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
"printer": {
"id": 103,
"name": "Resin Station",
"make": "Anycubic",
"model": "Photon Mono M7 Pro",
"make": "HeyGears",
"model": "Reflex RS Turbo",
"isActive": true,
"wattageW": 120,
"printTimeInSeconds": null,
Expand Down Expand Up @@ -77,8 +77,8 @@
"loadedInPrinter": {
"id": 103,
"name": "Resin Station",
"make": "Anycubic",
"model": "Photon Mono M7 Pro",
"make": "HeyGears",
"model": "Reflex RS Turbo",
"isActive": true,
"category": null
},
Expand Down Expand Up @@ -118,8 +118,8 @@
"printer": {
"id": 102,
"name": "Workshop",
"make": "Prusa",
"model": "MK4S",
"make": "Anycubic",
"model": "Kobra S1",
"isActive": true,
"wattageW": 120,
"printTimeInSeconds": null,
Expand Down Expand Up @@ -183,8 +183,8 @@
"loadedInPrinter": {
"id": 101,
"name": "Living Room",
"make": "Bambu Lab",
"model": "A1",
"make": "Snapmaker",
"model": "U1",
"isActive": true,
"category": null
},
Expand Down Expand Up @@ -279,8 +279,8 @@
"printer": {
"id": 101,
"name": "Living Room",
"make": "Bambu Lab",
"model": "A1",
"make": "Snapmaker",
"model": "U1",
"isActive": true,
"wattageW": 350,
"printTimeInSeconds": null,
Expand Down Expand Up @@ -344,8 +344,8 @@
"loadedInPrinter": {
"id": 102,
"name": "Workshop",
"make": "Prusa",
"model": "MK4S",
"make": "Anycubic",
"model": "Kobra S1",
"isActive": true,
"category": null
},
Expand Down Expand Up @@ -386,8 +386,8 @@
"printer": {
"id": 102,
"name": "Workshop",
"make": "Prusa",
"model": "MK4S",
"make": "Anycubic",
"model": "Kobra S1",
"isActive": true,
"wattageW": 120,
"printTimeInSeconds": null,
Expand Down Expand Up @@ -489,8 +489,8 @@
"printer": {
"id": 101,
"name": "Living Room",
"make": "Bambu Lab",
"model": "A1",
"make": "Snapmaker",
"model": "U1",
"isActive": true,
"wattageW": 350,
"printTimeInSeconds": null,
Expand Down
38 changes: 38 additions & 0 deletions cypress/support/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export interface CaptureSet {
fixtures: FixtureRoute[];
/** defaultPrintImageId -> committed demo image. */
printImages: Record<string, string>;
/** printerId -> committed demo photo, for the thumbnails the avatars render. */
printerImages: Record<string, string>;
/** CSS appended to BASE_CAPTURE_CSS for this set only. */
css?: string;
}
Expand Down Expand Up @@ -142,6 +144,23 @@ export function imagesRendered(host: string, timeout = 10000): ReadyStep {
};
}

/**
* Every `<img>` under `selector` has DECODED, not merely been created.
*
* `imagesRendered` compares host and `<img>` counts, which a still-loading image
* already satisfies - so it cannot detect the one race that actually loses a photo
* from a capture. This waits on the pixels.
*/
export function imagesLoaded(selector: string, timeout = 10000): ReadyStep {
return (scope) =>
cy.get(`${scope} ${selector} img`, { timeout }).should(($imgs) => {
$imgs.each((_, img) => {
expect((img as HTMLImageElement).complete).to.be.true;
expect((img as HTMLImageElement).naturalWidth).to.be.greaterThan(0);
});
});
}

// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -206,6 +225,25 @@ function stubApi(set: CaptureSet, unhandled: string[]) {
}
req.reply({ fixture: file });
});
// Printer avatars. The thumbnails fixture points at these relative paths rather than
// at real blob SAS URLs, so the photos come from the repo and no capture depends on
// an external host. The glob cannot collide with the thumbnails map itself: minimatch
// `*` never crosses a `/`, so `Printers/*/thumbnail` needs the two segments this has
// and `Printers/thumbnails` has only one.
cy.intercept('GET', '**/api/Printers/*/thumbnail*', (req) => {
const id = req.url.match(/\/Printers\/(\d+)\/thumbnail/)?.[1] ?? '';
const file = set.printerImages[id];
if (!file) {
// Reported like a missed stub rather than falling back to some other machine's
// photo, which every readiness check would still pass.
unhandled.push(
`${req.method} ${req.url} (no demo photo for printer ${id})`
);
req.reply({ statusCode: 404, body: {} });
return;
}
req.reply({ fixture: file });
});
}

/**
Expand Down
Loading
Loading