feat(book): Empty photo slots print art instead of blank paper - #84
Conversation
Summary: - Add nine flat-vector motifs in three families, authored once as SVG path data and drawn by both the book preview and the PDF exporter. - Derive motif colours from the layout's own background and shape fills, falling back to the house palette when a layout has none. - Pick a motif per slot from the submission and element ids, so regenerating a book keeps its art and no two slots of one frame repeat. - Leave the layout editor untouched: while authoring, every frame is empty and art there would say nothing about the finished page. Rationale: - A response with fewer photos than the layout can show used to print blank rectangles, which reads as broken rather than as designed. - Vector rather than raster: the art keeps preview and print identical without a second drawing implementation, and never reaches the effective-PPI preflight rules that apply to real photos. - Colours come from the layout because a background preset is copied into a layout's elements when applied and its id is not stored, so reading the shapes back also covers hand-built and recoloured layouts. - The existing "photo slot stays empty" warning is unchanged. The page looking finished and the organizer knowing a contributor skipped a photo are separate concerns. Tests: - bun run format:check, lint, typecheck: pass - bun run test: 327 pass - bun run test:e2e: 22 pass, 2 skipped (production smoke, no credentials locally) - bun run build, docker compose config, check-production-compose: pass - Parity baselines regenerated; both surfaces draw the same art Closes #83 AI-Assisted-By: Codex AI-Assisted: true AI-Agent: claude-code AI-Model: anthropic/claude-opus-5
| ...(shape.strokeWidth | ||
| ? { | ||
| borderColor: tone, | ||
| borderWidth: shape.strokeWidth, |
There was a problem hiding this comment.
🟡 Medium server/pdf-renderer.ts:187
Stroked filler motifs render with the wrong line weight in exported PDFs: shape.strokeWidth is in viewBox units, but borderWidth is not scaled with the motif. The SVG preview scales both geometry and strokes, so scale shape.strokeWidth by placement.scale here as well.
| borderWidth: shape.strokeWidth, | |
| borderWidth: shape.strokeWidth * placement.scale, |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/server/pdf-renderer.ts around line 187:
Stroked filler motifs render with the wrong line weight in exported PDFs: `shape.strokeWidth` is in viewBox units, but `borderWidth` is not scaled with the motif. The SVG preview scales both geometry and strokes, so scale `shape.strokeWidth` by `placement.scale` here as well.
There was a problem hiding this comment.
Not applying this one. borderWidth is already in viewBox units, so scaling it here would double-scale.
pdf-lib emits setLineWidth after the scale operator in drawSvgPath (api/operations.js), so the width is interpreted in the scaled user space, exactly like stroke-width under an SVG viewBox.
Measured it rather than trusting the read. Same stroked motif in a 100 mm frame and a 25 mm frame on one exported page, rasterised at a fixed DPI:
- 100 mm frame: 33 px stroke
- 25 mm frame: 8 px stroke
4.1:1 against a 4:1 size ratio. The stroke already tracks the motif. With the suggested change the 25 mm frame would draw at roughly a quarter of that, near-invisible in print.
Summary: - Remove the tinted panel each empty photo slot painted behind its motif, in both the book preview and the PDF exporter. - Drop the derived `base` tone from the filler palette, which only existed to fill that panel. Rationale: - The panel was mixed 12% toward the ink tone, so a warm cream page turned cold grey (#fbf3e7 became #e2ded6) and the slots read as unstyled placeholder boxes rather than as art. - Drawing straight onto the page makes an unfilled slot part of the page. The trade is that a frame placed over a solid coloured shape now draws its motif onto that shape, since accents are chosen to contrast with the page background rather than with whatever sits beneath the frame. Tests: - bun run format:check, lint, typecheck: pass - bun run test: 327 pass - bun run test:e2e: 22 pass, 2 skipped (production smoke, no credentials locally) - bun run build, docker compose config, check-production-compose: pass - Parity baselines and the issue #83 evidence regenerated Related to #83 AI-Assisted-By: Codex AI-Assisted: true AI-Agent: claude-code AI-Model: anthropic/claude-opus-5
| */ | ||
| function drawFillerArt(input: { | ||
| page: PDFPage | ||
| geometry: { x: number; y: number; width: number; height: number } |
There was a problem hiding this comment.
🟡 Medium server/pdf-renderer.ts:157
Rotated empty image and gallery frames export filler artwork axis-aligned, so their placement and orientation differ from the HTML preview. drawFillerArt receives geometry without rotation and never applies the frame rotation; include the rotation and transform the motif around the frame center to match the preview.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/server/pdf-renderer.ts around line 157:
Rotated empty image and gallery frames export filler artwork axis-aligned, so their placement and orientation differ from the HTML preview. `drawFillerArt` receives geometry without `rotation` and never applies the frame rotation; include the rotation and transform the motif around the frame center to match the preview.
There was a problem hiding this comment.
Accurate observation, but not fixed here on purpose.
The exporter already ignores frame rotation for real photos: drawCroppedImage takes no rotation, and decorative-image does not rotate either. layout-backgrounds.ts:35 documents the underlying cause, that the browser rotates around a shape's top-left corner and the PDF around its bottom-left.
Filler art matches that behaviour deliberately. Rotating only the placeholder would make things worse, not better: a rotated frame's art would sit at one angle while a photo in the same frame sits at another, so the slot would visibly shift the moment a contributor adds or removes a photo.
The right fix rotates images, decorative images, and filler art together, which changes existing export behaviour and needs its own before/after proof. Tracking separately rather than smuggling it into this PR.
Summary: - Take a line's palette colour from `stroke`, which is the only colour a line paints, instead of from a `fill` that is almost always transparent. - Skip elements with zero opacity, so a hidden shape cannot displace a colour the page actually shows. Rationale: - A layout whose accent came from a coloured rule contributed nothing and silently fell back to unrelated house accents. - Outline strokes on rectangles and circles are deliberately still ignored. Accents rank by contrast against the page, so a dark hairline border would outrank the panel it edges. Tests: - bun run format:check, lint, typecheck: pass - bun run test: 329 pass, including a line-stroke accent and a zero-opacity shape - bun run test:e2e: 22 pass, 2 skipped (production smoke, no credentials locally) - bun run build, docker compose config, check-production-compose: pass - Parity baselines regenerated: that fixture's ink is now its own rule brown rather than a house blue Related to #83 AI-Assisted-By: Codex AI-Assisted: true AI-Agent: claude-code AI-Model: anthropic/claude-opus-5
…r-art AI-Assisted-By: Codex # Conflicts: # src/components/layout-page.test.tsx
Problem
When a contributor uploads fewer photos than a layout can show, the leftover slots print as blank paper. A five-frame layout filled with three photos gives a page with two empty rectangles, which reads as broken rather than as designed.
Solution
Unfilled photo slots are now filled with flat vector placeholder art, coloured from the layout's own palette and drawn straight onto the page. Nine motifs across three families (botanical, geometric, textural), picked deterministically per slot so regenerating a book never reshuffles the art and no two slots of one frame show the same motif.
This is default behaviour with no toggle. The existing "photo slot stays empty" warning is unchanged, so the page looks finished while the organizer still sees that a contributor skipped a photo.
Before and after
Book preview of a layout with four photo slots and two uploaded photos:
The same art in an exported PDF, five empty slots on one page:
Notes for review
Vector, not raster. Motifs are authored once as SVG path data in a square coordinate system. The preview renders them as
<path>elements, the exporter throughpdf-lib'sdrawSvgPath. That keeps preview and print identical without a second drawing implementation, and keeps filler art out of the effective-PPI preflight rules that apply to embedded photos. An unfilled slot adds no asset-resolution metadata.Colours come from the layout, not from a theme. Applying a background preset copies its shapes into the layout's elements and does not store the preset id, so the palette is read back off the shapes the layout actually contains. This also covers hand-built layouts and presets the organizer has since recoloured. Accents are ranked by distance from the page background; the tone furthest from it in lightness carries the line work, which gives a dark ink on paper tones and a light one on a dark page.
Nothing is painted behind the motif. An earlier revision drew a tinted panel per slot, which turned a warm cream page cold grey and read as an unstyled placeholder box. Art now sits directly on the page. The trade: a frame placed on top of a solid coloured shape draws its motif onto that shape, because accents contrast with the page background rather than with whatever happens to sit beneath the frame.
The layout editor is untouched. While authoring, every frame is empty, so art there would say nothing about the finished page. Frames keep their dashed placeholder until a response is being previewed.
Parity baselines regenerated. The parity fixture has two empty gallery slots, so both the Fabric editor surface and the book preview now draw art there. They still match each other pixel for pixel.
Validation
bun run format:check,lint,typecheck: passbun run test: 327 pass, including new coverage for palette derivation, deterministic selection, and motif boundsbun run test:e2e: 22 pass, 2 skipped (production smoke, no local Clerk credentials)bun run build,docker compose config,check-production-compose: passNo changes outside the repository are required to deploy this.
Closes #83
Implemented by Claude Opus 5 through Claude Code.