Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Binary file modified e2e/layout-parity.spec.ts-snapshots/editor-layout-parity.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/layout-parity.spec.ts-snapshots/preview-layout-parity.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 27 additions & 1 deletion src/components/layout-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ describe("photo distribution in the preview", () => {
expect(markup).toContain("object-position:25% 10%")
})

it("leaves a frame empty once the uploaded photos run out", () => {
it("fills a frame with placeholder art once the uploaded photos run out", () => {
let schema = addElement(emptyLayoutSchema(), "image-frame", "photos")
schema = addElement(schema, "image-frame", "photos")
schema = addElement(schema, "image-frame", "photos")
Expand All @@ -355,6 +355,32 @@ describe("photo distribution in the preview", () => {
)

expect(markup.match(/<img/g)).toHaveLength(2)
expect(markup.match(/data-filler-motif/g)).toHaveLength(1)
expect(markup).not.toContain("border-dashed")
})

it("gives each empty slot of a gallery its own motif", () => {
const schema = addElement(emptyLayoutSchema(), "gallery-frame", "photos")
const gallery = schema.elements[0]!
if (gallery.type !== "gallery-frame") throw new Error("Expected a gallery frame")
gallery.arrangement = "four-square"
gallery.geometry = { x: 10, y: 10, width: 80, height: 80, rotation: 0 }

const markup = renderToStaticMarkup(
<LayoutPageElements schema={schema} content={{ submission }} />
)

const motifs = [...markup.matchAll(/data-filler-motif="([a-z-]+)"/g)].map((match) => match[1])
expect(motifs).toHaveLength(2)
expect(new Set(motifs).size).toBe(2)
})

it("keeps empty frames plainly empty while a layout is still being authored", () => {
const schema = addElement(emptyLayoutSchema(), "image-frame", "photos")

const markup = renderToStaticMarkup(<LayoutPageElements schema={schema} />)

expect(markup).toContain("border-dashed")
expect(markup).not.toContain("data-filler-motif")
})
})
65 changes: 65 additions & 0 deletions src/components/layout-page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { useMemo } from "react"

import {
fillerMotif,
fillerPalette,
fillerSeed,
MOTIF_VIEWBOX,
type FillerPalette,
} from "#/domain/filler-art.ts"
import { gallerySlots } from "#/domain/layout.ts"
import {
pageSpecification,
Expand Down Expand Up @@ -176,10 +183,51 @@ export function textElementVerticalOffsetMm(
return layoutText(runs, target.geometry.width, target.geometry.height, target.text).offsetYMm
}

/**
* Stands in for a photo the contributor did not upload. Only rendered once a response is being
* previewed: while a layout is being authored every frame is empty, so art there would say
* nothing about the finished page.
*
* `xMidYMid meet` centres the square motif on the slot's shorter side without stretching it,
* which is the fit the PDF exporter reproduces with `motifPlacement`.
*/
function FillerArt({
seed,
slotIndex,
palette,
}: {
seed: string
slotIndex: number
palette: FillerPalette
}) {
const motif = fillerMotif(seed, slotIndex)
return (
<svg
className="size-full"
viewBox={`0 0 ${MOTIF_VIEWBOX} ${MOTIF_VIEWBOX}`}
preserveAspectRatio="xMidYMid meet"
data-filler-motif={motif.id}
aria-hidden="true"
>
{motif.shapes.map((shape, index) => (
<path
key={index}
d={shape.d}
fill={shape.strokeWidth ? "none" : palette[shape.tone]}
stroke={shape.strokeWidth ? palette[shape.tone] : undefined}
strokeWidth={shape.strokeWidth}
strokeLinecap={shape.strokeWidth ? "round" : undefined}
/>
))}
</svg>
)
}

function ElementContent({
element,
content,
photoAssignment,
palette,
showEditorPlaceholders,
editingElementId,
editingText,
Expand All @@ -189,6 +237,7 @@ function ElementContent({
element: LayoutElement
content: LayoutPageContent
photoAssignment: PhotoAssignment
palette: FillerPalette
showEditorPlaceholders: boolean
editingElementId?: string
editingText?: string
Expand Down Expand Up @@ -383,6 +432,12 @@ function ElementContent({
content={content}
borderRadius={millimetresToContainerWidth(element.cornerRadius, specification)}
/>
) : content.submission ? (
<FillerArt
seed={fillerSeed(content.submission.id, element.id)}
slotIndex={0}
palette={palette}
/>
) : (
<div className="size-full border border-dashed border-foreground/25 bg-muted/20" />
)}
Expand Down Expand Up @@ -416,6 +471,14 @@ function ElementContent({
<div key={`${image.assetId}-${index}`} style={slotStyle}>
<PrintedPhoto element={element} image={image} content={content} />
</div>
) : content.submission ? (
<div key={index} style={slotStyle}>
<FillerArt
seed={fillerSeed(content.submission.id, element.id)}
slotIndex={index}
palette={palette}
/>
</div>
) : (
<span
key={index}
Expand Down Expand Up @@ -452,6 +515,7 @@ export function LayoutPageElements({
() => assignPhotosToFrames(schema.elements, content.submission?.answers ?? {}),
[schema.elements, content.submission]
)
const palette = useMemo(() => fillerPalette(schema), [schema])
return (
<div
className="pointer-events-none absolute inset-0"
Expand All @@ -464,6 +528,7 @@ export function LayoutPageElements({
element={element}
content={content}
photoAssignment={photoAssignment}
palette={palette}
showEditorPlaceholders={showEditorPlaceholders}
editingElementId={editingElementId}
editingText={editingText}
Expand Down
154 changes: 154 additions & 0 deletions src/domain/filler-art.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest"

import {
FILLER_MOTIFS,
fillerMotif,
fillerPalette,
fillerSeed,
MOTIF_VIEWBOX,
motifPlacement,
} from "./filler-art.ts"
import { backgroundPresets } from "./layout-backgrounds.ts"
import { emptyLayoutSchema } from "./layout.ts"
import { type LayoutSchema, type ShapeElement } from "./types.ts"

function schemaWithShapes(background: string, fills: string[]): LayoutSchema {
const elements: ShapeElement[] = fills.map((fill, index) => ({
id: `shape-${index}`,
type: "rectangle",
geometry: { x: 0, y: index * 10, width: 40, height: 10, rotation: 0 },
opacity: 1,
fill,
stroke: "transparent",
strokeWidth: 0,
}))
return { ...emptyLayoutSchema(), background, elements }
}

describe("filler art palette", () => {
it("draws its accents from the colours the layout itself uses", () => {
const palette = fillerPalette(schemaWithShapes("#fbf3e7", ["#5b927b", "#b45f52", "#27485b"]))

expect([palette.primary, palette.secondary, palette.ink].sort()).toEqual(
["#27485b", "#5b927b", "#b45f52"].sort()
)
})

it("falls back to the house palette when a layout has no shapes to borrow from", () => {
const palette = fillerPalette(emptyLayoutSchema())

expect(palette.ink).toBe("#27485b")
expect(palette.primary).not.toBe(palette.secondary)
})

it("reads a line's colour from its stroke, which is all a line paints", () => {
const schema = emptyLayoutSchema()
schema.elements = [
{
id: "rule",
type: "line",
geometry: { x: 0, y: 0, width: 60, height: 1, rotation: 0 },
opacity: 1,
fill: "transparent",
stroke: "#7a3b8f",
strokeWidth: 1.2,
},
]

expect([fillerPalette(schema).primary, fillerPalette(schema).secondary]).toContain("#7a3b8f")
})

it("ignores colours on an element the layout does not actually paint", () => {
const schema = emptyLayoutSchema()
schema.elements = [
{
id: "hidden",
type: "rectangle",
geometry: { x: 0, y: 0, width: 90, height: 90, rotation: 0 },
opacity: 0,
fill: "#7a3b8f",
stroke: "#7a3b8f",
strokeWidth: 2,
},
]
const palette = fillerPalette(schema)

expect([palette.primary, palette.secondary, palette.ink]).not.toContain("#7a3b8f")
})

it("skips shape colours that would be invisible against the page background", () => {
const palette = fillerPalette(schemaWithShapes("#fbf3e7", ["#faf2e6", "#b45f52"]))

expect([palette.primary, palette.secondary, palette.ink]).not.toContain("#faf2e6")
expect([palette.primary, palette.secondary, palette.ink]).toContain("#b45f52")
})

it("inks with a light tone on a dark page and a dark tone on a pale one", () => {
const pale = fillerPalette(schemaWithShapes("#fffdf7", ["#27485b", "#f0c66f", "#b45f52"]))
const dark = fillerPalette(schemaWithShapes("#101014", ["#27485b", "#f0c66f", "#b45f52"]))

expect(pale.ink).toBe("#27485b")
expect(dark.ink).toBe("#f0c66f")
})

it("keeps every tone clear of the page it will be drawn on", () => {
const preset = backgroundPresets("a5", "landscape").find(
(candidate) => candidate.id === "geometric-collage"
)!
const palette = fillerPalette(preset.schema)

expect([palette.primary, palette.secondary, palette.ink]).not.toContain(
preset.schema.background
)
})
})

describe("filler motif selection", () => {
const seed = fillerSeed("11111111-1111-4111-8111-111111111111", "gallery")

it("picks the same motif for a slot every time, so regenerating a book keeps its art", () => {
expect(fillerMotif(seed, 2).id).toBe(fillerMotif(seed, 2).id)
})

it("never repeats a motif between the slots of one frame", () => {
for (const element of ["frame-a", "frame-b", "frame-c", "gallery"]) {
const frameSeed = fillerSeed("11111111-1111-4111-8111-111111111111", element)
const chosen = [0, 1, 2, 3].map((slot) => fillerMotif(frameSeed, slot).id)
expect(new Set(chosen).size).toBe(4)
}
})

it("spreads motifs across frames and responses rather than reusing one", () => {
const ids = new Set(
["a", "b", "c", "d", "e", "f"].flatMap((submission) =>
["one", "two"].map((element) => fillerMotif(fillerSeed(submission, element), 0).id)
)
)

expect(ids.size).toBeGreaterThan(3)
})
})

describe("motif drawing data", () => {
it("keeps every motif inside the square both renderers scale", () => {
for (const motif of FILLER_MOTIFS) {
const coordinates = motif.shapes.flatMap((shape) =>
(shape.d.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number)
)
expect(Math.min(...coordinates), motif.id).toBeGreaterThanOrEqual(0)
expect(Math.max(...coordinates), motif.id).toBeLessThanOrEqual(MOTIF_VIEWBOX)
}
})

it("centres the motif on a slot's shorter side instead of stretching it", () => {
const wide = motifPlacement(200, 100)
expect(wide.scale).toBe(1)
expect(wide.offsetX).toBe(50)
expect(wide.offsetY).toBe(0)

const tall = motifPlacement(50, 150)
expect(tall.scale).toBe(0.5)
expect(tall.offsetX).toBe(0)
expect(tall.offsetY).toBe(50)
})
})
Loading