Skip to content

feat(book): Empty photo slots print art instead of blank paper - #84

Merged
patriksimms merged 4 commits into
mainfrom
feat/empty-slot-filler-art
Aug 24, 2026
Merged

feat(book): Empty photo slots print art instead of blank paper#84
patriksimms merged 4 commits into
mainfrom
feat/empty-slot-filler-art

Conversation

@patriksimms

@patriksimms patriksimms commented Aug 24, 2026

Copy link
Copy Markdown
Owner

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:

Before After
before after

The same art in an exported PDF, five empty slots on one page:

exported PDF

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 through pdf-lib's drawSvgPath. 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: pass
  • bun run test: 327 pass, including new coverage for palette derivation, deterministic selection, and motif bounds
  • bun run test:e2e: 22 pass, 2 skipped (production smoke, no local Clerk credentials)
  • bun run build, docker compose config, check-production-compose: pass

No changes outside the repository are required to deploy this.

Closes #83


Implemented by Claude Opus 5 through Claude Code.

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Suggested change
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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/domain/filler-art.ts
@patriksimms
patriksimms marked this pull request as ready for review August 24, 2026 19:40
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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracked as #86.

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
@patriksimms
patriksimms merged commit 972e373 into main Aug 24, 2026
3 checks passed
@patriksimms
patriksimms deleted the feat/empty-slot-filler-art branch August 24, 2026 20:27
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.

feat(book): Empty photo slots print art instead of blank paper

1 participant