diff --git a/client/src/components/Assistant/cards/book/bookCardModel.test.ts b/client/src/components/Assistant/cards/book/bookCardModel.test.ts index 81e60959a..eebd97da1 100644 --- a/client/src/components/Assistant/cards/book/bookCardModel.test.ts +++ b/client/src/components/Assistant/cards/book/bookCardModel.test.ts @@ -1,5 +1,5 @@ /** - * FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-016 + * FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-017 * * The pure side of the book's page card: a page named as the Studio names * it, a layout and a finding by their words, the findings worth a look, the @@ -8,7 +8,7 @@ * the card carries it and the book is still at the trial's version), and * where an applied change opens the Studio. */ -import { blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared'; +import { BOOK_FINDING_CODES, blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared'; import { describe, expect, it } from 'vitest'; import { @@ -95,6 +95,15 @@ describe('bookCardModel', () => { expect(findingLabel({ code: 'low_dpi', severity: 'warn' }, t)).toBe('assistant.card.book.finding.low_dpi'); }); + it('FE-AI-BOOKPAGE-017: every finding the checks answer with has its words, the art checks among them', () => { + for (const code of BOOK_FINDING_CODES) { + expect(findingLabel({ code, severity: 'info' }, t)).toBe(`assistant.card.book.finding.${code}`); + } + for (const code of ['flat_cover', 'weak_hierarchy', 'off_theme', 'energy_mismatch'] as const) { + expect(BOOK_FINDING_CODES).toContain(code); + } + }); + it('FE-AI-BOOKPAGE-013: a waiting edit or cover draws before and after; a move alone, a delete each page alone; new pages, other books and failures none', () => { const doc = book(); const [edit] = pageViewOf(card(), doc, 'pending'); diff --git a/client/src/components/Assistant/cards/book/bookCardModel.ts b/client/src/components/Assistant/cards/book/bookCardModel.ts index e036f9827..890e83284 100644 --- a/client/src/components/Assistant/cards/book/bookCardModel.ts +++ b/client/src/components/Assistant/cards/book/bookCardModel.ts @@ -63,6 +63,10 @@ const FINDING_LOOK: Readonly> = { edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' }, curve_overflow: { labelKey: 'assistant.card.book.finding.curve_overflow' }, effect_heavy: { labelKey: 'assistant.card.book.finding.effect_heavy' }, + flat_cover: { labelKey: 'assistant.card.book.finding.flat_cover' }, + weak_hierarchy: { labelKey: 'assistant.card.book.finding.weak_hierarchy' }, + off_theme: { labelKey: 'assistant.card.book.finding.off_theme' }, + energy_mismatch: { labelKey: 'assistant.card.book.finding.energy_mismatch' }, }; /** A page as the Studio names it: the cover, the first page, double page 3. */ diff --git a/client/src/components/Assistant/cards/book/bookLook.ts b/client/src/components/Assistant/cards/book/bookLook.ts index b08f26036..f447b7d05 100644 --- a/client/src/components/Assistant/cards/book/bookLook.ts +++ b/client/src/components/Assistant/cards/book/bookLook.ts @@ -37,7 +37,7 @@ export const BOOK_LOOK = { // The server's own read: it waits for the Studio to draw the page, and says it looks at it. preview_book_page: { stepKey: 'assistant.step.lookingAtPage' }, list_book_layouts: { stepKey: 'assistant.step.bookLayouts' }, - list_book_assets: { stepKey: 'assistant.step.bookStickers' }, + list_book_assets: { stepKey: 'assistant.step.bookAssets' }, suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' }, }, entities: { diff --git a/client/src/components/Studio/shapes.ts b/client/src/components/Studio/shapes.ts index ef229457e..3d252775f 100644 --- a/client/src/components/Studio/shapes.ts +++ b/client/src/components/Studio/shapes.ts @@ -1,4 +1,4 @@ -import type { BookShapeId } from '@trek/shared' +import { BOOK_FRAME_SHAPES, BOOK_SHAPE_GROUPS, type BookShapeId } from '@trek/shared' import { K, circle, polygon, r2, roundRect, scallop, star } from './shapePrimitives' import { MORE_SHAPE_PATHS } from './shapes-more' @@ -323,91 +323,10 @@ export function unitPath(path: string): string { */ export const HOLED_SHAPES = new Set(['gear', 'postmark', 'label-tab']) -export interface ShapeGroup { - /** i18n key under `journey.studio.shapeGroup`. */ - id: string - shapes: BookShapeId[] -} - /** - * How the panel is divided. - * - * Grouped the way you go looking for them — "I want an arrow", "I want - * something round and soft" — rather than by how they are built. Sixty - * unlabelled tiles in one grid is a wall; six groups of ten is a menu. + * How the panel is divided and which shapes it offers as picture frames: the + * one list in @trek/shared (book-shape-catalogue.ts), which the assistant's + * asset listing reads too. */ -export const SHAPE_GROUPS: ShapeGroup[] = [ - { - id: 'basic', - shapes: ['rect', 'squircle', 'ellipse', 'capsule', 'triangle', 'triangle-down', - 'diamond', 'parallelogram', 'trapezoid', 'arch', 'half-circle', 'quarter-circle'], - }, - { - id: 'polygons', - shapes: ['pentagon', 'hexagon', 'hexagon-flat', 'heptagon', 'octagon'], - }, - { - id: 'stars', - shapes: ['star-4', 'star-5', 'star-6', 'star-8', 'star-12', 'burst', 'seal', 'sparkle', - 'star-rounded', 'star-twinkle'], - }, - { - id: 'arrows', - shapes: ['arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'arrow-both', - 'chevron-right', 'chevron-left', 'arrow-bent', 'arrow-curve', 'arrow-circle', 'arrow-notched', 'arrow-thin'], - }, - { - id: 'speech', - shapes: ['bubble-round', 'bubble-square', 'bubble-oval', 'bubble-think', - 'bubble-shout', 'bubble-side', 'bubble-capsule'], - }, - { - id: 'travel', - shapes: ['pin', 'compass', 'mountain', 'sun', 'moon', 'cloud', 'cloud-puffy', - 'drop', 'wave', 'leaf', 'shield', 'ticket'], - }, - { - id: 'decor', - shapes: ['heart', 'flower-5', 'flower-6', 'cross', 'plus', 'gear', - 'blob-1', 'blob-2', 'blob-3', 'blob-4', 'heart-round', 'heart-pair', 'flower-8'], - }, - { - id: 'banners', - shapes: ['banner-ribbon', 'banner-pennant', 'banner-bookmark', 'banner-flag', - 'ribbon-ends', 'ribbon-arc', 'wavy-band', 'torn-strip'], - }, - // The second shelf (shapes-more.ts). - { id: 'arches', shapes: ['arch-tall', 'arch-pointed', 'arch-double', 'arch-half', 'arch-rainbow'] }, - { - id: 'cutouts', - shapes: ['leaf-palm', 'leaf-oak', 'leaf-long', 'leaf-monstera', 'coral-1', 'coral-2', 'seaweed', - 'star-organic', 'sun-cutout', 'organic-1', 'organic-2', 'organic-3'], - }, - { - id: 'stamps', - shapes: ['stamp-rect', 'stamp-circle', 'postmark', 'rosette', 'scallop-circle', 'badge-shield', - 'label-tab', 'ticket-stub', 'sunburst-12', 'sunburst-24'], - }, - { id: 'frames', shapes: ['frame-notched', 'frame-chamfer', 'frame-cushion'] }, - { id: 'ornaments', shapes: ['flourish-scroll', 'laurel', 'fleuron', 'corner-flourish'] }, -] - -/** - * The shapes offered as picture frames. - * - * A subset, and the subset is the point: a photograph cut to a speech bubble's - * tail or to a hairline is a photograph you cannot read. What is here holds an - * image at book size — broad, mostly convex, no thin limbs. - */ -export const FRAME_SHAPES: BookShapeId[] = [ - 'rect', 'squircle', 'ellipse', 'capsule', 'arch', 'half-circle', - 'triangle', 'diamond', 'pentagon', 'hexagon', 'octagon', - 'heart', 'star-5', 'star-6', 'seal', 'flower-5', 'flower-6', - 'blob-1', 'blob-2', 'blob-3', 'blob-4', - 'shield', 'drop', 'leaf', 'pin', 'banner-pennant', 'banner-bookmark', - // The second shelf: arches, stamps and the frames cut for photographs. - 'arch-tall', 'arch-pointed', 'arch-double', 'arch-half', 'stamp-rect', 'stamp-circle', - 'scallop-circle', 'badge-shield', 'ticket-stub', 'heart-round', 'star-rounded', 'flower-8', - 'frame-notched', 'frame-chamfer', 'frame-cushion', 'organic-1', 'organic-2', 'organic-3', - 'star-organic', 'sun-cutout', 'bubble-side', 'bubble-capsule', -] +export const SHAPE_GROUPS = BOOK_SHAPE_GROUPS +export const FRAME_SHAPES = BOOK_FRAME_SHAPES diff --git a/server/src/nest/assistant/assistant-book-design.prompt.ts b/server/src/nest/assistant/assistant-book-design.prompt.ts index 956d2cb9e..40c06d40c 100644 --- a/server/src/nest/assistant/assistant-book-design.prompt.ts +++ b/server/src/nest/assistant/assistant-book-design.prompt.ts @@ -13,22 +13,22 @@ */ export const BOOK_DESIGN_PROMPT = `How you design: - Act as a book designer, not a form filler: decide the story first (chapters, pacing), then the energy of the book, then the pages. -- Map the person's words to design_book's directives: "calm, lots of white" is style minimal and density airy; "like a magazine" is editorial; "bold", "lively" or "loud" is bold and rich; "scrapbook" is scrapbook; a page count is pages.min and pages.max; "only Portugal" is a scope. -- Before you call design_book, state the brief in one line: the energy (calm, lively or loud), the cover photo, the title words and the look. +- Map the person's words to design_book's directives: "calm, lots of white" is style minimal and density airy; "like a magazine" is editorial; "lively" or "loud" is that brief.energy, with a theme and a cover archetype; "bold" is bold; "scrapbook" is scrapbook; a page count is pages.min and pages.max; "only Portugal" is a scope. +- Before you call design_book, state the brief in one line: the energy (calm, lively or loud), the decoration, the theme, the cover archetype, the cover photo and the title words; then pass it as directives.brief and directives.cover. Always, whatever the energy: - Never upscale: under 200 dpi make the frame smaller; keep the focal point visible. - Nothing that matters in the fold, past the trim or outside the safe area; a picture meant to bleed reaches the bleed; mind the binding on a double spread. - Words on a photo sit on a quiet part of it, a scrim or a colour block; running text at 4.5:1 contrast, display type at 3:1. - Bound texts keep the journal in sync: prefer bindings over copied words; never invent captions or facts. The energy decides the rest: -- Calm: one dominant element per page; heading, then body, then captions; whitespace as a beat after a loud spread; margins at least the safe area plus 6 mm; everything on the margin grid and the gutter; one accent from the photos and two typefaces. -- Lively: still one hero per page, now layered: a photo over a colour block or a shape, the title over a photo, photos turned 2 to 6 degrees in a white or polaroid frame, oversize display type, colour blocking in the accent; set a cover title or a chapter mark with add_lockup, and give type on a photo an outline, a hard shadow or a band (style) rather than a scrim. -- Loud: lively pushed further: bigger type, stronger colour, more layers, a title that fills the cover; never two heroes on one page. +- Calm: one dominant element per page; heading, then body, then captions; whitespace as a beat after a loud spread; margins at least the safe area plus 6 mm; everything on the margin grid and the gutter; one accent from the photos and two typefaces; prints straight or turned 2 degrees at most, or as far as the theme turns them. +- Lively: still one hero per page, now layered: a photo over a colour block or a shape, the title over a photo, photos turned 3 to 7 degrees in a white or polaroid frame, oversize display type, colour blocking in the accent; set a cover title or a chapter mark with add_lockup, and give type on a photo an outline, a hard shadow or a band (style) rather than a scrim. +- Loud: lively pushed further: bigger type, stronger colour, more layers, prints turned 4 to 10 degrees, a title that fills the cover; never two heroes on one page. - A deliberate overlap is a design; an accidental one (two straight photos or texts of a size half over each other) is a fault. - Vary the beats, never three alike in a row; a quiet page for a long story, grids for bursts. - A decorated template (list_book_layouts, under decorated) lays a whole designed page with one layout op: the page's photos and words fill its roles in the book's theme; start a cover or a chapter opener from one, then refine it with style. Type scale, in points for a 210 mm page and scaled with the page width: -- Cover title in a display face, 72 to 180 pt, 12 to 30 % of the page height; title to subtitle at least 3:1. For a big cover raise type.display (up to 6.5, about 180 pt); keep type.scale between 0.9 and 1.1 so body stays 9 to 11 pt. +- Cover title in a display face, 72 to 180 pt, 12 to 30 % of the page height (a band, a badge or a stamp sets it as large as its box holds); title to subtitle at least 3:1. For a big cover raise type.display (up to 6.5, about 180 pt); keep type.scale between 0.9 and 1.1 so body stays 9 to 11 pt. - Date lines in spaced caps, 7 to 9 pt, tracking 0.25 to 0.4; an accent word over a title in a script (caveat, pacifico, vibes, marker) or the contrasting face (playfair italic), 1.2 to 1.6 times its cap height, with contrast, an outline or a shadow. - Body 9 to 11 pt in a face whose role is body; captions 6.5 to 8 pt with tracking 0.05 to 0.1; a display face, a body face and one more at most, a script or a mono line not counted; a family sets only the weights its font id lists. Look, judge, revise: diff --git a/server/src/nest/assistant/assistant-book-preview.ts b/server/src/nest/assistant/assistant-book-preview.ts index 8c62b0c70..97ca1e182 100644 --- a/server/src/nest/assistant/assistant-book-preview.ts +++ b/server/src/nest/assistant/assistant-book-preview.ts @@ -16,7 +16,9 @@ import { ASSISTANT_PAGE_IMAGE, previewBookPageRequestSchema } from '@trek/shared * so a preview and the edit it checks are one shape. A session without that * tool (a viewer's) previews pages as they stand. Previews are capped per * step and per turn, since each can wait seconds for the Studio and costs a - * model an image. + * model an image; a turn that designs the whole book (design_book or + * revise_book_design came back with a draft, or a draft was looked at) may + * look at twice as many. Only a session that edits the book designs it. */ const EDIT_TOOL = 'edit_book_page'; @@ -31,12 +33,21 @@ const CAPS = `At most ${ASSISTANT_PAGE_IMAGE.previewsPerStep} previews per step and ${ASSISTANT_PAGE_IMAGE.previewsPerTurn} ` + 'per turn.'; +/** An editor's caps: a turn that designs the book may look at more pages. A viewer is offered none of those tools. */ +const DESIGN_CAPS = + `At most ${ASSISTANT_PAGE_IMAGE.previewsPerStep} previews per step and ${ASSISTANT_PAGE_IMAGE.previewsPerTurn} ` + + `per turn, ${ASSISTANT_PAGE_IMAGE.previewsPerDesignTurn} in a turn that designs the book (design_book, ` + + 'revise_book_design or a draft looked at).'; + +/** The tools that put a whole-book design in play for the rest of the turn, and with it the larger preview budget. */ +const DESIGN_TOOLS: ReadonlySet = new Set(['design_book', 'revise_book_design']); + /** For a session that may change the book: a proposed edit and a design draft can be looked at too. */ const EDITOR_DESCRIPTION = `Look at one page of the book before you change it or after: ${SEES} With ops (the same ops edit_book_page ` + 'takes) you see the page as that edit would leave it, without saving anything. Preview a page once before you ' + 'propose an edit of it, fix what the findings or the image show, then propose the edit. With designId (from ' + - `design_book) you see the page of that design draft instead of the book. ${CAPS}`; + `design_book) you see the page of that design draft instead of the book. ${DESIGN_CAPS}`; /** For a session without edit_book_page (a viewer's): the page as it stands, nothing it could not ask for. */ const READER_DESCRIPTION = `Look at one page of the book as it stands: ${SEES} ${CAPS}`; @@ -48,11 +59,22 @@ const TOO_MANY = `Too many previews: at most ${ASSISTANT_PAGE_IMAGE.previewsPerStep} in one step and ` + `${ASSISTANT_PAGE_IMAGE.previewsPerTurn} in one turn. Propose the edit and let its card show the findings.`; +/** The same for an editor, whose turn may have a book design in play. */ +const TOO_MANY_DESIGN = + `Too many previews: at most ${ASSISTANT_PAGE_IMAGE.previewsPerStep} in one step and ` + + `${ASSISTANT_PAGE_IMAGE.previewsPerTurn} in one turn (${ASSISTANT_PAGE_IMAGE.previewsPerDesignTurn} while a book ` + + 'design is in play). Propose the edit and let its card show the findings.'; + +/** The session's `edit_book_page` ops schema, when it has that tool. */ +function editOps(specs: readonly AiToolSpec[]): unknown { + const edit = specs.find((spec) => spec.name === EDIT_TOOL); + return ((edit?.parameters.properties ?? {}) as Record).ops; +} + /** The preview's spec, its ops taken from the session's `edit_book_page` when it has that tool. */ function previewSpec(specs: readonly AiToolSpec[]): AiToolSpec { const edit = specs.find((spec) => spec.name === EDIT_TOOL); - const properties = (edit?.parameters.properties ?? {}) as Record; - const ops = properties.ops; + const ops = editOps(specs); // Shared parts the ops point at, where the schema keeps them apart. const shared: Record = {}; for (const key of ['$defs', 'definitions']) if (edit?.parameters[key]) shared[key] = edit.parameters[key]; @@ -77,9 +99,14 @@ function previewSpec(specs: readonly AiToolSpec[]): AiToolSpec { export function withPagePreview(tools: AgentToolPort, vision: TurnVision): AgentToolPort { let inStep = 0; let inTurn = 0; + // Only a session that may change the book designs it; a viewer keeps the plain budget whatever it passes. + let edits = false; + // Once the turn designs the book, it may look at more pages: a design is judged on many. + let designing = false; const preview = async (args: Record): Promise => { - if (inStep >= ASSISTANT_PAGE_IMAGE.previewsPerStep || inTurn >= ASSISTANT_PAGE_IMAGE.previewsPerTurn) { - return { ok: false, value: TOO_MANY }; + const perTurn = designing ? ASSISTANT_PAGE_IMAGE.previewsPerDesignTurn : ASSISTANT_PAGE_IMAGE.previewsPerTurn; + if (inStep >= ASSISTANT_PAGE_IMAGE.previewsPerStep || inTurn >= perTurn) { + return { ok: false, value: edits ? TOO_MANY_DESIGN : TOO_MANY }; } const parsed = previewBookPageRequestSchema.safeParse(args); if (!parsed.success) { @@ -90,13 +117,17 @@ export function withPagePreview(tools: AgentToolPort, vision: TurnVision): Agent inStep += 1; inTurn += 1; const { spreadId, ops, designId } = parsed.data; - return designId ? vision.look(spreadId, ops, designId) : vision.look(spreadId, ops); + const run = await (designId ? vision.look(spreadId, ops, designId) : vision.look(spreadId, ops)); + // A draft looked at puts the design in play, once the look found it. + if (edits && designId && run.ok) designing = true; + return run; }; return { // The loop asks for the specs once at the start of every step: a new step may preview again. specs: () => { inStep = 0; const listed = tools.specs(); + edits = editOps(listed) !== undefined; return [...listed, previewSpec(listed)]; }, kindOf: (name) => (name === PREVIEW_BOOK_PAGE_TOOL ? 'read' : tools.kindOf(name)), @@ -106,6 +137,12 @@ export function withPagePreview(tools: AgentToolPort, vision: TurnVision): Agent const { journeyId: _journeyId, ...rest } = args; return { args: rest }; }, - call: (name, args) => (name === PREVIEW_BOOK_PAGE_TOOL ? preview(args) : tools.call(name, args)), + call: async (name, args) => { + if (name === PREVIEW_BOOK_PAGE_TOOL) return preview(args); + const run = await tools.call(name, args); + // A design tool puts the design in play once its run came back with a draft. + if (edits && run.ok && DESIGN_TOOLS.has(name)) designing = true; + return run; + }, }; } diff --git a/server/src/nest/assistant/assistant-book-recipes.prompt.ts b/server/src/nest/assistant/assistant-book-recipes.prompt.ts new file mode 100644 index 000000000..4b7012efe --- /dev/null +++ b/server/src/nest/assistant/assistant-book-recipes.prompt.ts @@ -0,0 +1,52 @@ +import { + BOOK_COVER_ARCHETYPE_LINES, + BOOK_COVER_ARCHETYPES, + BOOK_FONT_ROLES, + BOOK_FONTS_IDS, + BOOK_THEME_PRESETS, + bookFontRole, +} from '@trek/shared'; + +/** + * The art director's index and recipes for the Studio assistant (the + * expansion plan's section 5): which themes, cover archetypes and faces there + * are, how a brief is written before a design, and short recipes for each + * cover archetype and three lively spreads, as JSON of the elements a model + * adds and styles (add, style, add_lockup, background), with frames in + * percent of the page so they fit any format. + * + * The index is built from the catalogues themselves (the themes, the + * archetype lines the directives describe them with, the faces by role), so + * it never names something the book does not have. Static once built, so the + * prompt cache stays stable. Placed before the designer's rules + * (assistant-book-design.prompt.ts) for those who may change the book. + */ + +/** A theme's name: its line up to the colon, "Loud travel poster". */ +const themeName = (descr: string) => descr.split(':')[0]?.trim() ?? descr; + +const THEMES = BOOK_THEME_PRESETS.map((t) => `${t.id} (${themeName(t.descr)})`).join(', '); +const ARCHETYPES = BOOK_COVER_ARCHETYPES.map((a) => `${a} (${BOOK_COVER_ARCHETYPE_LINES[a]})`).join('; '); +const FACES = BOOK_FONT_ROLES.map( + (role) => `${role}: ${BOOK_FONTS_IDS.filter((id) => bookFontRole(id) === role).join(', ')}`, +).join('; '); + +export const BOOK_RECIPES_PROMPT = `Art direction: +- Write the brief before design_book: the energy (calm, lively or loud), the decoration (none, light, rich), collages or not, the theme, the cover archetype, its photo and its words. Say it in one line, then pass it as directives.brief ({ energy, decoration, collage, theme }) and directives.cover ({ archetype, lockup, title }) with photos.coverPhotoId. +- The engine composes the archetype and the lively pages itself; add pages.overrides ({ index, beat or template }) only for a page of the plan you want otherwise, and tweak single pages with revise_book_design. +- Themes: ${THEMES}. +- Cover archetypes: ${ARCHETYPES}. +- Faces by role (fontRole display, heading, body, script): ${FACES}. +- list_book_assets lists stickers, shapes, fonts, lockups, themes and patterns; pick stickers by the theme's motif or the journey's city as a tag. +Recipes, for a cover built or tweaked by hand, as JSON: each object one element in paint order, added with its kind and content (photoId, text or binding, shape, stickerId), frame, rotation and an id of yours, then styled by that id with the rest; {"lockup"} is add_lockup in that box, {"background"} the background operation. Frames are [x, y, w, h] in percent of the page (mm = percent times the page size / 100), -2 and 102 past the trim to the bleed, a turned frame as it stands before its turn. accent, accent2, accent3, paper and ink are the theme's colours (token on a shape or text, the hex elsewhere); photoId cover is the cover photo, 2 to 4 others; motif is a sticker of the theme's motifs, landmark one of a city the journey stopped in. size is points on a 210 mm page: a title takes fit shrink, on the display tier (72 to 180 pt) unless a band, a badge or a stamp holds it smaller; a text of 7 to 10 pt is a meta line, fontRole heading, tracking 0.3, transform upper. +- spine-band: [{"kind":"photo","photoId":"cover","frame":[-2,-2,104,104]},{"kind":"shape","shape":"rect","token":"accent","frame":[-2,-2,18,104]},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"heading","token":"paper","size":44,"transform":"upper","tracking":0.18,"frame":[-29,41,74,10],"rotation":-90},{"kind":"text","text":"2026","token":"paper","size":8,"frame":[2,91,12,4]},{"kind":"text","binding":{"source":"journey.subtitle"},"fontRole":"script","size":30,"color":"#ffffff","outline":{"color":"#1a1a1a","width":0.7},"frame":[23,70,69,16]}] +- poster-pop: [{"kind":"shape","shape":"sunburst-24","token":"accent2","frame":[29,28,62,62]},{"kind":"shape","shape":"rect","token":"accent","frame":[-2,-2,104,57]},{"kind":"photo","photoId":"cover","frame":[38,40,44,33],"rotation":4,"border":{"width":3,"color":"#ffffff"},"shadow":{"dx":1.3,"dy":1.3,"color":"ink","alpha":0.22}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"paper","size":110,"weight":900,"transform":"upper","leading":0.92,"align":"left","frame":[8,8,84,30]},{"kind":"text","text":"2026","size":9,"frame":[8,88,28,4]},{"kind":"sticker","stickerId":"motif","frame":[76,34,14,14],"rotation":-5}] +- cutout: [{"background":"accent"},{"kind":"text","text":"May 2026","token":"paper","size":9,"frame":[8,8,84,4]},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"paper","size":80,"weight":800,"transform":"upper","frame":[8,13,84,25]},{"kind":"shape","shape":"leaf-monstera","token":"accent2","frame":[6,46,28,28],"rotation":-25},{"kind":"shape","shape":"coral-1","token":"accent3","frame":[68,62,30,30],"rotation":20},{"kind":"photo","photoId":"cover","frame":[26,40,48,43],"rotation":2,"border":{"width":4.5,"color":"#ffffff"}},{"kind":"shape","shape":"organic-2","token":"accent3","frame":[19,73,16,16],"rotation":20}] +- postcard: [{"kind":"sticker","stickerId":"landmark","frame":[26,8,48,44]},{"kind":"photo","photoId":"2","frame":[72,8,20,24],"rotation":6,"edge":"perforated","edgeSize":3,"border":{"width":1.6,"color":"#ffffff"}},{"kind":"shape","shape":"wave","token":"ink","frame":[72,35,20,4.5]},{"lockup":"title-script-year","box":[8,60,84,32]}] on a pastel paper; without a landmark of its places, the cover photo in the sticker's frame with mask arch-tall and a white cutout of 2 mm. +- grid-badge: [{"kind":"photo","photoId":"cover","frame":[-2,-2,50.8,50.8]},{"kind":"photo","photoId":"2","frame":[51.2,-2,50.8,50.8]},{"kind":"photo","photoId":"3","frame":[-2,51.2,50.8,50.8]},{"kind":"photo","photoId":"4","frame":[51.2,51.2,50.8,50.8]},{"lockup":"circle-badge","box":[29,29,42,42]}], a cross of paper 2.4 % (5 mm) wide, the badge off every photo's focal point. +- minimal-serif: [{"kind":"photo","photoId":"cover","frame":[8,8,84,53],"border":{"width":0.3,"color":"ink"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":80,"leading":1,"frame":[8,64,84,17]},{"kind":"text","binding":{"source":"journey.subtitle"},"fontRole":"heading","token":"ink","size":11,"frame":[8,82,84,3]},{"kind":"shape","shape":"rect","token":"ink","frame":[28,88.4,20,0.15]},{"kind":"shape","shape":"sun","token":"accent","frame":[48.5,87,3,3]},{"kind":"shape","shape":"rect","token":"ink","frame":[52,88.4,20,0.15]},{"kind":"text","text":"2026","size":8,"frame":[8,92,84,3]}] +- stamp-collage: [{"kind":"shape","shape":"rect","paint":{"type":"pattern","pattern":"checker","fg":"accent","bg":"paper","scale":1,"angle":0},"frame":[-2,-2,104,36]},{"kind":"photo","photoId":"cover","frame":[8,19,26,31],"rotation":-4,"edge":"perforated","edgeSize":3,"border":{"width":2.2,"color":"#ffffff"}},{"kind":"photo","photoId":"2","frame":[37,17,26,34],"rotation":3,"mask":"arch-tall","cutout":{"width":1.8,"color":"#ffffff"}},{"kind":"photo","photoId":"3","frame":[66,19,26,31],"rotation":-3,"edge":"perforated","edgeSize":3,"border":{"width":2.2,"color":"#ffffff"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":90,"weight":800,"transform":"upper","align":"left","frame":[8,62,52,18]},{"kind":"text","text":"May 2026","size":9,"frame":[8,81,52,4]},{"lockup":"postmark","box":[62,60,30,30]}] +Lively spreads, in percent of the double page (the fold at 50); photos 1 to 4 are the page's, entry its entry id: +- script overlay: [{"kind":"photo","photoId":"1","focalX":0.3,"frame":[-1,-2,102,104]},{"kind":"text","binding":{"source":"entry.location","entryId":"entry"},"fontRole":"script","size":120,"color":"#ffffff","outline":{"color":"#1a1a1a","width":2.9},"frame":[55,26,40,30],"rotation":-6},{"kind":"text","binding":{"source":"entry.title","entryId":"entry"},"token":"ink","size":9,"highlight":{"color":"paper"},"frame":[57,62,36,4]}], the photo's detail off the fold and the word. +- collage: [{"kind":"photo","photoId":"1","frameStyle":"polaroid","frame":[6,10,26,36],"rotation":-5},{"kind":"photo","photoId":"2","frameStyle":"white","frame":[22,42,24,32],"rotation":4},{"kind":"photo","photoId":"3","frameStyle":"tape","frame":[56,12,30,40],"rotation":-3},{"kind":"photo","photoId":"4","frameStyle":"white","frame":[66,52,26,34],"rotation":6},{"kind":"sticker","stickerId":"motif","frame":[39,8,7,10],"rotation":8}], up to 3 prints a page turned 3 to 7 degrees either way, none across the fold or over another's focal point. +- two-tone: [{"kind":"photo","photoId":"1","frame":[-1,-2,51,104]},{"kind":"shape","shape":"rect","token":"accent","frame":[50,-2,51,66]},{"kind":"shape","shape":"rect","token":"accent2","frame":[50,64,51,38]},{"kind":"text","text":"1 May 2026","token":"paper","size":9,"frame":[57,10,36,4]},{"kind":"text","binding":{"source":"entry.title","entryId":"entry"},"fontRole":"display","token":"paper","size":66,"leading":1,"align":"left","frame":[57,17,36,40]},{"kind":"text","binding":{"source":"entry.location","entryId":"entry"},"token":"paper","size":9,"frame":[57,72,36,4]}]`; diff --git a/server/src/nest/assistant/assistant.prompt.ts b/server/src/nest/assistant/assistant.prompt.ts index 73adca118..7d15c5ba8 100644 --- a/server/src/nest/assistant/assistant.prompt.ts +++ b/server/src/nest/assistant/assistant.prompt.ts @@ -1,4 +1,5 @@ import { BOOK_DESIGN_PROMPT } from './assistant-book-design.prompt'; +import { BOOK_RECIPES_PROMPT } from './assistant-book-recipes.prompt'; import { CARD_PROMPT_LINES } from './cards/registry'; /** @@ -78,12 +79,13 @@ const READER_PHOTO_SOURCES = 'the book or suggest_book_photos'; /** * The Studio's standing instructions, telling the model which language to - * answer in. The designer's rules and the design draft come only with the - * right to change the book, as the design's tools do; each of the two - * variants is the same text every turn, so the provider's cache holds. + * answer in. The art director's index and recipes, the designer's rules and + * the design draft come only with the right to change the book, as the + * design's tools do; each of the two variants is the same text every turn, + * so the provider's cache holds. */ export function studioPrompt(language: string, canWrite: boolean): string { const sources = canWrite ? WRITER_PHOTO_SOURCES : READER_PHOTO_SOURCES; const base = STUDIO_INSTRUCTIONS.replace('{language}', language).replace('{photoSources}', sources); - return canWrite ? `${base}\n\n${BOOK_DESIGN_PROMPT}` : base; + return canWrite ? `${base}\n\n${BOOK_RECIPES_PROMPT}\n\n${BOOK_DESIGN_PROMPT}` : base; } diff --git a/server/src/nest/assistant/cards/book/book-trial.ts b/server/src/nest/assistant/cards/book/book-trial.ts index ff5a65dfc..dd837758f 100644 --- a/server/src/nest/assistant/cards/book/book-trial.ts +++ b/server/src/nest/assistant/cards/book/book-trial.ts @@ -12,6 +12,7 @@ import { editBookPageArgs, moveBookPage, moveBookPageArgs, + pageArtFindings, setBookCover, setBookCoverArgs, spreadFindings, @@ -191,7 +192,12 @@ function freshOn(was: readonly BookFinding[], now: readonly BookFinding[], creat function findingsOn(doc: BookDocument, ids: readonly string[], ctx: BookOpsContext): Map { const found = new Map(); for (const spread of doc.spreads) { - if (ids.includes(spread.id)) found.set(spread.id, spreadFindings(spread, doc.page, ctx.photos)); + if (!ids.includes(spread.id)) continue; + // As the write answers them: the print faults, then whether the page reads as designed. + found.set(spread.id, [ + ...spreadFindings(spread, doc.page, ctx.photos), + ...pageArtFindings(spread, doc.page, doc.theme), + ]); } return found; } diff --git a/server/src/nest/journey/book-design-draft.store.ts b/server/src/nest/journey/book-design-draft.store.ts index 0a411ab79..9c95c8928 100644 --- a/server/src/nest/journey/book-design-draft.store.ts +++ b/server/src/nest/journey/book-design-draft.store.ts @@ -1,6 +1,13 @@ import { DomainError } from '../common/domain-error'; import { Injectable } from '@nestjs/common'; -import type { BookDesignLeftOut, BookDesignPlanRow, BookDesignStyleOut, BookDocument, BookFinding } from '@trek/shared'; +import type { + BookBriefArt, + BookDesignLeftOut, + BookDesignPlanRow, + BookDesignStyleOut, + BookDocument, + BookFinding, +} from '@trek/shared'; /** * Whole-book designs waiting for the person's yes (journey-studio-wp13.md @@ -38,6 +45,8 @@ export interface BookDesignDraft { leftOut: BookDesignLeftOut[]; findings: BookFinding[]; notes: string[]; + /** The brief the design was made against, so a revised page is judged by it too; none for a design without one. */ + brief?: BookBriefArt; /** Epoch milliseconds. */ expiresAt: number; } diff --git a/server/src/nest/journey/journey-book-design.mcp.ts b/server/src/nest/journey/journey-book-design.mcp.ts index 3bdca2379..657ac08e3 100644 --- a/server/src/nest/journey/journey-book-design.mcp.ts +++ b/server/src/nest/journey/journey-book-design.mcp.ts @@ -61,7 +61,7 @@ export class JourneyBookDesignMcp { @Tool({ name: 'design_book', - description: `Design the whole photo book of a journey as a draft: chapters, pacing, the pages, the photos picked for each, type and colours, with the engine's own check run on every page and repaired where it can. directives says what the person wants (style, density, page count, scope, chapters, photos, cover, type, palette, words, travel pages, what to keep). It answers the draft's design_id, the plan page by page (what each page becomes and its findings), the look, the photos left out and why, and the findings left. ${DRAFT_FLOW} ${LIMITS} Only the book's owner and editors can design it.`, + description: `Design the whole photo book of a journey as a draft: chapters, pacing, the pages, the photos picked for each, type and colours, with the engine's own check run on every page and repaired where it can. directives says what the person wants (style, density, page count, scope, chapters, photos, cover, type, palette, words, travel pages, what to keep) and the art direction: the brief (energy, decoration, collages, theme), the cover's archetype and lockup, and pages.overrides giving single pages of the plan another beat or layout. It answers the draft's design_id, the plan page by page (what each page becomes and its findings), the look, the photos left out and why, and the findings left. ${DRAFT_FLOW} ${LIMITS} Only the book's owner and editors can design it.`, inputSchema: { journeyId: idSchema, ...designBookArgs }, annotations: TOOL_ANNOTATIONS_READONLY, when: journeyAddonOn, diff --git a/server/src/nest/journey/journey-book-design.service.ts b/server/src/nest/journey/journey-book-design.service.ts index 1c236bcb7..c4a26cb20 100644 --- a/server/src/nest/journey/journey-book-design.service.ts +++ b/server/src/nest/journey/journey-book-design.service.ts @@ -25,9 +25,11 @@ import { BOOK_DESIGN_LIMITS, BOOK_OP_LIMITS, bookPageSetupForPreset, + briefFindings, describeSpread, designBook, normalizeBookDocument, + pageArtFindings, sortBookFindings, spreadFindings, summarizeSpread, @@ -49,6 +51,7 @@ import { type RestoreBookRevisionRequest, type ReviseBookDesignRequest, } from '@trek/shared'; +import { BOOK_STICKERS } from '@trek/shared/stickers'; import { randomUUID } from 'node:crypto'; @@ -109,11 +112,13 @@ export class JourneyBookDesignService { selection: result.plan .filter((row) => row.action !== 'keep' && (row.action !== 'drop' || freshBook)) .map((row) => row.spreadId), - style: { style: directives.style ?? 'classic', ...result.style }, + // The brief's theme takes the place of the style, as the engine reads it. + style: { style: directives.brief?.theme ?? directives.style ?? 'classic', ...result.style }, usedPhotos: result.photos.used.length, leftOut: result.photos.left_out, findings: result.findings, notes: result.notes, + ...(result.brief ? { brief: result.brief } : {}), expiresAt: Date.now() + BOOK_DESIGN_DRAFTS.ttlMs, }; await this.drafts.put(draft); @@ -142,7 +147,9 @@ export class JourneyBookDesignService { const index = made.doc.spreads.findIndex((s) => s.id === args.spreadId); const spread = made.doc.spreads[index]; if (!described || !spread) throw new DomainError(404, `No page ${args.spreadId} in this design`); - const findings = described.findings; + // The page judged against the design's brief too: its stickers and turns as the energy asked. + const brief = draft.brief ? briefFindings(made.doc, draft.brief, { only: [args.spreadId] }) : []; + const findings = sortBookFindings([...described.findings, ...brief]); const count = (severity: 'severe' | 'warn') => findings.filter((f) => f.severity === severity).length; const plan = draft.plan.map((row): BookDesignPlanRow => row.spreadId === args.spreadId @@ -347,6 +354,8 @@ export class JourneyBookDesignService { candidates, photos, gallery: facts.map((f) => ({ photoId: f.photo_id, width: f.width, height: f.height, caption: f.caption })), + // The sticker library's facts, for the stickers a decorated design places. + stickers: BOOK_STICKERS, }; } } @@ -384,7 +393,12 @@ function pagesAnswer(record: BookRecord, spreadIds: readonly string[]): BookWrit const doc = record.document; const named = new Set(spreadIds); const pages = doc.spreads.flatMap((s, i) => (named.has(s.id) ? [{ spread: s, index: i }] : [])); - const findings = sortBookFindings(pages.flatMap(({ spread }) => spreadFindings(spread, doc.page))); + const findings = sortBookFindings( + pages.flatMap(({ spread }) => [ + ...spreadFindings(spread, doc.page), + ...pageArtFindings(spread, doc.page, doc.theme), + ]), + ); return { version: record.version, spread_ids: pages.map(({ spread }) => spread.id), diff --git a/server/src/nest/journey/journey-book-edit.service.ts b/server/src/nest/journey/journey-book-edit.service.ts index b6ce3e228..c86a9bb00 100644 --- a/server/src/nest/journey/journey-book-edit.service.ts +++ b/server/src/nest/journey/journey-book-edit.service.ts @@ -24,8 +24,10 @@ import { deleteBookPages, describeSpread, duplicateBookPage, + listBookCatalogue, listBookLayouts, moveBookPage, + pageArtFindings, removeBookLayout, restoreBookLayout, restoreBookPage, @@ -172,13 +174,16 @@ export class JourneyBookEditService { } /** - * `list_book_assets`: the Studio's sticker library, searched. Not of the + * `list_book_assets`: the Studio's catalogues searched, the stickers, the + * shapes, the faces, the lockups, the themes or the patterns. Not of the * journey, but asked on one, a member's read like the other book reads, so * the Studio chat keeps it (its tools are pinned to the journey). */ - async assets(journeyId: number, userId: number, search: Omit) { + async assets(journeyId: number, userId: number, search: ListBookAssetsRequest) { await this.readable(journeyId, userId); - return { kind: 'sticker' as const, ...listBookAssets(search) }; + const { kind, theme, ...words } = search; + if (kind === 'sticker') return { kind, ...listBookAssets({ ...words, theme }) }; + return { kind, ...listBookCatalogue(kind, words) }; } /** @@ -514,7 +519,8 @@ function pagesOf(doc: BookDocument, made: BookMade, ctx: BookOpsContext) { const spread = doc.spreads[index]; if (!spread) continue; pages.push(summarizeSpread(spread, index)); - findings.push(...spreadFindings(spread, doc.page, ctx.photos)); + // The print faults, and whether the page reads as designed (a flat cover, a weak lead, colours off the theme). + findings.push(...spreadFindings(spread, doc.page, ctx.photos), ...pageArtFindings(spread, doc.page, doc.theme)); } const order = { severe: 0, warn: 1, info: 2 } as const; findings.sort((a, b) => order[a.severity] - order[b.severity]); diff --git a/server/src/nest/journey/journey-book.mcp.ts b/server/src/nest/journey/journey-book.mcp.ts index 3b3fb5e0d..0ca6af8fe 100644 --- a/server/src/nest/journey/journey-book.mcp.ts +++ b/server/src/nest/journey/journey-book.mcp.ts @@ -134,14 +134,14 @@ export class JourneyBookMcp { @Tool({ name: 'list_book_assets', description: - "The Studio's library of things to put on a page of a journey's book, searchable, the same one the Studio's panels show. kind sticker lists the illustrations: flat, multi-coloured drawings of landmarks, travel things, tropical and Mediterranean motifs, the sea, mountains, the north, Asia, food, paper and tape, hand-drawn doodles and ornaments. Each row has its id, theme, one sentence of what it shows, its tags, its aspect (width over height) and its colour slots with their defaults. query searches ids, tags and descriptions in English; tags and theme narrow it; at most limit rows, 40 by default, with the total found. Place one with edit_book_page add { kind: 'sticker', stickerId }, recolour it with colors (one per slot, in order) and mirror it with flipX. Stickers are decoration: put them beside or over a photo, never over words.", + "The Studio's catalogues of things to put on a page of a journey's book, searchable, the ones the Studio's panels show; every row has its id, one line on what it is and its tags, at most limit rows (40 by default) with the total found. kind sticker: flat, multi-coloured illustrations (landmarks, travel things, tropical and Mediterranean motifs, the sea, mountains, the north, Asia, food, paper and tape, doodles, ornaments) with their aspect and colour slots; place one with edit_book_page add { kind: 'sticker', stickerId }, recolour it with colors and mirror it with flipX; beside or over a photo, never over words. kind shape: the outlines a shape draws and a photo is cut to (mask), tagged frame when it holds a photo and edge when it takes a cut edge. kind font: the typefaces by role (display, heading, body, script, mono) with their weights. kind lockup: the set titles, badges, postmarks and quotes add_lockup places, with the word roles each takes. kind theme: the built-in themes with palette, faces and motifs, for set_book_theme and design_book's brief. kind pattern: the repeats a paint takes. query searches ids, tags and descriptions in English; tags narrow it; theme narrows stickers. Templates are list_book_layouts'.", inputSchema: { journeyId: idSchema, ...listBookAssetsArgs }, annotations: TOOL_ANNOTATIONS_READONLY, when: journeyAddonOn, access: { group: 'journey', mode: 'read' }, }) - async listAssets({ journeyId, query, tags, theme, limit }: OnJourney & ListBookAssetsRequest, ctx: McpContext) { - return ok(await this.books.assets(journeyId, ctx.userId, { query, tags, theme, limit })); + async listAssets({ journeyId, ...search }: OnJourney & ListBookAssetsRequest, ctx: McpContext) { + return ok(await this.books.assets(journeyId, ctx.userId, search)); } // ── Write ─────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/nest/assistant/assistant-book-preview.test.ts b/server/tests/unit/nest/assistant/assistant-book-preview.test.ts index 0158b8a19..eb1034c7e 100644 --- a/server/tests/unit/nest/assistant/assistant-book-preview.test.ts +++ b/server/tests/unit/nest/assistant/assistant-book-preview.test.ts @@ -2,7 +2,8 @@ * withPagePreview (ASTPVW-*): a Studio turn's tools with `preview_book_page` * beside them. The preview takes the session's own `edit_book_page` ops * schema, runs as a read through the turn's vision, refuses arguments it does - * not take, and is capped per step and per turn. + * not take, and is capped per step and per turn, more while an editor's turn + * designs the book. */ import type { AgentToolPort, AgentToolRun } from '../../../../src/nest/assistant/assistant-agent'; import { withPagePreview } from '../../../../src/nest/assistant/assistant-book-preview'; @@ -120,4 +121,75 @@ describe('withPagePreview', () => { const refused = await tools.call('preview_book_page', { spreadId: 'sp-2', designId: '../d' }); expect(refused).toMatchObject({ ok: false, value: expect.stringContaining('designId') }); }); + + it('ASTPVW-007: a turn that designs the book may look at sixteen pages, still three a step', async () => { + const { tools, vision, session } = setup(); + expect(ASSISTANT_PAGE_IMAGE).toMatchObject({ previewsPerDesignTurn: 16, perRequest: 3 }); + const preview = () => tools.call('preview_book_page', { spreadId: 'sp-2' }); + /** One step of `count` previews, one after another as the loop runs them; whether each was answered. */ + const step = (count: number) => { + tools.specs(); + return Array.from({ length: count }).reduce>( + async (done) => [...(await done), (await preview()).ok], + Promise.resolve([]), + ); + }; + // Eight in a turn before any design is in play, as before. + const before = [...(await step(3)), ...(await step(3)), ...(await step(3))]; + expect(before).toEqual([true, true, true, true, true, true, true, true, false]); + // design_book goes to the session, and from then on the turn may look at sixteen. + await tools.call('design_book', { directives: {} }); + expect(session.call).toHaveBeenCalledWith('design_book', { directives: {} }); + const more = [...(await step(4)), ...(await step(3)), ...(await step(3))]; + expect(more).toEqual([true, true, true, false, true, true, true, true, true, false]); + expect(vision.look).toHaveBeenCalledTimes(16); + expect((await preview()).value).toEqual(expect.stringContaining('16 while a book design is in play')); + }); + + it('ASTPVW-008: looking at a design draft or revising one puts the design in play too', async () => { + /** `count` previews, each in a step of its own. */ + const looks = (tools: AgentToolPort, count: number, args: Record) => + Array.from({ length: count }).reduce>(async (done) => { + await done; + tools.specs(); + await tools.call('preview_book_page', args); + }, Promise.resolve()); + const drafted = setup(); + await looks(drafted.tools, 12, { spreadId: 'sp-2', designId: 'd-1' }); + expect(drafted.vision.look).toHaveBeenCalledTimes(12); + const revised = setup(); + // The loop asks for the specs at the start of the step that revises. + revised.tools.specs(); + await revised.tools.call('revise_book_design', { designId: 'd-1', spreadId: 'sp-2', ops: [] }); + await looks(revised.tools, 10, { spreadId: 'sp-2' }); + expect(revised.vision.look).toHaveBeenCalledTimes(10); + const editor = setup().tools.specs()[1]; + expect(editor?.description).toContain('16 in a turn that designs the book'); + }); + + it('ASTPVW-009: only an editor whose design run or draft look came back puts the design in play', async () => { + /** `count` previews, each in a step of its own; how many the vision was asked for. */ + const looks = async (made: ReturnType, count: number, args: Record) => { + await Array.from({ length: count }).reduce>(async (done) => { + await done; + made.tools.specs(); + await made.tools.call('preview_book_page', args); + }, Promise.resolve()); + return made.vision.look.mock.calls.length; + }; + // A viewer is offered no designId; passing one anyway keeps the plain budget. + const viewer = setup([]); + expect(await looks(viewer, 10, { spreadId: 'sp-2', designId: 'd-1' })).toBe(8); + expect((await viewer.tools.call('preview_book_page', { spreadId: 'sp-2' })).value).not.toContain('design'); + // A design_book that failed puts nothing in play. + const failed = setup(); + failed.tools.specs(); + vi.mocked(failed.session.call).mockResolvedValueOnce({ ok: false, value: 'Invalid directives' }); + expect((await failed.tools.call('design_book', { directives: {} })).ok).toBe(false); + expect(await looks(failed, 10, { spreadId: 'sp-2' })).toBe(8); + // Nor does a draft the look could not find. + const missing = setup(); + missing.vision.look.mockResolvedValue({ ok: false, value: 'No such design' }); + expect(await looks(missing, 10, { spreadId: 'sp-2', designId: 'd-9' })).toBe(8); + }); }); diff --git a/server/tests/unit/nest/assistant/assistant-book-recipes.prompt.test.ts b/server/tests/unit/nest/assistant/assistant-book-recipes.prompt.test.ts new file mode 100644 index 000000000..8a040c4f6 --- /dev/null +++ b/server/tests/unit/nest/assistant/assistant-book-recipes.prompt.test.ts @@ -0,0 +1,162 @@ +/** + * The art director's index and recipes (ASTRCP-*): placed before the + * designer's rules for those who may change the book, built from the + * catalogues so it names only what the book has, its recipes JSON a model + * can send as the page edit's operations, and kept to a budget the prompt + * cache carries every turn. + */ +import { BOOK_DESIGN_PROMPT } from '../../../../src/nest/assistant/assistant-book-design.prompt'; +import { BOOK_RECIPES_PROMPT } from '../../../../src/nest/assistant/assistant-book-recipes.prompt'; +import { studioPrompt } from '../../../../src/nest/assistant/assistant.prompt'; +import { + BOOK_COVER_ARCHETYPES, + BOOK_FONTS_IDS, + BOOK_LOCKUPS, + BOOK_PATTERNS, + BOOK_SHAPES, + BOOK_THEME_IDS, + bookPageOpSchema, +} from '@trek/shared'; +import { BOOK_STICKERS } from '@trek/shared/stickers'; + +import { describe, expect, it } from 'vitest'; + +type Item = Record; + +/** The recipe lines, by name, with the JSON array each gives. */ +function recipes(): Map { + const out = new Map(); + for (const line of BOOK_RECIPES_PROMPT.split('\n')) { + const found = /^- ([a-z -]+): (\[.*\])(?:[ ,;]|$)/.exec(line); + if (!found?.[1] || !found[2]) continue; + // The array ends at the bracket that closes it, before any words after it. + let depth = 0; + let end = 0; + for (let i = 0; i < found[2].length; i++) { + const ch = found[2][i]; + if (ch === '[') depth += 1; + if (ch === ']') depth -= 1; + if (depth === 0) { + end = i + 1; + break; + } + } + out.set(found[1], JSON.parse(found[2].slice(0, end)) as Item[]); + } + return out; +} + +/** The theme's colours the recipes name, as a theme would give them. */ +const COLOURS: Record = { + accent: '#c2410c', + accent2: '#e9b86f', + accent3: '#4a6b5c', + paper: '#ffffff', + ink: '#1a1a1a', +}; + +/** An item with its named colours as hex, as the model sends them where no token is taken. */ +function hexed(value: unknown): unknown { + if (Array.isArray(value)) return value.map(hexed); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [ + k, + ['color', 'fg', 'bg'].includes(k) ? (COLOURS[String(v)] ?? v) : hexed(v), + ]), + ); +} + +const frameOf = (f: unknown) => { + const [x, y, w, h] = f as number[]; + return { x, y, w, h }; +}; + +/** The operations one recipe item stands for: add with its content then style with the rest, a lockup, a background. */ +function opsOf(raw: Item): unknown[] { + const item = hexed(raw) as Item; + if ('lockup' in item) + return [{ op: 'add_lockup', lockup: item.lockup, words: { title: 'T' }, box: frameOf(item.box) }]; + if ('background' in item) return [{ op: 'background', color: COLOURS[String(item.background)] }]; + const { kind, frame, rotation, photoId, text, binding, shape, stickerId, ...look } = item; + const content: Item = { kind }; + if (photoId !== undefined) content.photoId = 7; + if (text !== undefined) content.text = text; + if (binding) content.binding = { ...(binding as Item), ...((binding as Item).entryId ? { entryId: 3 } : {}) }; + if (shape !== undefined) content.shape = shape; + if (stickerId !== undefined) content.stickerId = BOOK_STICKERS[0]?.id; + const add = { op: 'add', element: content, frame: frameOf(frame), ...(rotation !== undefined ? { rotation } : {}) }; + return Object.keys(look).length ? [add, { op: 'style', elementId: 'r-1', set: { kind, ...look } }] : [add]; +} + +describe('the art director in the Studio', () => { + it('ASTRCP-001: comes before the designer rules for those who may change the book, never for a viewer', () => { + const writer = studioPrompt('German', true); + expect(writer).toContain(`\n\n${BOOK_RECIPES_PROMPT}\n\n${BOOK_DESIGN_PROMPT}`); + expect(writer.endsWith(BOOK_DESIGN_PROMPT)).toBe(true); + expect(studioPrompt('German', false)).not.toContain('Art direction:'); + expect(studioPrompt('German', true)).toBe(writer); + }); + + it('ASTRCP-002: asks for the brief first and indexes every theme, archetype and face', () => { + expect(BOOK_RECIPES_PROMPT).toContain('Write the brief before design_book'); + expect(BOOK_RECIPES_PROMPT).toContain('directives.brief'); + for (const id of BOOK_THEME_IDS) expect(BOOK_RECIPES_PROMPT, id).toContain(`${id} (`); + for (const archetype of BOOK_COVER_ARCHETYPES) + expect(BOOK_RECIPES_PROMPT, archetype).toContain(`- ${archetype}: [`); + const faces = BOOK_RECIPES_PROMPT.split('\n').find((line) => line.startsWith('- Faces by role')) ?? ''; + for (const id of BOOK_FONTS_IDS) expect(faces, id).toMatch(new RegExp(`[ :,]${id}[,;.]`)); + for (const recipe of ['- script overlay: [', '- collage: [', '- two-tone: [']) + expect(BOOK_RECIPES_PROMPT).toContain(recipe); + }); + + it('ASTRCP-003: gives every recipe as operations the page edit takes, naming only what the book has', () => { + const all = recipes(); + expect([...all.keys()]).toEqual([...BOOK_COVER_ARCHETYPES, 'script overlay', 'collage', 'two-tone']); + const items = [...all.values()].flat(); + for (const item of items) { + for (const op of opsOf(item)) { + const parsed = bookPageOpSchema.safeParse(op); + expect({ op, ok: parsed.success }).toEqual({ op, ok: true }); + } + } + const lockups = items.flatMap((i) => (typeof i.lockup === 'string' ? [i.lockup] : [])); + expect(lockups).toEqual(['title-script-year', 'circle-badge', 'postmark']); + for (const id of lockups) expect(BOOK_LOCKUPS.map((l) => l.id)).toContain(id); + const shapes = items.flatMap((i) => [i.shape, i.mask].filter((v): v is string => typeof v === 'string')); + expect(shapes.length).toBeGreaterThanOrEqual(8); + for (const id of shapes) expect(BOOK_SHAPES as readonly string[], id).toContain(id); + const patterns = items.flatMap((i) => { + const paint = i.paint as { pattern?: string } | undefined; + return paint?.pattern ? [paint.pattern] : []; + }); + expect(patterns).toEqual(['checker']); + for (const id of patterns) expect(BOOK_PATTERNS as readonly string[]).toContain(id); + }); + + it('ASTRCP-005: sets the spine-band title along the band, the frame turned about its centre on the page', () => { + const spine = recipes().get('spine-band') ?? []; + const band = spine.find((i) => i.shape === 'rect'); + const title = spine.find((i) => (i.binding as Item | undefined)?.source === 'journey.title'); + expect(band?.frame).toEqual([-2, -2, 18, 104]); + expect(title?.rotation).toBe(-90); + const [x, y, w, h] = title?.frame as number[]; + // Turned a quarter, a box 74 by 10 stands 10 wide and 74 tall about the same centre: on the band, on the page. + const turned = { x: x + w / 2 - h / 2, y: y + h / 2 - w / 2, w: h, h: w }; + expect(turned.x).toBeGreaterThanOrEqual(0); + expect(turned.x + turned.w).toBeLessThanOrEqual(16); + expect(turned.y).toBeGreaterThanOrEqual(5); + expect(turned.y + turned.h).toBeLessThanOrEqual(95); + // The grid's cells run past the trim, a cross of paper between them. + const cells = (recipes().get('grid-badge') ?? []).filter((i) => i.kind === 'photo').map((i) => i.frame as number[]); + expect(cells.map(([cx]) => cx)).toEqual([-2, 51.2, -2, 51.2]); + expect(Math.round(((cells[1]?.[0] ?? 0) - ((cells[0]?.[0] ?? 0) + (cells[0]?.[2] ?? 0))) * 10) / 10).toBe(2.4); + }); + + it('ASTRCP-004: keeps to a budget the cache carries every turn, without dashes', () => { + expect(BOOK_RECIPES_PROMPT.length).toBeLessThan(9500); + expect(BOOK_RECIPES_PROMPT.split('\n').length).toBeLessThanOrEqual(24); + expect(BOOK_RECIPES_PROMPT).not.toMatch(/[\u2013\u2014]/); + expect(BOOK_RECIPES_PROMPT).not.toContain(' - '); + }); +}); diff --git a/server/tests/unit/nest/journey-book-assets.mcp.test.ts b/server/tests/unit/nest/journey-book-assets.mcp.test.ts index fa402cad3..cef8919ad 100644 --- a/server/tests/unit/nest/journey-book-assets.mcp.test.ts +++ b/server/tests/unit/nest/journey-book-assets.mcp.test.ts @@ -1,6 +1,7 @@ /** - * list_book_assets and the sticker kind (JBASSET-*): the Studio's sticker - * library searched through JourneyBookMcp, and a sticker placed with + * list_book_assets (JBASSET-*): the Studio's catalogues searched through + * JourneyBookMcp, the sticker library and the shapes, faces, lockups, themes + * and patterns beside it, and a sticker placed with * edit_book_page from the library the edit context hands the engine. Taken * from JourneyBookModule over a snapshot test db. * @@ -21,7 +22,7 @@ import { makeUser } from '../../helpers/factories/users'; import { resetTestDb } from '../../helpers/test-db'; import { createTestModule, type TestModule } from '../../helpers/test-module'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; -import { bookOverviewSchema, bookWriteResultSchema } from '@trek/shared'; +import { bookOverviewSchema, bookWriteResultSchema, type ListBookAssetsRequest } from '@trek/shared'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -98,6 +99,41 @@ describe('list_book_assets', () => { 'Journey not found', ); }); + + it('JBASSET-005: lists the shapes, faces, lockups, themes and patterns too, each row an id, a line and its tags', async () => { + const { user: owner } = await makeUser(orm); + const journey = await makeJourney(orm, owner.id); + const list = async (args: ListBookAssetsRequest) => + json(await mcp.listAssets({ journeyId: journey.id, ...args }, ctx(owner.id))); + + const arches = await list({ kind: 'shape', query: 'arch', limit: 3 }); + expect(arches).toMatchObject({ kind: 'shape', rows: [{ id: 'arch' }, {}, {}] }); + expect(arches.total).toBeGreaterThan(3); + expect(arches.rows[0]).toMatchObject({ frame: true, edges: true, tags: expect.arrayContaining(['basic']) }); + const scripts = await list({ kind: 'font', tags: ['script'] }); + expect(scripts.rows.length).toBeGreaterThanOrEqual(10); + expect(scripts.rows.every((r: { tags: string[] }) => r.tags.includes('script'))).toBe(true); + const badge = await list({ kind: 'lockup', query: 'postmark' }); + expect(badge.rows[0]).toMatchObject({ id: 'postmark', words: expect.arrayContaining(['place', 'dates']) }); + const themes = await list({ kind: 'theme', query: 'tropical' }); + const tropical = themes.rows.find((r: { id: string }) => r.id === 'tropical'); + expect(tropical).toMatchObject({ palette: { paper: expect.any(String) }, fonts: { display: expect.any(String) } }); + const patterns = await list({ kind: 'pattern' }); + expect(patterns.rows.map((r: { id: string }) => r.id)).toContain('gingham'); + // A theme narrows stickers only; the other kinds pay it no heed. + expect((await list({ kind: 'pattern', theme: 'paper' })).total).toBe(patterns.total); + }); + + it('JBASSET-006: says what each kind lists, and that templates are list_book_layouts', () => { + const entry = getEntry(JourneyBookMcp as unknown as ClassRef, 'listAssets'); + if (entry?.kind !== 'tool') throw new Error('listAssets is no tool'); + const description = entry.options.description ?? ''; + for (const kind of ['kind sticker', 'kind shape', 'kind font', 'kind lockup', 'kind theme', 'kind pattern']) { + expect(description).toContain(kind); + } + expect(description).toContain("Templates are list_book_layouts'"); + expect(description).not.toMatch(/[\u2013\u2014]/); + }); }); describe('a sticker on a page', () => { diff --git a/server/tests/unit/nest/journey-book-edit.service.test.ts b/server/tests/unit/nest/journey-book-edit.service.test.ts index 0ab65c5f6..91263a76a 100644 --- a/server/tests/unit/nest/journey-book-edit.service.test.ts +++ b/server/tests/unit/nest/journey-book-edit.service.test.ts @@ -229,6 +229,15 @@ describe('editing a page', () => { expect(sent()).toEqual(saved(everyone)); }); + it('JBEDIT-016: an edit answers whether the page reads as designed: a cover with a small title alone is flat', async () => { + const { journey, editor, cover } = await crew(); + const answer = await edit.editPage(journey.id, editor.id, { + spreadId: cover, + ops: [{ op: 'text', role: 'heading', text: 'Lisbon', size: 24 }], + }); + expect(answer.findings).toContainEqual(expect.objectContaining({ code: 'flat_cover', severity: 'warn' })); + }); + it('JBEDIT-005: previous_spreads holds the page as it was, only when the call asks for it', async () => { const { journey, owner, inner } = await crew(); const photo = await galleryPhoto(journey.id, owner.id); diff --git a/server/tests/unit/nest/journey/journey-book-design.service.test.ts b/server/tests/unit/nest/journey/journey-book-design.service.test.ts index 657989813..d59bc1498 100644 --- a/server/tests/unit/nest/journey/journey-book-design.service.test.ts +++ b/server/tests/unit/nest/journey/journey-book-design.service.test.ts @@ -15,7 +15,10 @@ * stale version, and forgets the draft; a journey without a book gets a new * one and no revision; restoring the revision puts the book back only at the * version the apply left, and never writes a revision that does not read - * back or that a newer TREK kept; five revisions are kept. + * back or that a newer TREK kept; five revisions are kept; a design made + * with a brief composes its archetype cover and is decorated from the sticker + * library the service hands the engine, and a page of it revised later is + * judged against that brief. */ import { JourneyBookRevisions } from '../../../../src/db/entities/JourneyBookRevisions.entity'; import { JourneyEntryPhotos } from '../../../../src/db/entities/JourneyEntryPhotos.entity'; @@ -40,6 +43,7 @@ import { bookDesignApplyResultSchema, type BookDesignAnswer, } from '@trek/shared'; +import { BOOK_STICKERS } from '@trek/shared/stickers'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; @@ -368,3 +372,49 @@ describe('taking a design back', () => { expect(await findRow(orm, JourneyBookRevisions, { id: kept[0] ?? 0 })).toBeNull(); }); }); + +describe('the art direction', () => { + it('JBDSN-012: design_book composes the archetype cover and decorates from the theme motifs of the library', async () => { + const { journey, owner } = await crew(); + const directives = { brief: { energy: 'loud' as const, decoration: 'rich' as const, theme: 'tropical' as const } }; + const answer = await designs.design(journey.id, owner.id, { ...directives, cover: { archetype: 'poster-pop' } }); + + expect(answer.style.style).toBe('tropical'); + const { document } = await designs.draftView(journey.id, owner.id, answer.design_id); + expect(document.theme?.id).toBe('tropical'); + const cover = document.spreads[0]; + expect(cover?.elements.some((e) => e.kind === 'shape' && e.shape === 'sunburst-24')).toBe(true); + const tropical = new Set(BOOK_STICKERS.filter((s) => s.theme === 'tropical').map((s) => s.id)); + const stickers = document.spreads.flatMap((s) => s.elements).flatMap((e) => (e.kind === 'sticker' ? [e] : [])); + expect(stickers.length).toBeGreaterThanOrEqual(3); + expect(stickers.every((s) => tropical.has(s.stickerId))).toBe(true); + expect(answer.findings.filter((f) => f.severity === 'severe')).toEqual([]); + }); + + it('JBDSN-013: a revised page of a design made with a brief is judged against it; one made without is not', async () => { + const { journey, owner } = await crew(); + const framing = new Set(['cover', 'first', 'closing', 'last', 'back']); + const photoPage = (answer: BookDesignAnswer) => + answer.plan.find((row) => row.photoIds.length > 0 && !framing.has(row.beat))?.spreadId ?? ''; + const sticker = [ + { op: 'add' as const, element: { kind: 'sticker' as const, stickerId: BOOK_STICKERS[0]?.id ?? '' } }, + ]; + // Decoration none: a sticker on a photo page is more than the brief asked for. + const bare = await designs.design(journey.id, owner.id, { brief: { decoration: 'none' } }); + const page = photoPage(bare); + expect(page).not.toBe(''); + const revised = await designs.revise(journey.id, owner.id, { + designId: bare.design_id, + spreadId: page, + ops: sticker, + }); + expect(revised.findings).toContainEqual(expect.objectContaining({ code: 'energy_mismatch', severity: 'info' })); + const plain = await designs.design(journey.id, owner.id, {}); + const free = await designs.revise(journey.id, owner.id, { + designId: plain.design_id, + spreadId: photoPage(plain), + ops: sticker, + }); + expect(free.findings.map((f) => f.code)).not.toContain('energy_mismatch'); + }); +}); diff --git a/shared/src/assistant/assistant-vision.schema.ts b/shared/src/assistant/assistant-vision.schema.ts index 370c50b6f..cd4ae5a97 100644 --- a/shared/src/assistant/assistant-vision.schema.ts +++ b/shared/src/assistant/assistant-vision.schema.ts @@ -32,6 +32,12 @@ export const ASSISTANT_PAGE_IMAGE = { /** Previews of a proposed change one step and one turn may ask for. */ previewsPerStep: 3, previewsPerTurn: 8, + /** + * Previews a turn may ask for once a whole-book design is in play (it + * called design_book or revise_book_design, or looks at a draft): a design + * is judged on many pages, the cover twice. Still three images a request. + */ + previewsPerDesignTurn: 16, } as const; /** diff --git a/shared/src/book/book-asset-search.ts b/shared/src/book/book-asset-search.ts new file mode 100644 index 000000000..b3d7bbd65 --- /dev/null +++ b/shared/src/book/book-asset-search.ts @@ -0,0 +1,57 @@ +/** + * The search `list_book_assets` runs over every catalogue it lists (stickers, + * shapes, fonts, lockups, themes, patterns): every word of the query has to + * be found, a word that is the id or one of the tags counts for more than one + * buried in the description, and between equals the catalogue keeps its own + * order. Case and accents do not matter. + */ + +/** Lower case, accents taken off: how ids, tags and words are compared. */ +const normAsset = (s: string): string => s.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, ''); + +/** What an item of a catalogue is found by. */ +export interface AssetKey { + id: string; + /** Every word it is found by: its id, its tags, its description. */ + words: readonly string[]; + tags: readonly string[]; +} + +/** How well an item answers the words of a query, 0 when one of them is not found. */ +function score(key: AssetKey, words: readonly string[]): number { + const hay = normAsset(key.words.join(' ')); + const id = normAsset(key.id); + const tags = key.tags.map(normAsset); + let total = 0; + for (const w of words) { + if (!hay.includes(w)) return 0; + if (id === w || tags.includes(w)) total += 3; + else if (tags.some((t) => t.split(' ').includes(w)) || id.startsWith(w)) total += 2; + else total += 1; + } + return total; +} + +/** + * The items carrying every one of `tags` that answer `query`, best first, + * the catalogue's own order between equals; all of them in that order + * without a query. + */ +export function rankAssets( + pool: readonly T[], + keyOf: (item: T) => AssetKey, + query: string | undefined, + tags: readonly string[] = [], +): T[] { + const wanted = tags.map(normAsset); + const tagged = pool.filter((item) => wanted.every((t) => keyOf(item).tags.map(normAsset).includes(t))); + const words = normAsset(query ?? '') + .split(/\s+/) + .filter(Boolean); + if (!words.length) return tagged; + return tagged + .map((item, i) => ({ item, i, n: score(keyOf(item), words) })) + .filter((r) => r.n > 0) + .sort((a, b) => b.n - a.n || a.i - b.i) + .map((r) => r.item); +} diff --git a/shared/src/book/book-assets.spec.ts b/shared/src/book/book-assets.spec.ts new file mode 100644 index 000000000..504fa7edb --- /dev/null +++ b/shared/src/book/book-assets.spec.ts @@ -0,0 +1,99 @@ +import { + BOOK_ASSET_KINDS, + listBookAssetsRequestSchema, + listBookCatalogue, + type BookShapeAssetRow, +} from './book-assets'; +import { BOOK_FONTS_IDS } from './book-fonts.data'; +import { BOOK_LOCKUPS } from './book-lockups.data'; +import { BOOK_PATTERNS } from './book-paint.schema'; +import { BOOK_FRAME_SHAPES, BOOK_SHAPE_GROUPS } from './book-shape-catalogue'; +import { bookShapeTakesEdge } from './book-shapes-more'; +import { BOOK_SHAPES } from './book.schema'; +import { BOOK_THEME_IDS } from './theme/book-theme.schema'; + +import { describe, expect, it } from 'vitest'; + +/** + * `list_book_assets` beyond stickers (book-assets.ts): the shapes, the faces, + * the lockups, the themes and the patterns, each a row of id, line and tags + * with what it takes, searched the way the stickers are, and the one list of + * shape groups and frames the Studio's panels read too. + */ + +const all = (kind: Exclude<(typeof BOOK_ASSET_KINDS)[number], 'sticker'>) => listBookCatalogue(kind, { limit: 40 }); +const ids = (rows: readonly { id: string }[]) => rows.map((r) => r.id); + +describe('the catalogues list_book_assets lists', () => { + it('BKASSET-001: takes the six kinds, templates staying with list_book_layouts', () => { + expect(BOOK_ASSET_KINDS).toEqual(['sticker', 'shape', 'font', 'lockup', 'theme', 'pattern']); + expect(listBookAssetsRequestSchema.safeParse({ kind: 'lockup', query: 'badge' }).success).toBe(true); + expect(listBookAssetsRequestSchema.safeParse({ kind: 'template' }).success).toBe(false); + }); + + it('BKASSET-002: lists every shape with a line, its group, whether it frames a photo and takes a cut edge', () => { + const found = listBookCatalogue('shape', {}); + expect(found.total).toBe(BOOK_SHAPES.length); + expect(found.rows).toHaveLength(40); + const rows = listBookCatalogue('shape', { limit: 40, query: 'arch' }).rows as BookShapeAssetRow[]; + expect(rows[0]?.id).toBe('arch'); + const tall = rows.find((r) => r.id === 'arch-tall'); + expect(tall).toMatchObject({ frame: true, edges: true, tags: ['arches', 'frame', 'edge'] }); + expect(tall?.descr).toMatch(/arch/i); + for (const row of listBookCatalogue('shape', { query: 'star' }).rows as BookShapeAssetRow[]) { + expect(row.edges).toBe(bookShapeTakesEdge(row.id)); + expect(row.descr.length).toBeGreaterThan(5); + } + expect(ids(listBookCatalogue('shape', { tags: ['ornaments'] }).rows)).toEqual([ + 'flourish-scroll', + 'laurel', + 'fleuron', + 'corner-flourish', + ]); + }); + + it('BKASSET-003: keeps the shape groups and frames to shapes the book draws, each shape in one group at most', () => { + const grouped = BOOK_SHAPE_GROUPS.flatMap((g) => g.shapes); + expect(new Set(grouped).size).toBe(grouped.length); + expect(grouped.every((s) => (BOOK_SHAPES as readonly string[]).includes(s))).toBe(true); + expect(BOOK_FRAME_SHAPES.every((s) => (BOOK_SHAPES as readonly string[]).includes(s))).toBe(true); + }); + + it('BKASSET-004: lists every face by its role, with its weights and whether it has a real italic', () => { + expect(ids(listBookCatalogue('font', { limit: 40 }).rows)).toEqual([...BOOK_FONTS_IDS]); + const scripts = listBookCatalogue('font', { tags: ['script'] }).rows; + expect(scripts.length).toBeGreaterThanOrEqual(10); + const caveat = scripts.find((r) => r.id === 'caveat'); + expect(caveat).toMatchObject({ weights: [400, 500, 600, 700], italic: false, caps: false }); + expect(caveat?.descr).toBe('Caveat, a script for words laid over a title or a photo'); + expect(caveat?.tags).toEqual(['script']); + expect(listBookCatalogue('font', { query: 'bebas' }).rows[0]).toMatchObject({ id: 'bebas', caps: true }); + expect(listBookCatalogue('font', { query: 'garamond' }).rows[0]).toMatchObject({ id: 'garamond', italic: true }); + }); + + it('BKASSET-005: lists the lockups with the word roles each takes', () => { + expect(ids(all('lockup').rows)).toEqual(BOOK_LOCKUPS.map((l) => l.id)); + const badge = listBookCatalogue('lockup', { query: 'circle badge' }).rows[0]; + expect(badge).toMatchObject({ id: 'circle-badge', words: ['kicker', 'title', 'year'] }); + expect(ids(listBookCatalogue('lockup', { tags: ['quote'] }).rows)).toEqual(['quote-classic', 'quote-band']); + }); + + it('BKASSET-006: lists the themes with their palette, faces, motifs and decoration', () => { + expect(ids(all('theme').rows)).toEqual([...BOOK_THEME_IDS]); + const tropical = listBookCatalogue('theme', { tags: ['tropical'] }).rows.map((r) => r.id); + expect(tropical).toContain('tropical'); + expect(listBookCatalogue('theme', { query: 'riviera' }).rows[0]).toMatchObject({ + id: 'riviera', + fonts: { display: 'fraunces 600', script: 'vibes 400' }, + palette: { paper: expect.stringMatching(/^#[0-9a-f]{6}$/) }, + }); + }); + + it('BKASSET-007: lists the patterns, and answers how many it found beyond the limit', () => { + expect(ids(all('pattern').rows)).toEqual([...BOOK_PATTERNS]); + expect(listBookCatalogue('pattern', { query: 'check' }).rows[0]?.id).toBe('checker'); + const two = listBookCatalogue('pattern', { limit: 2 }); + expect([two.total, two.rows.length]).toEqual([BOOK_PATTERNS.length, 2]); + expect(listBookCatalogue('pattern', { query: 'unicorn' })).toEqual({ total: 0, rows: [] }); + }); +}); diff --git a/shared/src/book/book-assets.ts b/shared/src/book/book-assets.ts new file mode 100644 index 000000000..7635e7eb9 --- /dev/null +++ b/shared/src/book/book-assets.ts @@ -0,0 +1,196 @@ +import { rankAssets, type AssetKey } from './book-asset-search'; +import { + BOOK_FONT_FACES, + BOOK_FONTS_IDS, + type BookFontCategory, + type BookFontFamily, + type BookFontRole, +} from './book-fonts.data'; +import { BOOK_LOCKUPS } from './book-lockups.data'; +import { BOOK_PATTERNS } from './book-paint.schema'; +import { BOOK_FRAME_SHAPES, BOOK_SHAPE_GROUPS } from './book-shape-catalogue'; +import { bookShapeTakesEdge } from './book-shapes-more'; +import { BOOK_SHAPES } from './book.schema'; +import { BOOK_PATTERN_LINES, BOOK_SHAPE_LINES } from './data/book-asset-lines.data'; +import { BOOK_STICKER_THEMES } from './stickers/sticker-types'; +import type { BookPalette } from './theme/book-theme.schema'; +import { BOOK_THEME_PRESETS, type BookThemeFontPick } from './theme/book-themes.data'; + +import { z } from 'zod'; + +/** + * `list_book_assets`: the Studio's catalogues as the assistant searches them, + * the same ones the Studio's panels show. Stickers are listed behind their own + * entry point (`@trek/shared/stickers`, for the size of the drawings); the + * shapes, faces, lockups, themes and patterns here, each a row of what a model + * needs to pick one it cannot see: its id, one line on what it is, the tags it + * is found by, and what it takes. Templates are `list_book_layouts`'. + */ + +/** What list_book_assets lists. */ +export const BOOK_ASSET_KINDS = ['sticker', 'shape', 'font', 'lockup', 'theme', 'pattern'] as const; +export type BookAssetKind = (typeof BOOK_ASSET_KINDS)[number]; + +/** The most rows one answer holds. */ +const MAX_ASSET_ROWS = 40; + +/** `list_book_assets`: search the Studio's catalogue of things to put on a page. */ +export const listBookAssetsArgs = { + kind: z + .enum(BOOK_ASSET_KINDS) + .describe( + 'What to list: sticker (illustrations), shape (outlines and photo frames), font (typefaces by role), lockup (set titles, badges, postmarks, quotes), theme (palette, faces and motifs) or pattern (the repeats of a paint).', + ), + query: z + .string() + .trim() + .max(80) + .optional() + .describe('Words to search the ids, tags and descriptions by, such as lemon, paris, arch or script.'), + tags: z.array(z.string().trim().min(1).max(40)).max(8).optional().describe('Tags every result must carry.'), + theme: z + .enum(BOOK_STICKER_THEMES) + .optional() + .describe( + 'Stickers only, those of this theme: ephemera, landmarks, tropical, mediterranean, sea, mountains, nordic, asia, food, paper, doodles or ornaments.', + ), + limit: z.number().int().min(1).max(MAX_ASSET_ROWS).optional().describe('At most this many rows, 40 by default.'), +}; +export const listBookAssetsRequestSchema = z.strictObject(listBookAssetsArgs); +export type ListBookAssetsRequest = z.infer; + +interface Row { + id: string; + descr: string; + tags: readonly string[]; +} + +export interface BookShapeAssetRow extends Row { + /** Offered as a photo frame (a photo's mask) in the Studio. */ + frame: boolean; + /** Takes a cut edge: zigzag, scallop, perforated, wavy, torn or deckle. */ + edges: boolean; +} + +export interface BookFontAssetRow extends Row { + weights: readonly number[]; + /** Has a real italic. */ + italic: boolean; + /** Set in capitals whatever is typed. */ + caps: boolean; +} + +export interface BookLockupAssetRow extends Row { + /** The roles add_lockup takes words for, in the order the lockup sets them. */ + words: readonly string[]; +} + +export interface BookThemeAssetRow extends Row { + palette: BookPalette; + /** Each slot's face as a font id with its weight, null for a theme without a script. */ + fonts: { display: string; heading: string; body: string; script: string | null }; + decoration: string; +} + +export type BookCatalogueRow = BookShapeAssetRow | BookFontAssetRow | BookLockupAssetRow | BookThemeAssetRow | Row; + +/** The kind of face each picker group is, for a model reading a font's line. */ +const CATEGORY_WORDS: Readonly> = { + sans: 'a sans serif', + serif: 'a book serif', + elegant: 'an elegant display serif', + condensed: 'a condensed poster face', + retro: 'a heavy retro display face', + script: 'a script', + mono: 'a monospaced face', +}; + +function shapeRows(): BookShapeAssetRow[] { + const groupOf = new Map(BOOK_SHAPE_GROUPS.flatMap((g) => g.shapes.map((s): [string, string] => [s, g.id]))); + const frames = new Set(BOOK_FRAME_SHAPES); + return BOOK_SHAPES.map((id) => { + const frame = frames.has(id); + const edges = bookShapeTakesEdge(id); + const tags = [groupOf.get(id) ?? 'shape', ...(frame ? ['frame'] : []), ...(edges ? ['edge'] : [])]; + return { id, descr: BOOK_SHAPE_LINES[id], tags, frame, edges }; + }); +} + +/** What a face set in each role sets, for its line. */ +const ROLE_WORDS: Readonly> = { + display: 'cover and chapter titles', + heading: 'headings', + body: 'running text', + script: 'words laid over a title or a photo', + mono: 'figures and coordinates', +}; + +function fontRows(): BookFontAssetRow[] { + return BOOK_FONTS_IDS.map((id: BookFontFamily) => { + const face = BOOK_FONT_FACES[id]; + const caps = 'caps' in face && face.caps === true; + return { + id, + descr: `${face.name}, ${CATEGORY_WORDS[face.category]} for ${face.roles.map((r) => ROLE_WORDS[r]).join(', ')}`, + tags: [...new Set([face.category, ...face.roles])], + weights: face.weights, + italic: face.italics.length > 0, + caps, + }; + }); +} + +function lockupRows(): BookLockupAssetRow[] { + return BOOK_LOCKUPS.map((l) => { + const words = [...new Set(l.parts.flatMap((p) => (p.kind === 'text' && !p.literal ? [p.role] : [])))]; + return { id: l.id, descr: l.descr, tags: words, words }; + }); +} + +const faceOf = (f: BookThemeFontPick) => `${f.font}${f.weight ? ` ${f.weight}` : ''}`; + +function themeRows(): BookThemeAssetRow[] { + return BOOK_THEME_PRESETS.map((t) => ({ + id: t.id, + descr: t.descr, + tags: [...t.motifs, t.decoration], + palette: t.palette, + fonts: { + display: faceOf(t.fonts.display), + heading: faceOf(t.fonts.heading), + body: faceOf(t.fonts.body), + script: t.fonts.script ? faceOf(t.fonts.script) : null, + }, + decoration: t.decoration, + })); +} + +function patternRows(): Row[] { + return BOOK_PATTERNS.map((id) => ({ id, descr: BOOK_PATTERN_LINES[id], tags: ['pattern'] })); +} + +const ROWS: Readonly, () => BookCatalogueRow[]>> = { + shape: shapeRows, + font: fontRows, + lockup: lockupRows, + theme: themeRows, + pattern: patternRows, +}; + +const keyOf = (row: Row): AssetKey => ({ + id: row.id, + words: [row.id, row.id.replace(/-/g, ' '), ...row.tags, row.descr], + tags: row.tags, +}); + +/** + * `list_book_assets` for every kind but stickers: the rows matching the + * query and tags, best first, at most `limit` (40), with the total found. + */ +export function listBookCatalogue( + kind: Exclude, + search: { query?: string; tags?: readonly string[]; limit?: number }, +): { total: number; rows: BookCatalogueRow[] } { + const found = rankAssets(ROWS[kind](), keyOf, search.query, search.tags); + return { total: found.length, rows: found.slice(0, search.limit ?? MAX_ASSET_ROWS) }; +} diff --git a/shared/src/book/book-findings-art.spec.ts b/shared/src/book/book-findings-art.spec.ts new file mode 100644 index 000000000..6c769f499 --- /dev/null +++ b/shared/src/book/book-findings-art.spec.ts @@ -0,0 +1,263 @@ +import { artFindings, briefFindings, pageArtFindings, themeFindings } from './book-findings-art'; +import { designFindings } from './book-findings-design'; +import { BOOK_FINDING_CODES, type BookFinding } from './book-ops.schema'; +import { blankBook, bookPageSetupForPreset } from './book-pages'; +import { + bookLineElementSchema, + bookPhotoElementSchema, + bookShapeElementSchema, + bookStickerElementSchema, + bookTextElementSchema, + type BookDocument, + type BookElement, + type BookSpread, +} from './book.schema'; +import { describeSpread } from './describe-spread'; +import { bookThemeOf } from './theme/book-theme'; + +import { describe, expect, it } from 'vitest'; + +/** + * The art checks (book-findings-art.ts) on small fixture pages of the default + * square book: a flat cover, a page without a lead, colours and faces + * outside the theme, and a designed book judged against its brief. + */ + +type Frame = { x: number; y: number; w: number; h: number }; + +const text = (id: string, words: string, size: number, over = {}) => + bookTextElementSchema.parse({ + id, + kind: 'text', + frame: { x: 20, y: 20, w: 170, h: 40 }, + text: words, + size, + ...over, + }) as BookElement; +const photo = (id: string, frame: Frame, over = {}) => + bookPhotoElementSchema.parse({ id, kind: 'photo', frame, photoId: 7, ...over }) as BookElement; +const shape = (id: string, frame: Frame, over = {}) => + bookShapeElementSchema.parse({ id, kind: 'shape', frame, ...over }) as BookElement; +const line = (id: string, over = {}) => + bookLineElementSchema.parse({ id, kind: 'line', frame: { x: 20, y: 70, w: 120, h: 4 }, ...over }) as BookElement; +const sticker = (id: string, over = {}) => + bookStickerElementSchema.parse({ + id, + kind: 'sticker', + frame: { x: 30, y: 30, w: 30, h: 30 }, + stickerId: 'lemon', + ...over, + }) as BookElement; + +const spread = (elements: BookElement[], over: Partial = {}): BookSpread => ({ + id: 'sp-1', + role: 'cover', + background: null, + elements, + parked: [], + entryId: null, + ...over, +}); + +const page = blankBook({ title: 'T' }).page; +const codes = (list: readonly BookFinding[]) => list.map((f) => `${f.severity}:${f.code}`); +const art = (elements: BookElement[], over: Partial = {}) => + codes(artFindings(spread(elements, over), page)); + +describe('a flat cover', () => { + it('BKART-001: is a cover with a small title and nothing else to carry it', () => { + expect(art([text('t', 'Atlantic coast', 28)])).toEqual(['warn:flat_cover']); + expect(art([photo('p', { x: 40, y: 40, w: 120, h: 80 }), text('t', 'Atlantic coast', 28)])).toContain( + 'warn:flat_cover', + ); + // A scrim fading over a photo and a hairline rule carry nothing. + const scrim = shape('s', { x: 0, y: 120, w: 210, h: 90 }, { gradient: 'up', fill: '#111111', opacity: 0.6 }); + expect(art([scrim, text('t', 'Coast', 28), shape('r', { x: 20, y: 70, w: 30, h: 0.5 })])).toEqual([ + 'warn:flat_cover', + ]); + // A plain hairline is a rule whatever its kind, and a near white paint is the paper. + expect(art([text('t', 'Coast', 28), line('l', { strokeWidth: 0.4 })])).toEqual(['warn:flat_cover']); + expect(art([text('t', 'Coast', 28)], { backgroundPaint: { type: 'solid', color: '#fafafa' } })).toEqual([ + 'warn:flat_cover', + ]); + }); + + it('BKART-002: is not a cover with big type, a picture over most of it, a colour block, a drawing or a coloured page', () => { + expect(art([text('t', 'Coast', 48)])).toEqual([]); + expect(art([photo('p', { x: -3, y: -3, w: 216, h: 216 }), text('t', 'Coast', 28)])).toEqual([]); + const grid = [0, 1, 2, 3].map((i) => + photo(`p${i}`, { x: (i % 2) * 106, y: Math.floor(i / 2) * 106, w: 104, h: 104 }), + ); + expect(art([...grid, text('t', 'Coast', 28)])).toEqual([]); + expect(art([shape('b', { x: 0, y: 0, w: 210, h: 90 }, { fill: '#ef4e45' }), text('t', 'Coast', 28)])).toEqual([]); + expect(art([sticker('k'), text('t', 'Coast', 28)])).toEqual([]); + expect(art([text('t', 'Coast', 28)], { background: '#1d3557' })).toEqual([]); + expect(art([text('t', 'Coast', 28)], { backgroundPaint: { type: 'solid', color: '#1d3557' } })).toEqual([]); + const checker = { type: 'pattern', pattern: 'checker', fg: '#ef4e45', bg: '#ffffff', scale: 1, angle: 0 } as const; + expect(art([text('t', 'Coast', 28)], { backgroundPaint: checker })).toEqual([]); + // A rule with an ornament or a bar's weight is a drawing; a gradient in a colour is a colour block. + expect(art([text('t', 'Coast', 28), line('l', { ornament: 'sun' })])).toEqual([]); + expect(art([text('t', 'Coast', 28), line('l', { strokeWidth: 3 })])).toEqual([]); + const fade = shape('g', { x: 0, y: 0, w: 210, h: 90 }, { gradient: 'down', fill: '#ef4e45' }); + expect(art([fade, text('t', 'Coast', 28)])).toEqual([]); + }); + + it('BKART-012: weighs its sizes against a page 210 mm wide, larger on a larger page', () => { + const large = bookPageSetupForPreset('square-300'); + expect(codes(artFindings(spread([text('t', 'Coast', 60)]), large))).toEqual(['warn:flat_cover']); + expect(art([text('t', 'Coast', 60)])).toEqual([]); + const inner = { role: 'inner' as const }; + expect(codes(artFindings(spread([text('a', 'Stops', 24), text('b', 'Day one', 14)], inner), large))).toEqual([]); + expect(art([text('a', 'Stops', 24), text('b', 'Day one', 14)], inner)).toEqual(['info:weak_hierarchy']); + }); + + it('BKART-003: is a cover only, never an empty one, and is said with the design findings', () => { + expect(art([])).toEqual([]); + expect(art([text('t', 'Coast', 28)], { role: 'inner' })).toEqual([]); + expect(art([text('t', 'Coast', 28)], { role: 'back' })).toEqual([]); + const said = designFindings(spread([text('t', 'Atlantic coast', 28)]), page); + expect(said.find((f) => f.code === 'flat_cover')?.elementIds).toEqual(['t']); + }); +}); + +describe('a weak hierarchy', () => { + const inner = { role: 'inner' as const }; + + it('BKART-004: is a page whose largest words are under twice the next', () => { + expect(art([text('a', 'Porto', 30), text('b', 'Day three', 20)], inner)).toEqual(['info:weak_hierarchy']); + expect(art([text('a', 'Porto', 30), text('b', 'Day three', 15)], inner)).toEqual([]); + expect( + artFindings(spread([text('a', 'Porto', 30), text('b', 'Day three', 20)], inner), page)[0]?.elementIds, + ).toEqual(['a', 'b']); + }); + + it('BKART-005: weighs no script, no echo of the same words, no hidden text and no page without a title', () => { + const script = text('s', 'travel', 40, { font: 'caveat' }); + expect(art([script, text('a', 'Porto', 30), text('b', 'May', 9)], inner)).toEqual([]); + expect(art([text('a', 'Porto', 30), text('a2', 'Porto', 30), text('b', 'May', 9)], inner)).toEqual([]); + expect(art([text('a', 'Porto', 30), text('b', 'Day three', 20, { hidden: true })], inner)).toEqual([]); + expect(art([text('a', 'Porto', 16), text('b', 'Day three', 14)], inner)).toEqual([]); + }); +}); + +describe('colours and faces outside the theme', () => { + const theme = bookThemeOf('riviera'); + const doc = (elements: BookElement[]): BookDocument => { + const book = blankBook({ title: 'T' }); + const spreads = book.spreads.map((s) => (s.role === 'inner' ? { ...s, elements } : s)); + return { ...book, spreads, theme }; + }; + const off = (elements: BookElement[]) => themeFindings(spread(elements, { role: 'inner' }), theme); + + it('BKART-006: are said once a page, naming the elements and what strays', () => { + const found = off([text('a', 'Porto', 12, { color: '#ff00ff' }), text('b', 'May', 9, { font: 'bebas' })]); + expect(found).toHaveLength(1); + expect(found[0]).toMatchObject({ code: 'off_theme', severity: 'info', elementIds: ['a', 'b'] }); + expect(found[0]?.message).toContain('#ff00ff'); + expect(found[0]?.message).toContain('bebas'); + }); + + it('BKART-007: leave alone what follows the theme, neutral greys, mono lines, photos, stickers and a book without one', () => { + const accent = theme.palette.accent; + expect(off([text('a', 'Porto', 12, { color: '#ff00ff', token: 'accent', fontRole: 'body' })])).toEqual([]); + expect(off([text('a', 'Porto', 12, { color: accent, font: theme.fonts.heading.font })])).toEqual([]); + expect(off([text('a', 'Porto', 12, { color: '#111111', font: 'bebas', fontRole: 'heading' })])).toEqual([]); + expect( + off([text('a', '41.15 N', 9, { font: 'mono' }), photo('p', { x: 0, y: 0, w: 50, h: 50 }), sticker('k')]), + ).toEqual([]); + expect(themeFindings(spread([text('a', 'Porto', 12, { color: '#ff00ff' })]), undefined)).toEqual([]); + }); + + it('BKART-008: reach the model with the page it describes', () => { + const book = doc([text('a', 'Porto', 12, { color: '#ff00ff' })]); + const inner = book.spreads.find((s) => s.role === 'inner') as BookSpread; + const described = describeSpread(book, inner.id); + expect(described?.findings.map((f) => f.code)).toContain('off_theme'); + expect(described?.text).toContain('off_theme'); + }); +}); + +describe('a book against its brief', () => { + const book = (stickers: number, turn: number): BookDocument => { + const base = blankBook({ title: 'T' }); + const elements = [ + photo('p', { x: 20, y: 20, w: 120, h: 90 }, { rotation: turn }), + ...Array.from({ length: stickers }, (_, i) => sticker(`k${i}`)), + ]; + return { ...base, spreads: base.spreads.map((s) => (s.role === 'inner' ? { ...s, elements } : s)) }; + }; + + it('BKART-009: says the photo pages whose stickers fall outside the decoration asked for', () => { + const light = { energy: 'lively', decoration: 'light', bare: false } as const; + expect(briefFindings(book(1, 4), light)).toEqual([]); + const none = briefFindings(book(0, 4), light); + expect(none.map((f) => f.code)).toEqual(['energy_mismatch']); + expect(none[0]?.message).toContain('1 to 2 per spread'); + expect(codes(briefFindings(book(7, 4), { energy: 'loud', decoration: 'rich', bare: false }))).toEqual([ + 'info:energy_mismatch', + ]); + expect(briefFindings(book(1, 0), { energy: 'calm', decoration: 'none', bare: true })).toHaveLength(1); + }); + + it('BKART-010: says photos turned past what the energy takes, or none turned in a lively book', () => { + const calm = briefFindings(book(0, 8), { energy: 'calm', decoration: 'none', bare: false }); + expect(calm).toHaveLength(1); + expect(calm[0]?.elementIds).toEqual(['p']); + expect(briefFindings(book(0, 1), { energy: 'calm', decoration: 'none', bare: false })).toEqual([]); + const flat = briefFindings(book(1, 0), { energy: 'lively', decoration: 'light', bare: false }); + expect(flat.map((f) => f.message)).toEqual([expect.stringContaining('the book reads calm')]); + }); + + /** A book of inner photo pages, each with this many stickers and its photo turned so far. */ + const pages = (stickers: readonly number[], turn = 4): BookDocument => { + const base = blankBook({ title: 'T' }); + const inner = stickers.map((n, i) => ({ + id: `sp-${i}`, + role: 'inner' as const, + background: null, + parked: [], + entryId: null, + elements: [ + photo(`p${i}`, { x: 20, y: 20, w: 120, h: 90 }, { rotation: turn }), + ...Array.from({ length: n }, (_, k) => sticker(`k${i}-${k}`)), + ], + })); + return { ...base, spreads: [base.spreads[0] as BookSpread, ...inner] }; + }; + const light = { energy: 'lively', decoration: 'light', bare: false } as const; + + it('BKART-013: judges only the pages the design made, a third of them under the decoration before it says so', () => { + expect(briefFindings(pages([0, 1, 1, 2]), light)).toEqual([]); + expect(codes(briefFindings(pages([0, 0, 1, 2]), light))).toEqual(['info:energy_mismatch']); + // A page kept as it was is the person's: neither its stickers nor its turns are judged. + expect(codes(briefFindings(pages([5, 1]), light))).toEqual(['info:energy_mismatch']); + expect(briefFindings(pages([5, 1]), light, { kept: ['sp-0'] })).toEqual([]); + expect(codes(briefFindings(pages([1, 1], 12), light))).toEqual(['info:energy_mismatch']); + expect(briefFindings(pages([1, 1], 12), light, { kept: ['sp-0', 'sp-1'], lively: true })).toEqual([]); + }); + + it('BKART-014: judges a page alone when named, and a book with lively pages as lively whatever its turns', () => { + // One page named: too many stickers is said, too few and the book reading calm are not. + expect(briefFindings(pages([0], 0), light, { only: ['sp-0'] })).toEqual([]); + expect(codes(briefFindings(pages([4], 0), light, { only: ['sp-0'] }))).toEqual(['info:energy_mismatch']); + expect(briefFindings(pages([1], 0), light, { lively: true })).toEqual([]); + expect(briefFindings(pages([1], 0), light)[0]?.message).toContain('no page is lively'); + // A calm book turns as far as its theme does. + const calm = { energy: 'calm', decoration: 'none', bare: false } as const; + expect(briefFindings(pages([0], 4), { ...calm, tilt: [0, 4] })).toEqual([]); + expect(codes(briefFindings(pages([0], 4), calm))).toEqual(['info:energy_mismatch']); + }); + + it('BKART-015: a changed page answers the art checks and what strays from the theme together', () => { + const theme = bookThemeOf('riviera'); + const cover = spread([text('t', 'Atlantic coast', 28, { color: '#ff00ff' })]); + expect(codes(pageArtFindings(cover, page, theme))).toEqual(['warn:flat_cover', 'info:off_theme']); + expect(codes(pageArtFindings(cover, page, undefined))).toEqual(['warn:flat_cover']); + }); + + it('BKART-011: every art finding is a code the book tools answer with', () => { + for (const code of ['flat_cover', 'weak_hierarchy', 'off_theme', 'energy_mismatch', 'raster_effect']) { + expect(BOOK_FINDING_CODES).toContain(code); + } + }); +}); diff --git a/shared/src/book/book-findings-art.ts b/shared/src/book/book-findings-art.ts new file mode 100644 index 000000000..49372fdcb --- /dev/null +++ b/shared/src/book/book-findings-art.ts @@ -0,0 +1,294 @@ +import { deltaE, luminance, parseHex, toHsl } from './book-colour'; +import { bookFontCountsInMix, bookFontRole } from './book-fonts'; +import { visibleSpread } from './book-groups'; +import type { BookFinding } from './book-ops.schema'; +import type { + BookDocument, + BookElement, + BookFrame, + BookPageSetup, + BookShapeElement, + BookSpread, + BookTextElement, +} from './book.schema'; +import { STICKERS_PER_SPREAD, tiltRange, type DesignArt } from './design/design-art'; +import { bookThemeColor } from './theme/book-theme'; +import { BOOK_COLOR_TOKENS, type BookTheme } from './theme/book-theme.schema'; + +/** + * Whether a page reads as designed, not only as laid out without faults: the + * checks an art director makes after the print checks pass. + * + * - `flat_cover` (warn): a cover with no title of 48 pt or more and nothing + * else to carry it: no photo over most of it, no colour block, no coloured + * page, no drawing, sticker or ornament. A photo over half the cover carries + * it as a colour block does. The sizes are for a page 210 mm wide and scale + * with the page width, as the type scale does. + * - `weak_hierarchy` (info): the largest words on a page are under twice the + * next, so nothing leads. Script and mono lines dress a page and are not + * weighed, nor are repeats of the same words (an echoed title). + * - `off_theme` (info): colours or faces on a page that are none of the + * theme's and follow no link to it. + * - `energy_mismatch` (info): a designed book whose stickers per spread or + * whose turned photos fall outside what its brief asked for; only a design + * made with a brief is judged by one, and only on the pages it made (a page + * it kept as it was is the person's). + */ + +/** A title this large carries a cover on its own, on a page 210 mm wide; the sizes here scale with the page width. */ +const COVER_TITLE_PT = 48; +const REFERENCE_WIDTH = 210; +/** Photos over this share of the cover make a picture cover. */ +const PICTURE_SHARE = 0.5; +/** Words this large are a page's title, the one the hierarchy is judged by. */ +const TITLE_PT = 20; +/** Two colours this far apart (delta E) are different colours. */ +const OFF_PALETTE = 14; + +const n1 = (v: number) => `${Math.round(v * 10) / 10}`; + +/** How the sizes of the checks scale on this page: its width over the 210 mm they are set for. */ +const scaleOf = (page: BookPageSetup) => page.pageWidth / REFERENCE_WIDTH; + +/** A frame in millimetres, as the book types it, so a zod-inferred `el.frame` passes in every compile mode. */ +type Box = BookFrame; + +function shared(a: Box, b: Box): number { + const w = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x); + const h = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y); + return w > 0 && h > 0 ? w * h : 0; +} + +/** A colour that belongs to any palette: a grey, near black or near white. */ +function neutral(hex: string): boolean { + const rgb = parseHex(hex); + if (!rgb) return true; + const hsl = toHsl(rgb); + return hsl.s < 0.12 || hsl.l < 0.08 || hsl.l > 0.95; +} + +/** A colour darker or stronger than a light paper. */ +const deep = (hex: string | null | undefined) => { + const rgb = hex ? parseHex(hex) : null; + return !!rgb && luminance(rgb) < 0.75; +}; + +/** A page painted a colour of its own: anything but a light paper, a pattern or a gradient included. */ +function colouredPage(spread: BookSpread): boolean { + const paint = spread.backgroundPaint; + if (paint) return paint.type === 'solid' ? deep(paint.color) : true; + return deep(spread.background); +} + +/** Thinner than this (mm) a rule or an outline is a hairline, which carries no cover. */ +const HAIRLINE = 1.5; + +/** A gradient with no paint of its own in a grey: the scrim a title sits on over a photo. */ +const scrim = (el: BookShapeElement) => el.gradient !== 'none' && !el.paint && (!el.fill || neutral(el.fill)); + +/** + * A drawing, a sticker, an ornament, or a shape that blocks colour: any shape + * but a scrim fading over a photo or a hairline rule, any line but a plain + * hairline. + */ +function decorates(el: BookElement): boolean { + if (el.kind === 'sticker' || el.kind === 'icon') return true; + if (el.kind === 'line') return el.ornament !== 'none' || el.squiggle !== 'none' || el.strokeWidth >= HAIRLINE; + if (el.kind !== 'shape' || el.opacity < 0.5 || scrim(el)) return false; + return Math.min(el.frame.w, el.frame.h) >= HAIRLINE && (el.fill !== null || el.stroke !== null); +} + +/** + * Whether a cover holds something that carries it: big type, photos over most + * of it (one to the bleed, or a grid of them), a colour block or a drawing. + */ +function carried(spread: BookSpread, page: BookPageSetup): boolean { + const area = page.pageWidth * page.pageHeight; + const cover: Box = { x: 0, y: 0, w: page.pageWidth, h: page.pageHeight }; + const pictured = spread.elements.reduce( + (sum, el) => sum + (el.kind === 'photo' && el.photoId != null ? shared(el.frame, cover) : 0), + 0, + ); + if (pictured >= area * PICTURE_SHARE) return true; + const big = COVER_TITLE_PT * scaleOf(page); + return spread.elements.some((el) => (el.kind === 'text' ? !!el.text.trim() && el.size >= big : decorates(el))); +} + +function flatCover(spread: BookSpread, page: BookPageSetup): BookFinding | null { + if (spread.role !== 'cover' || !spread.elements.length) return null; + if (colouredPage(spread) || carried(spread, page)) return null; + return { + code: 'flat_cover', + severity: 'warn', + elementIds: spread.elements.slice(0, 8).map((e) => e.id), + message: `the cover has no title of ${n1(COVER_TITLE_PT * scaleOf(page))} pt or more and no photo, colour block or decoration to carry it; set the title larger, give the cover a colour block or a large photo, or design it on an archetype`, + }; +} + +/** The texts a page's hierarchy is read from: words, in a face that sets them (no script, no mono). */ +const weighed = (el: BookElement): el is BookTextElement => + el.kind === 'text' && !!el.text.trim() && bookFontCountsInMix(el.font); + +function weakHierarchy(spread: BookSpread, page: BookPageSetup): BookFinding | null { + const texts = spread.elements.filter(weighed).sort((a, b) => b.size - a.size); + const [top] = texts; + if (!top || top.size < TITLE_PT * scaleOf(page)) return null; + const words = top.text.trim(); + const next = texts.find((t) => t !== top && t.text.trim() !== words); + if (!next || top.size >= next.size * 2) return null; + return { + code: 'weak_hierarchy', + severity: 'info', + elementIds: [top.id, next.id], + message: `the largest text ${top.id} at ${n1(top.size)} pt is under twice text ${next.id} at ${n1(next.size)} pt; set one larger or the other smaller so the page has one lead`, + }; +} + +/** The art findings of a page that need no theme: a flat cover and a weak hierarchy. */ +export function artFindings(spread: BookSpread, page: BookPageSetup): BookFinding[] { + const visible = visibleSpread(spread); + return [flatCover(visible, page), weakHierarchy(visible, page)].filter((f): f is BookFinding => f !== null); +} + +/** The colours and faces of an element that are none of the theme's and follow no link to it. */ +function strays(el: BookElement, colours: readonly string[], faces: ReadonlySet): string[] { + const out: string[] = []; + const colour = (hex: string | null | undefined, linked: boolean) => { + if (!hex || linked || neutral(hex) || colours.some((t) => deltaE(hex, t) <= OFF_PALETTE)) return; + out.push(hex.toLowerCase()); + }; + const face = (font: string, linked: boolean) => { + if (!linked && !faces.has(font) && bookFontRole(font) !== 'mono') out.push(font); + }; + switch (el.kind) { + case 'photo': + case 'sticker': + return out; + case 'line': + colour(el.stroke, false); + return out; + case 'shape': + if (el.gradient === 'none' && (!el.paint || el.paint.type === 'solid')) colour(el.fill, !!el.token); + return out; + case 'icon': + colour(el.color, !!el.token); + return out; + case 'text': + if (el.text.trim()) { + colour(el.color, !!el.token); + face(el.font, !!el.fontRole); + } + return out; + default: + colour(el.color, !!el.token); + colour(el.accent, !!el.accentToken); + face(el.font, !!el.fontRole); + return out; + } +} + +/** Colours or faces on a page outside the book's theme, as one finding; none for a book without a theme. */ +export function themeFindings(spread: BookSpread, theme: BookTheme | undefined): BookFinding[] { + if (!theme) return []; + const colours = BOOK_COLOR_TOKENS.map((t) => bookThemeColor(theme, t)); + const faces = new Set(Object.values(theme.fonts).flatMap((f) => (f ? [f.font] : []))); + const found = visibleSpread(spread).elements.flatMap((el) => { + const what = strays(el, colours, faces); + return what.length ? [{ id: el.id, what }] : []; + }); + if (!found.length) return []; + const named = [...new Set(found.flatMap((f) => f.what))].slice(0, 6); + return [ + { + code: 'off_theme', + severity: 'info', + elementIds: found.slice(0, 8).map((f) => f.id), + message: `${found.length} elements are set outside the book's theme (${named.join(', ')}); link them to it with token and fontRole, or set them in its colours and faces`, + }, + ]; +} + +/** How far an element is turned from straight, 0 to 180 degrees. */ +function turnOf(el: BookElement): number { + const rest = ((el.rotation % 360) + 360) % 360; + return Math.min(rest, 360 - rest); +} + +/** What a book is judged against: its brief's energy and decoration, and the turns that energy takes. */ +export type BookBriefArt = Pick & Partial>; + +/** Which pages of a book its brief judges, and what the design made of them. */ +interface BriefScope { + /** The pages the design kept as they were: the person's, not judged. */ + kept?: readonly string[]; + /** Whether the design made lively pages (collages, overlays, badges, stamp rows, two-tone splits). */ + lively?: boolean; + /** Only these pages, as a revised page is judged; what the book as a whole reads like is not said then. */ + only?: readonly string[]; +} + +/** A photo page's stickers out of the decoration's range: more than its most, or under its least on many pages. */ +function decorationFinding(pages: readonly BookSpread[], art: BookBriefArt, whole: boolean): BookFinding | null { + const [least, most] = art.bare ? [0, 0] : STICKERS_PER_SPREAD[art.decoration]; + const counted = pages.map((s) => ({ + id: s.id, + n: s.elements.filter((e) => e.kind === 'sticker' && !e.hidden).length, + })); + const over = counted.filter((p) => p.n > most); + const under = counted.filter((p) => p.n < least); + // A page with no free spot for a sticker takes fewer: the book is under-decorated when a third of its pages are. + const off = [...over, ...(whole && under.length * 3 > counted.length ? under : [])]; + if (!off.length) return null; + return { + code: 'energy_mismatch', + severity: 'info', + elementIds: [], + message: `${off.length} of ${pages.length} photo pages carry stickers outside the ${least} to ${most} per spread the brief's ${art.bare ? 'none' : art.decoration} decoration asks for (${off + .slice(0, 6) + .map((p) => `${p.id}: ${p.n}`) + .join(', ')})`, + }; +} + +/** + * Whether the stickers per spread and the turned prints of a designed book + * are what its brief asked for, on the pages `scope` judges: every page the + * design made, or the ones named. A lively book reads calm only when it has + * no lively page and nothing turned as far as its energy asks. + */ +export function briefFindings(doc: BookDocument, art: BookBriefArt, scope: BriefScope = {}): BookFinding[] { + const kept = new Set(scope.kept); + const only = scope.only ? new Set(scope.only) : null; + const judged = doc.spreads.filter((s) => !kept.has(s.id) && (!only || only.has(s.id))); + const out: BookFinding[] = []; + const pages = judged.filter((s) => s.role === 'inner' && s.elements.some((e) => e.kind === 'photo' && !e.hidden)); + const decoration = decorationFinding(pages, art, !only); + if (decoration) out.push(decoration); + const [lo, hi] = art.tilt ?? tiltRange(art.energy); + const turned = judged.flatMap((s) => + s.elements.filter((e) => (e.kind === 'photo' || e.kind === 'sticker') && !e.hidden && turnOf(e) > 0.5), + ); + const steepest = Math.max(0, ...turned.map(turnOf)); + const past = turned.filter((e) => turnOf(e) > hi + 0.5); + if (past.length) { + out.push({ + code: 'energy_mismatch', + severity: 'info', + elementIds: past.slice(0, 8).map((e) => e.id), + message: `${past.length} photos or stickers are turned past the ${n1(hi)} degrees a ${art.energy} brief takes, up to ${n1(steepest)}`, + }); + } else if (!only && art.energy !== 'calm' && steepest < lo && !scope.lively) { + out.push({ + code: 'energy_mismatch', + severity: 'info', + elementIds: [], + message: `no page is lively and no photo or sticker is turned the ${lo} degrees or more a ${art.energy} brief asks for; the book reads calm`, + }); + } + return out; +} + +/** The art findings a change of a page answers with: a flat cover, a weak hierarchy, colours or faces off the theme. */ +export function pageArtFindings(spread: BookSpread, page: BookPageSetup, theme: BookTheme | undefined): BookFinding[] { + return [...artFindings(spread, page), ...themeFindings(spread, theme)]; +} diff --git a/shared/src/book/book-findings-design.ts b/shared/src/book/book-findings-design.ts index abc8e75e1..65a01f7b9 100644 --- a/shared/src/book/book-findings-design.ts +++ b/shared/src/book/book-findings-design.ts @@ -1,5 +1,6 @@ import { compareCodeUnits } from '../common/compare'; import { contrastOfLuminance, deltaE, greyLuminance, luminance, parseHex, requiredContrast } from './book-colour'; +import { artFindings } from './book-findings-art'; import { bookFontCountsInMix, bookFontsUsed } from './book-fonts'; import { visibleSpread } from './book-groups'; import type { BookPhotoInfo } from './book-ops-core'; @@ -315,6 +316,8 @@ export function designFindings( smallText(ctx); crowded(ctx); offGrid(ctx); + // Whether the page reads as designed: a flat cover, a page without a lead (book-findings-art.ts). + for (const f of artFindings(spread, page)) add(ctx, f); return sortBookFindings(ctx.out); } diff --git a/shared/src/book/book-ops.schema.ts b/shared/src/book/book-ops.schema.ts index 4f0d003d4..dca1954ba 100644 --- a/shared/src/book/book-ops.schema.ts +++ b/shared/src/book/book-ops.schema.ts @@ -549,6 +549,10 @@ export const BOOK_FINDING_CODES = [ 'edge_unused', 'curve_overflow', 'effect_heavy', + 'flat_cover', + 'weak_hierarchy', + 'off_theme', + 'energy_mismatch', ] as const; export type BookFindingCode = (typeof BOOK_FINDING_CODES)[number]; diff --git a/shared/src/book/book-shape-catalogue.ts b/shared/src/book/book-shape-catalogue.ts new file mode 100644 index 000000000..096cbdc6c --- /dev/null +++ b/shared/src/book/book-shape-catalogue.ts @@ -0,0 +1,226 @@ +import type { BookShapeId } from './book.schema'; + +/** + * How the Studio's shape shelf is divided and which shapes it offers as photo + * frames: one list for the Shapes panel, the photo inspector and the + * assistant's `list_book_assets`, so the three agree on what a frame is. + * + * Grouped the way you go looking for them ("I want an arrow", "I want + * something round and soft") rather than by how they are built. Sixty + * unlabelled tiles in one grid is a wall; six groups of ten is a menu. + */ + +export interface BookShapeGroup { + /** The group's i18n key under `journey.studio.shapeGroup`, and its tag in the asset listing. */ + id: string; + shapes: BookShapeId[]; +} + +export const BOOK_SHAPE_GROUPS: BookShapeGroup[] = [ + { + id: 'basic', + shapes: [ + 'rect', + 'squircle', + 'ellipse', + 'capsule', + 'triangle', + 'triangle-down', + 'diamond', + 'parallelogram', + 'trapezoid', + 'arch', + 'half-circle', + 'quarter-circle', + ], + }, + { id: 'polygons', shapes: ['pentagon', 'hexagon', 'hexagon-flat', 'heptagon', 'octagon'] }, + { + id: 'stars', + shapes: [ + 'star-4', + 'star-5', + 'star-6', + 'star-8', + 'star-12', + 'burst', + 'seal', + 'sparkle', + 'star-rounded', + 'star-twinkle', + ], + }, + { + id: 'arrows', + shapes: [ + 'arrow-right', + 'arrow-left', + 'arrow-up', + 'arrow-down', + 'arrow-both', + 'chevron-right', + 'chevron-left', + 'arrow-bent', + 'arrow-curve', + 'arrow-circle', + 'arrow-notched', + 'arrow-thin', + ], + }, + { + id: 'speech', + shapes: [ + 'bubble-round', + 'bubble-square', + 'bubble-oval', + 'bubble-think', + 'bubble-shout', + 'bubble-side', + 'bubble-capsule', + ], + }, + { + id: 'travel', + shapes: [ + 'pin', + 'compass', + 'mountain', + 'sun', + 'moon', + 'cloud', + 'cloud-puffy', + 'drop', + 'wave', + 'leaf', + 'shield', + 'ticket', + ], + }, + { + id: 'decor', + shapes: [ + 'heart', + 'flower-5', + 'flower-6', + 'cross', + 'plus', + 'gear', + 'blob-1', + 'blob-2', + 'blob-3', + 'blob-4', + 'heart-round', + 'heart-pair', + 'flower-8', + ], + }, + { + id: 'banners', + shapes: [ + 'banner-ribbon', + 'banner-pennant', + 'banner-bookmark', + 'banner-flag', + 'ribbon-ends', + 'ribbon-arc', + 'wavy-band', + 'torn-strip', + ], + }, + // The second shelf (book-shapes-more.ts). + { id: 'arches', shapes: ['arch-tall', 'arch-pointed', 'arch-double', 'arch-half', 'arch-rainbow'] }, + { + id: 'cutouts', + shapes: [ + 'leaf-palm', + 'leaf-oak', + 'leaf-long', + 'leaf-monstera', + 'coral-1', + 'coral-2', + 'seaweed', + 'star-organic', + 'sun-cutout', + 'organic-1', + 'organic-2', + 'organic-3', + ], + }, + { + id: 'stamps', + shapes: [ + 'stamp-rect', + 'stamp-circle', + 'postmark', + 'rosette', + 'scallop-circle', + 'badge-shield', + 'label-tab', + 'ticket-stub', + 'sunburst-12', + 'sunburst-24', + ], + }, + { id: 'frames', shapes: ['frame-notched', 'frame-chamfer', 'frame-cushion'] }, + { id: 'ornaments', shapes: ['flourish-scroll', 'laurel', 'fleuron', 'corner-flourish'] }, +]; + +/** + * The shapes offered as picture frames. + * + * A subset, and the subset is the point: a photograph cut to a speech bubble's + * tail or to a hairline is a photograph you cannot read. What is here holds an + * image at book size: broad, mostly convex, no thin limbs. + */ +export const BOOK_FRAME_SHAPES: BookShapeId[] = [ + 'rect', + 'squircle', + 'ellipse', + 'capsule', + 'arch', + 'half-circle', + 'triangle', + 'diamond', + 'pentagon', + 'hexagon', + 'octagon', + 'heart', + 'star-5', + 'star-6', + 'seal', + 'flower-5', + 'flower-6', + 'blob-1', + 'blob-2', + 'blob-3', + 'blob-4', + 'shield', + 'drop', + 'leaf', + 'pin', + 'banner-pennant', + 'banner-bookmark', + // The second shelf: arches, stamps and the frames cut for photographs. + 'arch-tall', + 'arch-pointed', + 'arch-double', + 'arch-half', + 'stamp-rect', + 'stamp-circle', + 'scallop-circle', + 'badge-shield', + 'ticket-stub', + 'heart-round', + 'star-rounded', + 'flower-8', + 'frame-notched', + 'frame-chamfer', + 'frame-cushion', + 'organic-1', + 'organic-2', + 'organic-3', + 'star-organic', + 'sun-cutout', + 'bubble-side', + 'bubble-capsule', +]; diff --git a/shared/src/book/book-sticker.spec.ts b/shared/src/book/book-sticker.spec.ts index f7e8db839..7a45b602b 100644 --- a/shared/src/book/book-sticker.spec.ts +++ b/shared/src/book/book-sticker.spec.ts @@ -1,3 +1,4 @@ +import { listBookAssetsRequestSchema } from './book-assets'; import { normalizeBookDocument } from './book-normalize'; import { STICKER_INK, @@ -8,7 +9,6 @@ import { bookStickerSlotColor, bookStickerWords, isBookSticker, - listBookAssetsRequestSchema, } from './book-sticker'; import { bookElementSchema, bookStickerElementSchema } from './book.schema'; @@ -100,7 +100,7 @@ describe('the sticker element and the asset search in the contract', () => { expect(doc.spreads[0]?.elements.map((e) => e.id)).toEqual(['good']); }); - it('STK-006: list_book_assets takes a kind, a query, tags, a theme and a limit, nothing else', () => { + it('STK-006: list_book_assets takes a kind of the catalogues, a query, tags, a theme and a limit, nothing else', () => { expect( listBookAssetsRequestSchema.parse({ kind: 'sticker', query: ' lemon ', theme: 'mediterranean', limit: 5 }), ).toEqual({ @@ -109,7 +109,9 @@ describe('the sticker element and the asset search in the contract', () => { theme: 'mediterranean', limit: 5, }); - expect(listBookAssetsRequestSchema.safeParse({ kind: 'font' }).success).toBe(false); + // Templates are list_book_layouts': not a kind of this tool. + expect(listBookAssetsRequestSchema.safeParse({ kind: 'template' }).success).toBe(false); + expect(listBookAssetsRequestSchema.safeParse({ kind: 'font' }).success).toBe(true); expect(listBookAssetsRequestSchema.safeParse({ kind: 'sticker', limit: 41 }).success).toBe(false); expect(listBookAssetsRequestSchema.safeParse({ kind: 'sticker', theme: 'moon' }).success).toBe(false); expect(listBookAssetsRequestSchema.safeParse({ kind: 'sticker', extra: 1 }).success).toBe(false); diff --git a/shared/src/book/book-sticker.ts b/shared/src/book/book-sticker.ts index 0e77001bf..9571e35c4 100644 --- a/shared/src/book/book-sticker.ts +++ b/shared/src/book/book-sticker.ts @@ -1,10 +1,5 @@ import type { BookElement, BookFrame, BookPageSetup, BookStickerElement } from './book.schema'; -import { - BOOK_STICKER_THEMES, - type BookStickerDef, - type BookStickerLayer, - type BookStickerSlot, -} from './stickers/sticker-types'; +import type { BookStickerDef, BookStickerLayer, BookStickerSlot } from './stickers/sticker-types'; import { z } from 'zod'; @@ -119,27 +114,3 @@ export const bookStickerIdSchema = z .regex(BOOK_STICKER_ID_PATTERN, 'expected a sticker id') .max(MAX_STICKER_ID) .describe('A sticker of the Studio library by id, as list_book_assets names it: hibiscus, eiffelTower, washiDots.'); - -/** The asset kinds list_book_assets answers for. Stickers for now; fonts, themes and templates join it later. */ -export const BOOK_ASSET_KINDS = ['sticker'] as const; - -/** `list_book_assets`: search the Studio's catalogue of things to put on a page. */ -export const listBookAssetsArgs = { - kind: z.enum(BOOK_ASSET_KINDS).describe('What to list: sticker, the illustrations of the Studio library.'), - query: z - .string() - .trim() - .max(80) - .optional() - .describe('Words to search the ids, tags and descriptions by, such as lemon, paris or washi tape.'), - tags: z.array(z.string().trim().min(1).max(40)).max(8).optional().describe('Tags every result must carry.'), - theme: z - .enum(BOOK_STICKER_THEMES) - .optional() - .describe( - 'Only stickers of this theme: ephemera, landmarks, tropical, mediterranean, sea, mountains, nordic, asia, food, paper, doodles or ornaments.', - ), - limit: z.number().int().min(1).max(40).optional().describe('At most this many rows, 40 by default.'), -}; -export const listBookAssetsRequestSchema = z.strictObject(listBookAssetsArgs); -export type ListBookAssetsRequest = z.infer; diff --git a/shared/src/book/data/book-asset-lines.data.ts b/shared/src/book/data/book-asset-lines.data.ts new file mode 100644 index 000000000..6345840ea --- /dev/null +++ b/shared/src/book/data/book-asset-lines.data.ts @@ -0,0 +1,141 @@ +import type { BookPattern } from '../book-paint.schema'; +import type { BookShapeId } from '../book.schema'; + +/** + * One English line on each shape and each pattern, for `list_book_assets`: what + * a model picking one it cannot see needs to know. Data, not code: the + * catalogues themselves (the outlines in book-shapes-more.ts and the client's + * shapes.ts, the repeats in the paint renderer) stay where they are drawn. + */ + +export const BOOK_SHAPE_LINES: Readonly> = { + rect: 'A rectangle; with a radius a rounded card, with an edge a stamp or a torn sheet.', + ellipse: 'A circle or an oval: a badge, a sun, a round photo.', + line: 'A straight rule, its height the thickness; for a drawn line with points use the line kind.', + triangle: 'A triangle pointing up: a pennant, a mountain, a sail.', + 'triangle-down': 'A triangle pointing down.', + diamond: 'A diamond, a square stood on its corner.', + parallelogram: 'A slanted rectangle, for a band that leans.', + trapezoid: 'A trapezoid, wider at the foot.', + pentagon: 'A five-sided polygon.', + hexagon: 'A six-sided polygon, pointed at the top.', + 'hexagon-flat': 'A six-sided polygon, flat at the top.', + heptagon: 'A seven-sided polygon.', + octagon: 'An eight-sided polygon.', + arch: 'A round-topped arch, a window or a door.', + 'half-circle': 'A half circle, flat side down: a setting sun.', + 'quarter-circle': 'A quarter circle, for a corner.', + capsule: 'A pill shape with round ends: a label, a chip.', + squircle: 'A soft square between a square and a circle.', + 'star-4': 'A four-pointed star.', + 'star-5': 'A five-pointed star.', + 'star-6': 'A six-pointed star.', + 'star-8': 'An eight-pointed star.', + 'star-12': 'A twelve-pointed star, almost a burst.', + burst: 'A spiky burst, an exclamation behind a word.', + seal: 'A round seal with a wavy rim, a badge or a sticker.', + sparkle: 'A four-armed sparkle that glints beside a title.', + 'arrow-right': 'A block arrow pointing right.', + 'arrow-left': 'A block arrow pointing left.', + 'arrow-up': 'A block arrow pointing up.', + 'arrow-down': 'A block arrow pointing down.', + 'arrow-both': 'A block arrow pointing both ways.', + 'chevron-right': 'A chevron pointing right.', + 'chevron-left': 'A chevron pointing left.', + 'arrow-bent': 'A block arrow that turns a corner.', + 'bubble-round': 'A round speech bubble with a tail.', + 'bubble-square': 'A square speech bubble with a tail.', + 'bubble-oval': 'An oval speech bubble with a tail.', + 'bubble-think': 'A thought bubble of clouds.', + heart: 'A heart.', + cloud: 'A flat cloud.', + 'cloud-puffy': 'A puffy cloud of round lobes.', + drop: 'A water drop.', + moon: 'A crescent moon.', + sun: 'A sun with rays.', + 'flower-5': 'A flower of five petals.', + 'flower-6': 'A flower of six petals.', + leaf: 'A simple leaf.', + cross: 'A diagonal cross, an X.', + plus: 'A plus sign.', + shield: 'A shield, a crest.', + gear: 'A cog wheel.', + ticket: 'A ticket with notched sides.', + wave: 'A band of waves: the sea, or a postmark cancel.', + mountain: 'A mountain with a snow cap.', + compass: 'A compass rose.', + pin: 'A map pin.', + 'blob-1': 'An organic blob, a soft splash of colour behind a photo.', + 'blob-2': 'An organic blob, rounder.', + 'blob-3': 'An organic blob, longer.', + 'blob-4': 'An organic blob, lobed.', + 'banner-ribbon': 'A ribbon banner with folded ends, for a title across it.', + 'banner-pennant': 'A pennant flag.', + 'banner-bookmark': 'A bookmark ribbon with a notched foot.', + 'banner-flag': 'A waving flag.', + 'arch-tall': 'A tall round-topped arch, the Mediterranean window a photo is cut to.', + 'arch-pointed': 'A pointed gothic arch.', + 'arch-double': 'Two arches side by side.', + 'arch-half': 'Half an arch, rounded on one side.', + 'arch-rainbow': 'A rainbow arch of bands.', + 'leaf-palm': 'A palm leaf cut out of paper.', + 'leaf-oak': 'An oak leaf cut out of paper.', + 'leaf-long': 'A long narrow leaf cut out of paper.', + 'leaf-monstera': 'A monstera leaf with its holes, cut out of paper.', + 'coral-1': 'A branching coral cut out of paper, in the manner of Matisse.', + 'coral-2': 'A second, bushier coral cut-out.', + seaweed: 'A wavy strand of seaweed cut out of paper.', + 'star-organic': 'A hand-cut wonky star.', + 'sun-cutout': 'A hand-cut sun with uneven rays.', + 'organic-1': 'A hand-cut rounded shape, a pebble.', + 'organic-2': 'A hand-cut shape, a soft bean.', + 'organic-3': 'A hand-cut shape, an uneven oval.', + 'stamp-rect': 'A postage stamp with a perforated edge; masks a photo as a stamp.', + 'stamp-circle': 'A round perforated stamp.', + postmark: 'A round postmark of two rings.', + rosette: 'A rosette with a scalloped rim.', + 'scallop-circle': 'A circle with a scalloped edge, a label.', + 'badge-shield': 'A shield-shaped badge.', + 'label-tab': 'A luggage label with a hole.', + 'ticket-stub': 'A ticket stub with round notches.', + 'sunburst-12': 'A sunburst of twelve rays.', + 'sunburst-24': 'A sunburst of twenty-four rays, behind a photo or a title.', + 'ribbon-ends': 'A band with forked ends.', + 'ribbon-arc': 'A ribbon curved into an arc, for a title over it.', + 'wavy-band': 'A wavy band across a page.', + 'torn-strip': 'A strip of torn paper.', + 'bubble-shout': 'A spiky shout bubble.', + 'bubble-side': 'A speech bubble with its tail at the side.', + 'bubble-capsule': 'A capsule speech bubble.', + 'arrow-curve': 'A drawn arrow that curves.', + 'arrow-circle': 'A drawn arrow that circles round.', + 'arrow-notched': 'A notched block arrow.', + 'arrow-thin': 'A thin drawn arrow.', + 'heart-round': 'A round, plump heart.', + 'heart-pair': 'Two hearts together.', + 'star-rounded': 'A star with rounded points.', + 'star-twinkle': 'A twinkling star of thin arms.', + 'flower-8': 'A flower of eight petals.', + 'frame-notched': 'A frame with notched corners, for a photo.', + 'frame-chamfer': 'A frame with cut corners, for a photo.', + 'frame-cushion': 'A cushion frame with soft bulging sides, for a photo.', + 'flourish-scroll': 'A scrolling flourish, an ornament under a title.', + laurel: 'A laurel wreath, round a date or a number.', + fleuron: "A fleuron, the printer's flower set between paragraphs.", + 'corner-flourish': 'A flourish for the corner of a page.', +}; + +export const BOOK_PATTERN_LINES: Readonly> = { + checker: 'Squares of two colours in a chequerboard, a picnic cloth or a tiled floor.', + gingham: 'Gingham, a woven check of crossing bands.', + stripes: 'Broad stripes, a deckchair or an awning.', + pinstripe: 'Fine stripes, a shirt.', + dots: 'Small dots in rows.', + polka: 'Large polka dots.', + zigzag: 'A zigzag chevron.', + waves: 'Rows of waves.', + grid: 'A fine grid of lines, graph paper.', + scales: 'Overlapping half circles, fish scales or roof tiles.', + sunburst: 'Rays from a centre, a sunburst.', + confetti: 'Scattered confetti.', +}; diff --git a/shared/src/book/describe-spread.ts b/shared/src/book/describe-spread.ts index 0fd863de7..3ebe1bfed 100644 --- a/shared/src/book/describe-spread.ts +++ b/shared/src/book/describe-spread.ts @@ -1,4 +1,5 @@ import { photoDpi, spreadFindings } from './book-findings'; +import { themeFindings } from './book-findings-art'; import { designFindings, sortBookFindings } from './book-findings-design'; import { bookFontRole, bookWeightWord, nearestBookWeight } from './book-fonts'; import { BOOK_GRIDS } from './book-grids'; @@ -27,6 +28,7 @@ import { import { bookEdgeWords, bookLineWords } from './describe-lines'; import { decoratedTemplateRow, decoratedTemplatesFor } from './templates/template-catalogue'; import { bookThemeLine, themeBackgroundWords, themeLinkWords } from './theme/book-theme-describe'; +import type { BookTheme } from './theme/book-theme.schema'; /** * A page in words, for a model that cannot see it and as the text beside a @@ -238,14 +240,17 @@ function elementSummary(el: BookElement): BookElementSummary { /** * Every finding of one page, the print faults and the design checks - * (book-findings-design.ts) together, severe first, at most the cap. + * (book-findings-design.ts) together, severe first, at most the cap; with the + * book's theme also what strays from it (book-findings-art.ts). */ export function pageFindings( spread: BookSpread, page: BookPageSetup, photos?: readonly BookPhotoInfo[], + theme?: BookTheme, ): BookFinding[] { - return sortBookFindings([...spreadFindings(spread, page, photos), ...designFindings(spread, page, photos)]); + const design = [...designFindings(spread, page, photos), ...themeFindings(spread, theme)]; + return sortBookFindings([...spreadFindings(spread, page, photos), ...design]); } /** @@ -263,7 +268,7 @@ export function describeSpread( const index = doc.spreads.findIndex((s) => s.id === spreadId); const spread = doc.spreads[index]; if (!spread) return null; - const findings = pageFindings(spread, doc.page, photos); + const findings = pageFindings(spread, doc.page, photos, doc.theme); const lines = [pageLine(doc, spread, index), bookThemeLine(doc.theme)]; if (spread.elements.length) { lines.push('Elements, back to front (frame in mm: x, y, w x h):'); diff --git a/shared/src/book/design/design-art.spec.ts b/shared/src/book/design/design-art.spec.ts new file mode 100644 index 000000000..71bf32c77 --- /dev/null +++ b/shared/src/book/design/design-art.spec.ts @@ -0,0 +1,262 @@ +import { + bookPhotoElementSchema, + bookTextElementSchema, + type BookElement, + type BookPageSetup, + type BookPhotoElement, + type BookSpread, +} from '../book.schema'; +import { BOOK_STICKERS } from '../stickers/sticker-catalogue'; +import { bookThemeOf } from '../theme/book-theme'; +import { + CALM_ART, + STICKERS_PER_SPREAD, + designArt, + livelyLook, + tiltRange, + withinTilt, + type DesignArt, +} from './design-art'; +import type { ComposeCtx } from './design-compose'; +import { bookDesignDirectivesSchema, type BookDesignDirectivesInput } from './design-directives.schema'; +import { designRng } from './design-random'; +import { decorateSpread, placeStickers, sharedArea, stickersFor, turnedBox } from './design-stickers'; +import { designGrid, designLook } from './design-style'; + +import { describe, expect, it } from 'vitest'; + +/** + * The art direction a design is composed in (design-art.ts), the seeded + * numbers its choices are made with (design-random.ts), and the stickers it + * lays on a page (design-stickers.ts): never over words, never over a + * photo's detail, never in the fold or the safe margin. + */ + +const parse = (raw: BookDesignDirectivesInput) => bookDesignDirectivesSchema.parse(raw); + +describe('the art direction', () => { + it('DSNART-001: is calm, bare and without collages for a design without a brief', () => { + const art = designArt(parse({ style: 'tropical' }), bookThemeOf('tropical'), BOOK_STICKERS); + expect(art).toMatchObject({ briefed: false, energy: 'calm', decoration: 'none', bare: false, collage: false }); + expect(CALM_ART).toMatchObject({ energy: 'calm', decoration: 'none', stickers: [], library: [], tilt: [0, 2] }); + }); + + it('DSNART-002: reads the energy from the brief, lively when it asks for decoration or collages alone', () => { + const theme = bookThemeOf('nordic'); + expect(designArt(parse({ brief: { energy: 'loud' } }), theme).energy).toBe('loud'); + expect(designArt(parse({ brief: { decoration: 'light' } }), theme).energy).toBe('lively'); + expect(designArt(parse({ brief: { collage: true } }), theme)).toMatchObject({ energy: 'lively', collage: true }); + expect(designArt(parse({ brief: { decoration: 'none' } }), theme)).toMatchObject({ energy: 'calm', bare: true }); + }); + + it('DSNART-003: decorates a lively book as its theme does, and draws from the theme motifs only', () => { + const tropical = bookThemeOf('tropical'); + const art = designArt(parse({ brief: { energy: 'lively' }, seed: 9 }), tropical, BOOK_STICKERS); + expect(art.decoration).toBe(tropical.decoration); + expect(art.seed).toBe(9); + expect(art.stickers.length).toBeGreaterThan(5); + expect(art.stickers.every((s) => tropical.motifs.includes(s.theme as (typeof tropical.motifs)[number]))).toBe(true); + expect(art.library).toHaveLength(BOOK_STICKERS.length); + // A theme of the older looks has no motifs: the travel things every journey has. + const own = { ...tropical, motifs: [] }; + const plain = designArt(parse({ brief: { energy: 'loud', decoration: 'rich' } }), own, BOOK_STICKERS); + expect(new Set(plain.stickers.map((s) => s.theme))).toEqual(new Set(['ephemera', 'doodles'])); + }); + + it('DSNART-004: stickers per spread follow the level: none, light one or two, rich three to six', () => { + expect(STICKERS_PER_SPREAD).toEqual({ none: [0, 0], light: [1, 2], rich: [3, 6] }); + }); + + it('DSNART-005: turns prints as the energy asks, a calm book as far as its theme does, and keeps a turn within it', () => { + expect(tiltRange('calm')).toEqual([0, 2]); + expect(tiltRange('calm', 4)).toEqual([0, 4]); + expect(tiltRange('lively')).toEqual([3, 7]); + expect(tiltRange('loud', 2)).toEqual([4, 10]); + const calm = designArt(parse({ brief: { energy: 'calm' } }), bookThemeOf('nordic')); + expect(calm.tilt).toEqual([0, 2]); + expect(designArt(parse({ brief: { energy: 'calm' } }), bookThemeOf('poster-pop')).tilt).toEqual([0, 4]); + expect([withinTilt(calm, 5), withinTilt(calm, -6), withinTilt(calm, 1.5)]).toEqual([2, -2, 1.5]); + const lively = designArt(parse({ brief: { energy: 'lively' } }), bookThemeOf('nordic')); + expect(withinTilt(lively, 6)).toBe(6); + }); +}); + +describe('the seeded numbers', () => { + it('DSNRNG-001: are the same for one seed and key, and others for another', () => { + const a = designRng(3, 'cover'); + const b = designRng(3, 'cover'); + const seq = (r: ReturnType) => Array.from({ length: 6 }, () => r.next()); + const first = seq(a); + expect(seq(b)).toEqual(first); + expect(seq(designRng(4, 'cover'))).not.toEqual(first); + expect(seq(designRng(3, 'beat'))).not.toEqual(first); + expect(first.every((v) => v >= 0 && v < 1)).toBe(true); + }); + + it('DSNRNG-002: stay in their ranges, pick from a list, shuffle without losing anything', () => { + const rng = designRng(1, 'spec'); + for (let i = 0; i < 200; i++) { + const r = rng.range(2, 5); + expect(r >= 2 && r < 5).toBe(true); + const n = rng.int(3, 6); + expect(Number.isInteger(n) && n >= 3 && n <= 6).toBe(true); + expect([1, -1]).toContain(rng.sign()); + } + expect(rng.pick([])).toBeUndefined(); + expect(['a', 'b']).toContain(rng.pick(['a', 'b'])); + const list = [1, 2, 3, 4, 5, 6]; + const shuffled = rng.shuffle(list); + expect([...shuffled].sort()).toEqual(list); + expect(list).toEqual([1, 2, 3, 4, 5, 6]); + expect(rng.chance(0)).toBe(false); + expect(rng.chance(1)).toBe(true); + }); +}); + +const page: BookPageSetup = { + preset: 'square-210', + pageWidth: 210, + pageHeight: 210, + bleed: 3, + safe: 5, + pageNumbers: { + show: false, + startAt: 2, + position: 'outer', + margin: 12, + size: 8, + autoColor: true, + color: '#8a8578', + font: 'sans', + }, +}; + +/** A composing context with an art direction, enough for the stickers. */ +function ctxOf(art: DesignArt): ComposeCtx { + const directives = parse({ style: 'tropical' }); + const look = designLook(directives, page, []); + let n = 0; + const labels = { day: '', stations: '', summary: '', countries: '', distance: '', pros: '', cons: '' }; + return { + look, + page, + grid: designGrid(page), + newId: (p) => `${p}-${++n}`, + locale: 'en-GB', + infos: new Map(), + travel: { locale: 'en-GB', labels, stats: null, path: [], entries: [] }, + directives, + notes: [], + art, + }; +} + +const rich = (more: Partial = {}): DesignArt => ({ + ...designArt(parse({ brief: { energy: 'lively', decoration: 'rich' } }), bookThemeOf('tropical'), BOOK_STICKERS), + ...more, +}); + +const photo = (id: string, frame: BookPhotoElement['frame'], focal = { focalX: 0.5, focalY: 0.5 }) => + bookPhotoElementSchema.parse({ id, kind: 'photo', frame, photoId: 1, ...focal }) as BookElement; +const words = (id: string, frame: BookPhotoElement['frame']) => + bookTextElementSchema.parse({ id, kind: 'text', frame, text: 'Porto', size: 20 }) as BookElement; + +describe('stickers on a page', () => { + const elements = [ + photo('p1', { x: 30, y: 30, w: 150, h: 110 }, { focalX: 0.3, focalY: 0.4 }), + words('t1', { x: 240, y: 30, w: 150, h: 40 }), + photo('p2', { x: 240, y: 90, w: 150, h: 100 }), + ]; + + it('DSNSTK-001: lie off the words, the photos details, the fold and the safe margin, each a drawing of the motifs', () => { + const c = ctxOf(rich()); + const made = placeStickers(c, elements, { count: 6, size: [0.14, 0.22], sides: ['left', 'right'], key: 'deco:1' }); + expect(made.length).toBeGreaterThanOrEqual(3); + const motifs = new Set(rich().stickers.map((s) => s.id)); + const words = elements.filter((e) => e.kind === 'text').map((e) => turnedBox(e.frame, e.rotation)); + for (const k of made) { + const box = turnedBox(k.frame, k.rotation); + expect(motifs.has(k.stickerId)).toBe(true); + expect(words.every((w) => sharedArea(w, box) === 0)).toBe(true); + expect(box.x + box.w <= 210 - 5 || box.x >= 210 + 5).toBe(true); + expect(box.y >= 5 && box.y + box.h <= 205 && box.x >= 5 && box.x + box.w <= 415).toBe(true); + for (const e of elements) { + if (e.kind !== 'photo') continue; + const fx = e.frame.x + e.focalX * e.frame.w; + const fy = e.frame.y + e.focalY * e.frame.h; + expect(fx >= box.x && fx <= box.x + box.w && fy >= box.y && fy <= box.y + box.h).toBe(false); + } + } + // No two stickers on top of each other, and every drawing different while the library lasts. + made.forEach((a, i) => + made + .slice(i + 1) + .forEach((b) => expect(sharedArea(turnedBox(a.frame, a.rotation), turnedBox(b.frame, b.rotation))).toBe(0)), + ); + expect(new Set(made.map((k) => k.stickerId)).size).toBe(made.length); + // The same for one key, each from a context of its own (the ids count on from where a context stands). + const again = () => + placeStickers(ctxOf(rich()), elements, { count: 6, size: [0.14, 0.22], sides: ['left', 'right'], key: 'deco:1' }); + expect(again()).toEqual(again()); + expect(again().map((k) => k.stickerId)).toEqual(made.map((k) => k.stickerId)); + }); + + it('DSNSTK-002: are none for a bare brief, a library without drawings, a page at its cap or a full page', () => { + const request = { count: 3, size: [0.14, 0.22] as const, sides: ['left', 'right'] as const, key: 'k' }; + expect(placeStickers(ctxOf(rich({ bare: true })), elements, request)).toEqual([]); + expect(placeStickers(ctxOf(rich({ stickers: [] })), elements, request)).toEqual([]); + const full = Array.from({ length: 90 }, (_, i) => words(`w${i}`, { x: 1, y: 1, w: 2, h: 2 })); + expect(placeStickers(ctxOf(rich()), full, request)).toEqual([]); + const wall = [words('all', { x: 0, y: 0, w: 420, h: 210 })]; + expect(placeStickers(ctxOf(rich()), wall, request)).toEqual([]); + }); + + it('DSNSTK-003: decorate a spread at the level asked, the same way for one key, not at all for a calm book', () => { + const spread: BookSpread = { id: 'sp', role: 'inner', background: null, elements, parked: [], entryId: null }; + const c = ctxOf(rich()); + const count = stickersFor(c, 'deco:4'); + expect(count >= 3 && count <= 6).toBe(true); + const decorated = decorateSpread(c, spread, 'deco:4'); + const added = decorated.elements.slice(elements.length); + expect(added.length).toBeGreaterThan(0); + expect(added.length).toBeLessThanOrEqual(count); + expect(added.every((e) => e.kind === 'sticker')).toBe(true); + expect(decorateSpread(ctxOf(CALM_ART), spread, 'deco:4')).toBe(spread); + const light = ctxOf(rich({ decoration: 'light' })); + expect([1, 2]).toContain(stickersFor(light, 'deco:4')); + }); + + it('DSNSTK-004: are turned as the book turns its prints: nearly straight in a calm book, further in a loud one', () => { + const request = { count: 6, size: [0.14, 0.22] as const, sides: ['left', 'right'] as const, key: 'deco:2' }; + const calm = placeStickers(ctxOf(rich({ energy: 'calm', tilt: [0, 2] })), elements, request); + expect(calm.length).toBeGreaterThan(0); + expect(calm.every((k) => Math.abs(k.rotation) <= 2)).toBe(true); + const loud = placeStickers(ctxOf(rich({ energy: 'loud', tilt: [4, 10] })), elements, request); + expect(loud.length).toBeGreaterThan(0); + expect(loud.every((k) => Math.abs(k.rotation) >= 4 && Math.abs(k.rotation) <= 10)).toBe(true); + }); +}); + +describe('the look of a lively book', () => { + const nordic = parse({ style: 'nordic' }); + const look = designLook(nordic, page, []); + const artOf = (raw: BookDesignDirectivesInput) => designArt(parse(raw), look.theme); + + it('DSNART-006: turns and frames the prints and sets the cover title larger; a calm book stays as it is', () => { + expect(livelyLook(look, artOf({ brief: { energy: 'calm' } }), nordic)).toBe(look); + expect(look).toMatchObject({ tilt: 0, frameStyle: 'none' }); + const livelyRaw = { style: 'nordic', brief: { energy: 'lively' } } as const; + const lively = livelyLook(look, artOf(livelyRaw), parse(livelyRaw)); + expect(lively).toMatchObject({ tilt: 3, frameStyle: 'white' }); + expect(lively.sizes.display).toBeCloseTo(look.sizes.heading * 1.5, 0); + expect(lively.sizes.body).toBe(look.sizes.body); + const loudRaw = { style: 'nordic', brief: { energy: 'loud' } } as const; + const loud = livelyLook(look, artOf(loudRaw), parse(loudRaw)); + expect(loud.tilt).toBe(4); + expect(loud.sizes.display).toBeCloseTo(look.sizes.heading * 2, 0); + // A display tier the directives name is theirs. + const askedRaw = { style: 'nordic', brief: { energy: 'loud' }, type: { display: 3 } } as const; + const asked = designLook(parse(askedRaw), page, []); + expect(livelyLook(asked, artOf(askedRaw), parse(askedRaw)).sizes.display).toBe(asked.sizes.display); + }); +}); diff --git a/shared/src/book/design/design-art.ts b/shared/src/book/design/design-art.ts new file mode 100644 index 000000000..959a80b4a --- /dev/null +++ b/shared/src/book/design/design-art.ts @@ -0,0 +1,151 @@ +import type { BookStickerDef } from '../stickers/sticker-types'; +import type { BookTheme, BookThemeDecoration } from '../theme/book-theme.schema'; +import type { BookDesignEnergy } from './design-brief.schema'; +import type { BookDesignDirectives } from './design-directives.schema'; +import type { BookDesignLook } from './design-style'; + +/** + * The art direction a design is composed in, read once from the brief and the + * book's theme: how lively it is, how many stickers a spread takes and from + * which motifs, whether photos gather in collages, and the seed of every + * choice made along the way (design-random.ts). + * + * A design without a brief is calm, undecorated and without collages, which + * is the engine as it was before the brief existed: nothing here changes a + * book nobody asked to change. + */ + +/** What the engine needs of a sticker to place one: the catalogue's facts, never its drawing. */ +export type BookDesignSticker = Pick; + +export interface DesignArt { + /** Whether a brief was given at all; the findings that judge a book against its brief need one. */ + briefed: boolean; + energy: BookDesignEnergy; + /** Stickers per inner spread (`STICKERS_PER_SPREAD`). */ + decoration: BookThemeDecoration; + /** No sticker anywhere, a cover archetype's own included: the brief said decoration none. */ + bare: boolean; + collage: boolean; + /** The stickers the book may use: those of its theme's motifs, in catalogue order. */ + stickers: readonly BookDesignSticker[]; + /** The whole library, for a drawing picked by what it shows (a cover's landmark). */ + library: readonly BookDesignSticker[]; + /** The drawings the design placed so far, so a book repeats one only when it runs out. */ + used?: Set; + /** + * How far the book turns its prints and stickers, least and most, in + * degrees off straight (`tiltRange`): every turn the engine gives is in it, + * and the brief's findings judge a book by it. + */ + tilt: readonly [number, number]; + seed: number; +} + +/** Stickers a spread takes for each decoration level, fewest and most. */ +export const STICKERS_PER_SPREAD: Readonly> = { + none: [0, 0], + light: [1, 2], + rich: [3, 6], +}; + +/** + * How far a page turns its prints and stickers at each energy, in degrees + * off straight, least and most. Calm turns nothing on purpose: a scrapbook's + * own two degrees stay within it. + */ +const TILT_RANGE: Readonly> = { + calm: [0, 2], + lively: [3, 7], + loud: [4, 10], +}; + +/** + * The turns a book takes at an energy: its range, the most widened to the + * theme's own turn, since a theme that turns its prints four degrees is that + * theme at every energy. + */ +export function tiltRange(energy: BookDesignEnergy, themeTilt = 0): readonly [number, number] { + const [least, most] = TILT_RANGE[energy]; + return [least, Math.max(most, themeTilt)]; +} + +/** A turn an archetype or a page asks for, kept to the most the book's energy takes, its direction kept. */ +export function withinTilt(art: Pick, turn: number): number { + const most = art.tilt[1]; + return Math.max(-most, Math.min(most, turn)); +} + +/** The cover title's tier a book takes at each energy when the directives name none: oversize type for a lively one. */ +const DISPLAY_TIER: Readonly> = { calm: 1, lively: 1.5, loud: 2 }; + +/** The largest size a text element takes (book.schema.ts). */ +const MAX_PT = 200; + +/** + * The look a lively or loud book is set in: its prints turned at least the + * least its energy takes, alternating as a scrapbook turns them, framed in + * white where the style leaves them bare, and the cover title on a display + * tier of its own unless `type.display` names one. A calm book's look as it is. + */ +export function livelyLook(look: BookDesignLook, art: DesignArt, directives: BookDesignDirectives): BookDesignLook { + if (art.energy === 'calm') return look; + const tier = DISPLAY_TIER[art.energy]; + const display = + directives.type?.display === undefined + ? Math.min(MAX_PT, Math.round(look.sizes.heading * tier * 10) / 10) + : look.sizes.display; + return { + ...look, + tilt: Math.max(look.tilt, art.tilt[0]), + frameStyle: look.frameStyle === 'none' ? 'white' : look.frameStyle, + sizes: { ...look.sizes, display }, + }; +} + +/** The motifs a theme without any is decorated from: the travel things every journey has. */ +const FALLBACK_MOTIFS = ['ephemera', 'doodles'] as const; + +/** The energy a brief asks for: its own, else lively when it asks for decoration or collages, else calm. */ +function energyOf(brief: BookDesignDirectives['brief']): BookDesignEnergy { + if (brief?.energy) return brief.energy; + const decorated = brief?.decoration !== undefined && brief.decoration !== 'none'; + return decorated || brief?.collage ? 'lively' : 'calm'; +} + +/** The book's art direction, from its directives, its theme and the sticker library the caller holds. */ +export function designArt( + directives: BookDesignDirectives, + theme: BookTheme, + library: readonly BookDesignSticker[] = [], +): DesignArt { + const brief = directives.brief; + const energy = energyOf(brief); + const decoration = brief?.decoration ?? (energy === 'calm' ? 'none' : theme.decoration); + const motifs: readonly string[] = theme.motifs.length ? theme.motifs : FALLBACK_MOTIFS; + return { + briefed: brief !== undefined, + energy, + decoration, + bare: brief?.decoration === 'none', + collage: brief?.collage ?? false, + stickers: library.filter((s) => motifs.includes(s.theme)), + library, + used: new Set(), + tilt: tiltRange(energy, theme.tilt), + seed: directives.seed ?? 0, + }; +} + +/** The art direction of a design composed without one: calm, bare, as before. */ +export const CALM_ART: DesignArt = { + briefed: false, + energy: 'calm', + decoration: 'none', + bare: false, + collage: false, + stickers: [], + library: [], + tilt: TILT_RANGE.calm, + seed: 0, +}; diff --git a/shared/src/book/design/design-book.ts b/shared/src/book/design/design-book.ts index a01d131a7..28216664e 100644 --- a/shared/src/book/design/design-book.ts +++ b/shared/src/book/design/design-book.ts @@ -1,5 +1,6 @@ import { buildBook, distributeGallery } from '../auto/auto-book'; import type { AutoEntry, AutoInput, AutoPhoto } from '../auto/auto-types'; +import { briefFindings, type BookBriefArt } from '../book-findings-art'; import { bookLevelFindings, sortBookFindings } from '../book-findings-design'; import { withRealWeight } from '../book-fonts'; import { bookElementId } from '../book-ids'; @@ -10,7 +11,9 @@ import type { BookTravelContext } from '../book-travel'; import { MAX_BOOK_TITLE, MAX_SPREADS, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema'; import { pageFindings } from '../describe-spread'; import type { PhotoPickCandidate } from '../photo-pick'; +import { designArt, livelyLook, type BookDesignSticker } from './design-art'; import type { ComposeCtx } from './design-compose'; +import type { CoverMaterial } from './design-cover-kit'; import { bookDesignDirectivesSchema, type BookDesignDirectives, @@ -18,7 +21,9 @@ import { } from './design-directives.schema'; import { composeBack, composeCover, composeTitlePage, stationsPerSpread } from './design-framing'; import { composeDesignedInner, type FramingNeeds } from './design-inner'; +import { isLivelyBeat } from './design-lively-pacing'; import { keepsStored, mergeDesign, type BookDesignPlanRow, type DesignedSpread } from './design-merge'; +import { applyPageOverrides } from './design-overrides'; import type { BookDesignBeat } from './design-pacing'; import { choosePhotos, type BookDesignLeftOut } from './design-photos'; import { repairSpread } from './design-repair'; @@ -35,6 +40,13 @@ import { themeDesignedSpread, unlinkDrifted } from './design-theme'; * every other design composes the book from chapters, pacing, a page * grid, the photos' own palette and type, repairs every page against the * findings and answers what is left. + * + * The art direction (design-brief.schema.ts) shapes it further: a cover + * composed on its archetype (design-cover.ts), lively spreads and stickers + * for a lively or loud book (design-lively-pacing.ts, design-stickers.ts), + * single pages of the plan recomposed (design-overrides.ts), and the book + * judged against its brief (book-findings-art.ts). A design asked for none of + * it is composed exactly as before. */ export interface BookDesignInput extends AutoInput { @@ -47,6 +59,11 @@ export interface BookDesignInput extends AutoInput { distanceLabel?: string; prosLabel?: string; consLabel?: string; + /** + * The sticker library's facts (`BOOK_STICKERS` of `@trek/shared/stickers`), + * for the stickers a decorated design places; without it nothing is stuck on. + */ + stickers?: readonly BookDesignSticker[]; } export interface BookDesignResult { @@ -62,6 +79,8 @@ export interface BookDesignResult { * fit a page, so a reader keeping only the first few keeps what matters most. */ notes: string[]; + /** The brief the book was designed against, so a page revised later is judged by it; none without a brief. */ + brief?: BookBriefArt; } /** The entries the directives' scope names, in the order they came. */ @@ -97,7 +116,7 @@ function pageFor(doc: BookDocument | null, input: BookDesignInput, directives: B * is dropped without a word. Scope, keep, format and seed the Auto path honours. */ function classicOnly(d: BookDesignDirectives): boolean { - if (d.style !== 'classic' || d.photos || d.palette || d.type) return false; + if (d.style !== 'classic' || d.photos || d.palette || d.type || d.brief) return false; if (d.pages || d.cover || d.text || d.travel) return false; return d.chapters === 'entry' && d.density === 'balanced'; } @@ -208,7 +227,7 @@ export function designBook( input: BookDesignInput, raw: BookDesignDirectivesInput, ): BookDesignResult { - const directives = bookDesignDirectivesSchema.parse(raw); + const directives = withBriefTheme(bookDesignDirectivesSchema.parse(raw)); const entries = inScope(input.entries, directives.scope); if (classicOnly(directives)) return classicDesign(doc, input, directives, entries); @@ -219,7 +238,10 @@ export function designBook( const choice = choosePhotos(placed, input.candidates, directives, input.photos); const usedPhotos = [...choice.byEntry.values()].flat(); const usedInfos = input.photos.filter((p) => usedPhotos.some((u) => u.photoId === p.photoId)); - const look = designLook(directives, page, usedInfos); + const plain = designLook(directives, page, usedInfos); + const art = designArt(directives, plain.theme, input.stickers); + // A lively or loud book turns and frames its prints and sets its cover title larger. + const look = livelyLook(plain, art, directives); const c: ComposeCtx = { look, page, @@ -230,6 +252,7 @@ export function designBook( travel: travelOf(input, placed), directives, notes: [], + art, }; const needs: FramingNeeds = { room: roomFor(doc, directives.keep), perStations: stationsPerSpread(c) }; const inner = composeDesignedInner(c, placed, choice, input, needs); @@ -244,7 +267,12 @@ export function designBook( const titlePage = TITLE_PAGE_STYLES.has(directives.style); const first = titlePage ? composeTitlePage(c, words, placed) : blank(c, 'first'); const framing: DesignedSpread[] = [ - { spread: composeCover(c, words, cover), beat: 'cover', chapter: null, findings: [] }, + { + spread: composeCover(c, words, cover, coverMaterial(cover, usedPhotos, placed)), + beat: 'cover', + chapter: null, + findings: [], + }, { spread: first, beat: 'first', chapter: null, findings: [] }, ]; const closing: DesignedSpread[] = [ @@ -252,25 +280,39 @@ export function designBook( { spread: composeBack(c, words, placed), beat: 'back', chapter: null, findings: [] }, ]; const repairCtx = { page, photos: input.photos, look, newId }; - const designed = [...framing, ...inner.spreads, ...closing].map((d): DesignedSpread => { - const fixed = repairSpread(themeDesignedSpread(d.spread, look), repairCtx); + const finish = (spread: BookSpread) => { + const fixed = repairSpread(themeDesignedSpread(spread, look), repairCtx); // Every face at a weight its family has: a travel element's bold in a one-weight poster face would be faked. const weighted = { ...fixed.spread, elements: fixed.spread.elements.map(withRealWeight) }; - return { ...d, spread: unlinkDrifted(weighted, look.theme), findings: fixed.findings }; - }); + return { spread: unlinkDrifted(weighted, look.theme), findings: fixed.findings }; + }; + const designed = [...framing, ...inner.spreads, ...closing].map((d): DesignedSpread => ({ + ...d, + ...finish(d.spread), + })); - const merged = mergeDesign(doc, designed, directives.keep); + const laid = mergeDesign(doc, designed, directives.keep); const title = (directives.cover?.title ?? input.title).slice(0, MAX_BOOK_TITLE); const pageNumbers = { ...page.pageNumbers, font: look.body }; - const result: BookDocument = { + const built: BookDocument = { version: 1, title, page: { ...page, pageNumbers: { ...pageNumbers, startAt: 1 } }, - spreads: merged.spreads, + spreads: laid.spreads, ...(doc?.layouts ? { layouts: doc.layouts } : {}), theme: look.theme, }; - const residual = designed.flatMap((d) => d.findings.filter((f) => f.severity !== 'info')); + const overrides = directives.pages?.overrides ?? []; + const env = { c, entries: placed, finish, ops: { photos: input.photos, newId } }; + const merged = overrides.length + ? applyPageOverrides(built, laid.plan, overrides, env) + : { doc: built, plan: laid.plan, notes: [] }; + const result = merged.doc; + const residual = overrides.length + ? redesignedFindings(result, merged.plan, input.photos) + : designed.flatMap((d) => d.findings.filter((f) => f.severity !== 'info')); + const judged = briefOf(c); + const brief = judged ? briefFindings(result, judged, briefScope(merged.plan)) : []; return { doc: result, plan: merged.plan, @@ -279,11 +321,59 @@ export function designBook( used: [...new Set(merged.plan.flatMap((r) => (r.action === 'drop' ? [] : r.photoIds)))], left_out: [...choice.leftOut, ...inner.leftOut], }, - findings: sortBookFindings([...residual, ...bookLevelFindings(result)]), - notes: [...new Set([...inner.notes, ...c.notes])], + findings: sortBookFindings([...residual, ...bookLevelFindings(result), ...brief]), + notes: [...new Set([...inner.notes, ...c.notes, ...merged.notes])], + ...(judged ? { brief: judged } : {}), }; } +/** The brief a design is judged against, when it was made with one. */ +function briefOf(c: ComposeCtx): BookBriefArt | undefined { + const art = c.art; + if (!art?.briefed) return undefined; + return { energy: art.energy, decoration: art.decoration, bare: art.bare, tilt: art.tilt }; +} + +/** The pages the plan kept as they were, and whether it made lively ones: what the brief's findings read of it. */ +function briefScope(plan: readonly BookDesignPlanRow[]): { kept: string[]; lively: boolean } { + const kept = plan.filter((r) => r.action === 'keep').map((r) => r.spreadId); + return { kept, lively: plan.some((r) => r.action !== 'keep' && isLivelyBeat(r.beat)) }; +} + +/** What the repair left on the pages a design writes, measured afresh once pages were overridden. */ +function redesignedFindings( + doc: BookDocument, + plan: readonly BookDesignPlanRow[], + photos: BookDesignInput['photos'], +): BookFinding[] { + const kept = new Set(plan.filter((r) => r.action === 'keep').map((r) => r.spreadId)); + return doc.spreads + .filter((s) => !kept.has(s.id)) + .flatMap((s) => pageFindings(s, doc.page, photos).filter((f) => f.severity !== 'info')); +} + +/** + * The photos a composed cover may use: the cover photo first, then one from + * each part of the book in turn, so a grid or a collage of the cover shows + * the journey and not one afternoon of it. + */ +function coverMaterial( + cover: AutoPhoto | null, + used: readonly AutoPhoto[], + entries: readonly AutoEntry[], +): CoverMaterial { + const rest = used.filter((p) => p.photoId !== cover?.photoId); + const picks = [0.2, 0.5, 0.8, 0.35, 0.65].map((at) => rest[Math.floor(at * rest.length)]); + const spread = [...new Map(picks.flatMap((p) => (p ? [[p.photoId, p] as const] : []))).values()]; + return { photos: [...(cover ? [cover] : []), ...spread], entries }; +} + +/** The directives with the brief's theme as the style it takes the place of. */ +function withBriefTheme(d: BookDesignDirectives): BookDesignDirectives { + const theme = d.brief?.theme; + return theme && theme !== d.style ? { ...d, style: theme } : d; +} + /** The styles that open the book on a title page: the quiet, bookish ones. */ const TITLE_PAGE_STYLES: ReadonlySet = new Set(['editorial', 'journal', 'fabric-classic', 'minimal-serif']); diff --git a/shared/src/book/design/design-brief.schema.ts b/shared/src/book/design/design-brief.schema.ts new file mode 100644 index 000000000..1072a1e3b --- /dev/null +++ b/shared/src/book/design/design-brief.schema.ts @@ -0,0 +1,113 @@ +import { BOOK_COVER_ARCHETYPES, type BookCoverArchetype } from '../book-archetypes'; +import { bookPageOpSchema } from '../book-ops.schema'; +import { bookLockupIdSchema } from '../book-text-ops.schema'; +import { MAX_SPREADS } from '../book.schema'; +import { BOOK_THEME_DECORATIONS, BOOK_THEME_IDS } from '../theme/book-theme.schema'; + +import { z } from 'zod'; + +/** + * The art direction of a whole-book design, as `design_book` takes it beside + * the older directives: the brief (how lively the book is, how much it is + * decorated, whether photos gather in collages, which theme it is set in), the + * cover's archetype and lockup, and per-page overrides of the beat or the + * layout. Every field is optional: a design asked for none of them is + * composed exactly as before. + * + * Each value carries a line on what it does, because the tool schema is all a + * model reads of it; the lines for the archetypes are also the prompt's index + * (assistant-book-recipes.prompt.ts on the server), so the two never differ. + */ + +/** How lively a book is. Calm is the engine as it always was. */ +export const BOOK_DESIGN_ENERGIES = ['calm', 'lively', 'loud'] as const; +export type BookDesignEnergy = (typeof BOOK_DESIGN_ENERGIES)[number]; + +/** One line on each cover archetype (book-archetypes.ts), for the schema and the prompt. */ +export const BOOK_COVER_ARCHETYPE_LINES: Readonly> = { + 'spine-band': 'a photo to the bleed, a coloured band on the spine side carrying the title', + 'poster-pop': 'a two-tone colour block, a heavy two-line title, a turned inset photo in a white border, stickers', + cutout: 'a saturated page, cut-out leaves and blobs in two tones round a bordered portrait photo', + postcard: 'a pastel page, a landmark drawing at its centre, a display serif title and a script line', + 'grid-badge': 'four photos in a grid with a white cross, a round title badge at its centre', + 'minimal-serif': 'a white page, a large photo with a hairline, a very large thin serif title and a sun ornament', + 'stamp-collage': 'photos in arches and stamps over a checker band, a round postmark', +}; + +/** The pages a lively or loud book is made of besides the calm ones (design-lively.ts). */ +export const BOOK_DESIGN_LIVELY_BEATS = ['collage', 'script-overlay', 'grid-badge', 'stamp-row', 'two-tone'] as const; +export type BookDesignLivelyBeat = (typeof BOOK_DESIGN_LIVELY_BEATS)[number]; + +/** The beats a page override may ask for: the lively ones and the photo beats a page is recomposed into. */ +export const BOOK_DESIGN_OVERRIDE_BEATS = [...BOOK_DESIGN_LIVELY_BEATS, 'hero', 'duo', 'trio', 'grid'] as const; +export type BookDesignOverrideBeat = (typeof BOOK_DESIGN_OVERRIDE_BEATS)[number]; + +const archetypeLines = BOOK_COVER_ARCHETYPES.map((a) => `${a} (${BOOK_COVER_ARCHETYPE_LINES[a]})`).join(', '); + +export const bookDesignBriefSchema = z + .strictObject({ + energy: z + .enum(BOOK_DESIGN_ENERGIES) + .describe( + "calm keeps whitespace and the grid, prints turned 2 degrees at most or the theme's own turn; lively makes some pages collages, script overlays, badges, stamp rows and two-tone splits, prints turned 3 to 7 degrees; loud turns them 4 to 10.", + ) + .optional(), + decoration: z + .enum(BOOK_THEME_DECORATIONS) + .describe( + "Stickers from the theme's motifs per spread: none, light 1 to 2, rich 3 to 6; left out, the theme's own for a lively or loud book and none for a calm one.", + ) + .optional(), + collage: z + .boolean() + .describe('Set groups of photos as collages of turned, framed prints; in a calm book its grids only.') + .optional(), + theme: z + .enum(BOOK_THEME_IDS) + .describe('A built-in theme by id, as style lists them; it takes the place of style.') + .optional(), + }) + .describe('The art direction, decided before the pages.'); +export type BookDesignBrief = z.infer; + +export const bookCoverArchetypeSchema = z.enum(BOOK_COVER_ARCHETYPES).describe(`A composed cover: ${archetypeLines}.`); + +/** The cover title as a lockup of the Studio's Text styles shelf. */ +export const bookCoverLockupSchema = bookLockupIdSchema.describe( + "The cover title set as this lockup, an id add_lockup takes; the archetype's own when left out.", +); + +/** + * A layout a page override asks for: any template the `layout` operation of + * the page edit takes, checked by that operation's own schema, so a layout the + * engine learns there is one an override may name. + */ +const overrideTemplateSchema = z + .string() + .max(40) + .refine((id) => bookPageOpSchema.safeParse({ op: 'layout', template: id }).success, { + message: 'not a layout list_book_layouts names', + }) + .describe('A double page layout by id, as list_book_layouts lists them.'); + +export const bookDesignPageOverrideSchema = z + .strictObject({ + index: z + .number() + .int() + .min(2) + .max(MAX_SPREADS - 1) + .describe('The page by its index in the plan design_book answered; the cover is 0.'), + beat: z + .enum(BOOK_DESIGN_OVERRIDE_BEATS) + .describe( + 'Recompose the page from its photos: collage (turned framed prints), script-overlay (one photo across the spread under a giant script word), grid-badge (a photo grid round a title badge), stamp-row (photos as stamps with a postmark), two-tone (a colour page with the title beside a photo), hero, duo, trio or grid.', + ) + .optional(), + template: overrideTemplateSchema.optional(), + }) + .refine((v) => v.beat !== undefined || v.template !== undefined, { message: 'give a beat or a template' }); +export type BookDesignPageOverride = z.infer; + +/** At most this many pages overridden in one design. */ +export const MAX_BOOK_DESIGN_OVERRIDES = 40; diff --git a/shared/src/book/design/design-brief.spec.ts b/shared/src/book/design/design-brief.spec.ts new file mode 100644 index 000000000..5c19ff42a --- /dev/null +++ b/shared/src/book/design/design-brief.spec.ts @@ -0,0 +1,110 @@ +import { BOOK_COVER_ARCHETYPES } from '../book-archetypes'; +import { designBookRequestSchema } from '../book-design-tools.schema'; +import { BOOK_LOCKUPS } from '../book-lockups.data'; +import { BOOK_THEME_IDS } from '../theme/book-theme.schema'; +import { + BOOK_COVER_ARCHETYPE_LINES, + BOOK_DESIGN_LIVELY_BEATS, + BOOK_DESIGN_OVERRIDE_BEATS, + MAX_BOOK_DESIGN_OVERRIDES, + bookCoverArchetypeSchema, + bookDesignBriefSchema, + bookDesignPageOverrideSchema, +} from './design-brief.schema'; +import { bookDesignDirectivesSchema } from './design-directives.schema'; +import { BOOK_DESIGN_BEATS } from './design-pacing'; + +import { describe, expect, it } from 'vitest'; + +/** + * The art direction design_book takes (design-brief.schema.ts): the brief, + * the cover's archetype and lockup, and the page overrides, each bounded and + * described, beside directives written before they existed, which still + * parse to exactly what they did. + */ + +const ok = (directives: unknown) => bookDesignDirectivesSchema.safeParse(directives).success; + +describe('the art direction of design_book', () => { + it('DSNBRF-001: directives written before the brief existed parse as they did, with no art direction', () => { + const old = { + style: 'editorial', + density: 'rich', + pages: { min: 6, max: 10 }, + cover: { template: 'cover-band', title: 'Coast' }, + type: { scale: 1.2, display: 3 }, + keep: 'nothing', + seed: 4, + }; + const parsed = bookDesignDirectivesSchema.parse(old); + expect(parsed).toMatchObject(old); + expect(parsed.brief).toBeUndefined(); + expect(parsed.cover).not.toHaveProperty('archetype'); + expect(parsed.cover).not.toHaveProperty('lockup'); + expect(parsed.pages).not.toHaveProperty('overrides'); + expect(bookDesignDirectivesSchema.parse({})).toEqual(bookDesignDirectivesSchema.parse({ style: 'classic' })); + }); + + it('DSNBRF-002: the brief takes an energy, a decoration, collages and a theme, nothing else', () => { + const brief = { energy: 'loud', decoration: 'rich', collage: true, theme: 'tropical' }; + expect(bookDesignBriefSchema.parse(brief)).toEqual(brief); + expect(bookDesignBriefSchema.parse({})).toEqual({}); + expect(bookDesignBriefSchema.safeParse({ energy: 'wild' }).success).toBe(false); + expect(bookDesignBriefSchema.safeParse({ decoration: 'heavy' }).success).toBe(false); + expect(bookDesignBriefSchema.safeParse({ theme: 'classic' }).success).toBe(false); + expect(bookDesignBriefSchema.safeParse({ energy: 'calm', mood: 'happy' }).success).toBe(false); + for (const theme of BOOK_THEME_IDS) expect(bookDesignBriefSchema.safeParse({ theme }).success, theme).toBe(true); + }); + + it('DSNBRF-003: the cover takes an archetype of the seven and a lockup of the Text styles shelf', () => { + for (const archetype of BOOK_COVER_ARCHETYPES) expect(ok({ cover: { archetype } }), archetype).toBe(true); + for (const lockup of BOOK_LOCKUPS) expect(ok({ cover: { lockup: lockup.id } }), lockup.id).toBe(true); + expect(ok({ cover: { archetype: 'collage' } })).toBe(false); + expect(ok({ cover: { lockup: 'big-title' } })).toBe(false); + // Every archetype is said in a line, so a model reads what it picks. + expect(Object.keys(BOOK_COVER_ARCHETYPE_LINES).sort()).toEqual([...BOOK_COVER_ARCHETYPES].sort()); + for (const archetype of BOOK_COVER_ARCHETYPES) + expect(bookCoverArchetypeSchema.description).toContain(`${archetype} (`); + expect(designBookRequestSchema.safeParse({ directives: { cover: { archetype: 'cutout' } } }).success).toBe(true); + }); + + it('DSNBRF-004: a page override names a page of the plan and a beat or a layout the page edit takes', () => { + expect(bookDesignPageOverrideSchema.parse({ index: 4, beat: 'collage' })).toEqual({ index: 4, beat: 'collage' }); + expect(bookDesignPageOverrideSchema.safeParse({ index: 4, template: 'two-up' }).success).toBe(true); + expect(bookDesignPageOverrideSchema.safeParse({ index: 4, template: 'cover-band' }).success).toBe(true); + expect(bookDesignPageOverrideSchema.safeParse({ index: 4, template: 'no-such-layout' }).success).toBe(false); + expect(bookDesignPageOverrideSchema.safeParse({ index: 4 }).success).toBe(false); + expect(bookDesignPageOverrideSchema.safeParse({ index: 1, beat: 'duo' }).success).toBe(false); + expect(bookDesignPageOverrideSchema.safeParse({ index: 4, beat: 'cover' }).success).toBe(false); + const many = Array.from({ length: MAX_BOOK_DESIGN_OVERRIDES + 1 }, (_, i) => ({ index: i + 2, beat: 'duo' })); + expect(ok({ pages: { overrides: many } })).toBe(false); + expect(ok({ pages: { min: 4, overrides: many.slice(1) } })).toBe(true); + }); + + it('DSNBRF-005: the lively beats join the beats a plan names, and an override takes them and the photo beats', () => { + for (const beat of BOOK_DESIGN_LIVELY_BEATS) { + expect(BOOK_DESIGN_BEATS).toContain(beat); + expect(BOOK_DESIGN_OVERRIDE_BEATS).toContain(beat); + } + expect(BOOK_DESIGN_OVERRIDE_BEATS).toEqual(expect.arrayContaining(['hero', 'duo', 'trio', 'grid'])); + expect(BOOK_DESIGN_BEATS.slice(0, 17)).toEqual([ + 'cover', + 'first', + 'summary', + 'countries', + 'opener', + 'hero', + 'story', + 'quiet', + 'grid', + 'duo', + 'trio', + 'pano', + 'map', + 'stations', + 'closing', + 'last', + 'back', + ]); + }); +}); diff --git a/shared/src/book/design/design-compose.ts b/shared/src/book/design/design-compose.ts index 0995d3c4e..e979531d7 100644 --- a/shared/src/book/design/design-compose.ts +++ b/shared/src/book/design/design-compose.ts @@ -12,6 +12,7 @@ import type { BookShapeElement, BookTextElement, } from '../book.schema'; +import type { DesignArt } from './design-art'; import type { BookDesignDirectives } from './design-directives.schema'; import { dpiIn } from './design-photos'; import type { BookDesignGrid, BookDesignLook } from './design-style'; @@ -38,6 +39,8 @@ export interface ComposeCtx { travel: BookTravelContext; directives: BookDesignDirectives; notes: string[]; + /** The brief's art direction (design-art.ts); a context without one composes calm. */ + art?: DesignArt; } export type Side = 'left' | 'right' | 'single'; diff --git a/shared/src/book/design/design-cover-collage.ts b/shared/src/book/design/design-cover-collage.ts new file mode 100644 index 000000000..457b9a191 --- /dev/null +++ b/shared/src/book/design/design-cover-collage.ts @@ -0,0 +1,145 @@ +import type { AutoPhoto } from '../auto/auto-types'; +import type { BookFrame, BookPhotoElement } from '../book.schema'; +import { frameOf, photoEl } from './design-compose'; +import { + addLockup, + badgeSpan, + inkOn, + keptTurn, + mmOn, + mmToPt, + placeLockup, + printEl, + shapeEl, + spacedLine, + titleText, + type CoverDraft, + type CoverKit, +} from './design-cover-kit'; + +/** + * The covers made of several photos: four in a grid round a title badge, and + * two or three cut into arches and stamps over a checker band with a postmark. + */ + +const WHITE = '#ffffff'; + +/** A photo filling a cell of a grid edge to edge: unframed and straight, at print resolution. */ +function cellEl(k: CoverKit, photo: AutoPhoto, cell: BookFrame): BookPhotoElement { + return { ...photoEl(k.c, photo, cell), frameStyle: 'none', rotation: 0 }; +} + +/** + * The cells of a cover grid, to the bleed with a gutter between them: four as + * two by two, three as two over one, two side by side, one to the bleed. + */ +function gridCells(k: CoverKit, count: number, g: number): BookFrame[] { + const { W, H, b } = k; + const half = { w: (W - g) / 2 + b, h: (H - g) / 2 + b }; + const right = (W + g) / 2; + const low = (H + g) / 2; + if (count >= 4) { + return [ + frameOf(-b, -b, half.w, half.h), + frameOf(right, -b, half.w, half.h), + frameOf(-b, low, half.w, half.h), + frameOf(right, low, half.w, half.h), + ]; + } + if (count === 3) { + return [frameOf(-b, -b, half.w, half.h), frameOf(right, -b, half.w, half.h), frameOf(-b, low, W + b * 2, half.h)]; + } + if (count === 2) return [frameOf(-b, -b, half.w, H + b * 2), frameOf(right, -b, half.w, H + b * 2)]; + return count === 1 ? [frameOf(-b, -b, W + b * 2, H + b * 2)] : []; +} + +/** + * Grid and badge (reference 6): four photos two by two to the bleed with a + * white cross of 4 to 6 mm between them, and a round badge at the centre with + * the title and the year (the circle-badge lockup, or the one asked for). + */ +export function gridBadge(k: CoverKit): CoverDraft { + const { c, W, H } = k; + const draft: CoverDraft = { elements: [], groups: [] }; + const photos = k.photos.slice(0, 4); + const cells = gridCells(k, photos.length, mmOn(k, k.rng.range(4, 6))); + photos.forEach((p, i) => { + const cell = cells[i]; + if (cell) draft.elements.push(cellEl(k, p, cell)); + }); + const cellsLaid = draft.elements.filter((e): e is BookPhotoElement => e.kind === 'photo'); + const d = badgeSpan(cellsLaid, [W / 2, H / 2], W * k.rng.range(0.4, 0.46), W * 0.26); + addLockup(k, draft, { box: frameOf((W - d) / 2, (H - d) / 2, d, d), ground: c.look.paper, fallback: 'circle-badge' }); + return draft; +} + +/** + * The prints of a stamp collage in a row across the foot of the band: the + * middle one cut to an arch with a white die-cut edge, the others perforated + * as stamps in a white border, each turned a little by the seed. + */ +function stampRow(k: CoverKit, photos: readonly AutoPhoto[], y: number): BookPhotoElement[] { + const { W } = k; + const pw = W * 0.26; + const gap = mmOn(k, 7); + const n = photos.length; + const start = (W - (n * pw + (n - 1) * gap)) / 2; + const arch = n === 1 ? 0 : 1; + return photos.map((p, i) => { + const turn = keptTurn(k, k.rng.sign() * k.rng.range(2, 6)); + const ph = i === arch ? pw * 1.3 : pw * 1.2; + const frame = frameOf(start + i * (pw + gap), y - ph / 2, pw, ph); + if (i === arch) { + return printEl(k, p, frame, { turn, mask: 'arch-tall', cutout: { width: mmOn(k, 1.8), color: WHITE } }); + } + return printEl(k, p, frame, { + turn, + edge: { edge: 'perforated', size: mmOn(k, 1.5) }, + border: { width: mmOn(k, 2.2), color: WHITE }, + }); + }); +} + +/** + * Stamp collage (reference 2, India): a checker band across the top of the + * page, two or three photos as an arch and stamps over its edge, the title + * in the display face under them (or the lockup asked for), the month in + * spaced capitals, and a round postmark turned beside the title. + */ +export function stampCollage(k: CoverKit): CoverDraft { + const { c, W, H, b, m } = k; + const draft: CoverDraft = { elements: [], groups: [] }; + const bandH = H * 0.34; + const band = shapeEl(k, 'rect', frameOf(-b, -b, W + b * 2, bandH + b), c.look.accent); + const scale = Math.round(k.rng.range(0.9, 1.3) * 100) / 100; + const paint = { + type: 'pattern' as const, + pattern: 'checker' as const, + fg: c.look.accent, + bg: c.look.paper, + scale, + angle: 0, + }; + draft.elements.push({ ...band, paint }); + draft.elements.push(...stampRow(k, k.photos.slice(0, 3), bandH + mmOn(k, 4))); + const titleTop = H * 0.62; + // The postmark stays above the bottom margin, turned: on a short page it is smaller. + const d = Math.min(W * 0.3, (H - m - titleTop + mmOn(k, 3)) * 0.92); + const titleW = W - m * 2 - d - mmOn(k, 8); + const ink = inkOn(c, c.look.paper); + const titleBox = frameOf(m, titleTop, titleW, H * 0.27); + if (!addLockup(k, draft, { box: titleBox, ground: c.look.paper }).length) { + if (k.title.words.trim()) { + const frame = frameOf(m, titleTop, titleW, H * 0.18); + draft.elements.push( + titleText(k, frame, { max: mmToPt(H * 0.15), color: ink, transform: 'upper', align: 'left', weight: 800 }), + ); + } + const line = k.words.dates || k.words.year || ''; + if (line) draft.elements.push(spacedLine(k, line, frameOf(m, titleTop + H * 0.19, titleW, mmOn(k, 8)), ink, 9)); + } + const turn = -k.rng.range(6, 14); + const box = frameOf(W - m - d, titleTop - mmOn(k, 3), d, d); + placeLockup(k, draft, { id: 'postmark', box, ground: c.look.paper, turn }); + return draft; +} diff --git a/shared/src/book/design/design-cover-graphic.ts b/shared/src/book/design/design-cover-graphic.ts new file mode 100644 index 000000000..fc553eb19 --- /dev/null +++ b/shared/src/book/design/design-cover-graphic.ts @@ -0,0 +1,184 @@ +import { luminance, parseHex, toHsl } from '../book-colour'; +import { bookStickerElement } from '../book-sticker'; +import type { BookFrame } from '../book.schema'; +import { mixHex } from '../theme/book-theme'; +import { CALM_ART, type BookDesignSticker } from './design-art'; +import { frameOf } from './design-compose'; +import { + addLockup, + clearOfTrim, + deepened, + inkOn, + keptTurn, + mmOn, + mmToPt, + printEl, + shapeEl, + spacedLine, + standOut, + titleText, + type CoverDraft, + type CoverKit, +} from './design-cover-kit'; +import { placeStickers, sharedArea, turnedBox } from './design-stickers'; + +/** + * The covers led by colour and drawing: a poster of two colour blocks with a + * heavy title and a turned inset photo among stickers, and a pastel postcard + * round a drawing of a landmark. + */ + +const WHITE = '#ffffff'; + +/** + * Poster pop (reference 2): the upper half of the page a block of the accent, + * the title in two heavy lines on it at about a third of the page height, a + * sun burst rising from the edge of the block behind a turned inset photo in + * a white border, the subtitle and the year on the paper below, and three to + * six stickers of the theme's motifs round the photo. + */ +export function posterPop(k: CoverKit): CoverDraft { + const { c, W, H, b, m } = k; + const draft: CoverDraft = { elements: [], groups: [] }; + const split = H * k.rng.range(0.5, 0.58); + // A lockup sets small lines on the block too, which a mid-tone accent would not carry. + const block = k.lockup ? deepened(c.look.accent) : c.look.accent; + const blockEl = shapeEl(k, 'rect', frameOf(-b, -b, W + b * 2, split + b), block); + const photo = k.photos[0]; + let photoBox: BookFrame | null = null; + if (photo) { + const pw = W * 0.44; + const wide = !!(photo.width && photo.height && photo.width > photo.height); + const ph = Math.min(H * 0.4, wide ? pw * 0.75 : pw * 1.2); + const turn = keptTurn(k, k.rng.sign() * k.rng.range(3, 6)); + const cx = W * 0.6 + k.rng.range(-0.04, 0.04) * W; + const cy = Math.min(split + H * 0.06, H - m - ph / 2 - mmOn(k, 4)); + const frame = frameOf(Math.min(cx - pw / 2, W - m - pw - 2), cy - ph / 2, pw, ph); + const burst = W * 0.62; + const burstFrame = clearOfTrim(k, frameOf(cx - burst / 2, cy - burst / 2, burst, burst), 0); + // Behind the block, so the rays rise from its edge and the words on the block keep it as their ground. + draft.elements.push(shapeEl(k, 'sunburst-24', burstFrame, standOut(c, c.look.paper, [block])), blockEl); + const print = printEl(k, photo, frame, { turn, border: { width: mmOn(k, 3), color: WHITE }, shadow: true }); + draft.elements.push(print); + photoBox = turnedBox(print.frame, print.rotation); + } else { + draft.elements.push(blockEl); + } + const zone = frameOf(m, m, W - m * 2, split - m - mmOn(k, 6)); + const titleZone = photoBox ? frameOf(zone.x, zone.y, zone.w, Math.min(zone.h, photoBox.y - zone.y - 3)) : zone; + const asked = addLockup(k, draft, { box: titleZone, ground: block }); + if (!asked.length && k.title.words.trim()) { + const max = mmToPt(titleZone.h / 2 / 0.95); + draft.elements.push( + titleText(k, titleZone, { + max, + color: inkOn(c, block), + transform: 'upper', + leading: 0.92, + weight: 900, + align: 'left', + }), + ); + } + // The lines on the paper keep left of the photo. + const left = photoBox ? Math.max(m + mmOn(k, 30), photoBox.x - 4) : W - m; + const ink = inkOn(c, c.look.paper); + const sub = k.sub.words.trim(); + if (sub && !asked.length) { + const text = spacedLine(k, sub, frameOf(m, split + mmOn(k, 7), left - m, mmOn(k, 8)), ink, 10); + draft.elements.push(k.sub.bound ? { ...text, binding: { source: 'journey.subtitle' } } : text); + } + const year = k.words.year ?? ''; + if (year) draft.elements.push(spacedLine(k, year, frameOf(m, H - m - mmOn(k, 8), left - m, mmOn(k, 8)), ink, 9)); + const count = (c.art ?? CALM_ART).bare ? 0 : k.rng.int(3, 6); + draft.elements.push( + ...placeStickers(c, draft.elements, { + count, + size: [0.12, 0.19], + sides: ['single'], + key: 'cover:poster-pop', + used: c.art?.used, + }), + ); + return draft; +} + +/** + * A landmark drawing of the journey: one with a tag that is the name of a + * stop it made (the city a stop is written as, "Paris" of "Paris, France"), + * or whose name the title says. None for a journey the library draws nothing + * of, since another place's landmark on the cover would be wrong: the cover + * photo takes its place. + */ +function landmarkFor(k: CoverKit): BookDesignSticker | undefined { + const landmarks = (k.c.art ?? CALM_ART).library.filter((s) => s.theme === 'landmarks'); + const stops = new Set(k.places.map((p) => p.split(',')[0]?.trim() ?? '')); + const title = ` ${k.title.words.toLowerCase()} `; + return landmarks.find( + (s) => + s.tags.some((t) => stops.has(t.toLowerCase())) || (!!s.tags[0] && title.includes(` ${s.tags[0].toLowerCase()} `)), + ); +} + +/** A pastel ground: the paper when it is a light tint, else the second accent washed out. */ +function pastelOf(k: CoverKit): string { + const paper = k.c.look.paper; + const rgb = parseHex(paper) ?? { r: 255, g: 255, b: 255 }; + const tinted = luminance(rgb) >= 0.7 && toHsl(rgb).s >= 0.08 && luminance(rgb) < 0.97; + return tinted ? paper : mixHex(k.c.look.theme.palette.accent2, WHITE, 0.72); +} + +/** + * Illustrated postcard (reference 5): a pastel page, a drawing of a landmark + * of the journey at its centre (the cover photo in an arch when the library + * draws none of its places), a small photo as a postage stamp in the corner + * with the cancel lines under it, and the title in the display serif with a + * script subtitle and the year under it (the title-script-year lockup, or the + * one asked for). + */ +export function postcard(k: CoverKit): CoverDraft { + const { c, W, H, m } = k; + const ground = pastelOf(k); + const draft: CoverDraft = { elements: [], groups: [], ...(ground === c.look.paper ? {} : { background: ground }) }; + const art = c.art ?? CALM_ART; + const landmark = art.bare ? undefined : landmarkFor(k); + const [photo, second] = k.photos; + const top = H * 0.08; + const tall = H * k.rng.range(0.42, 0.46); + let centre: BookFrame | null = null; + if (landmark) { + const w = Math.min(W - m * 2, (tall * landmark.box[0]) / Math.max(landmark.box[1], 1)); + const h = (w * landmark.box[1]) / Math.max(landmark.box[0], 1); + centre = frameOf((W - w) / 2, top + (tall - h) / 2, w, h); + draft.elements.push(bookStickerElement(landmark, centre, c.newId('k'))); + } else if (photo) { + const w = Math.min(tall * 0.78, W * 0.4); + const arch = printEl(k, photo, frameOf((W - w) / 2, top, w, tall), { + mask: 'arch-tall', + cutout: { width: mmOn(k, 2), color: WHITE }, + }); + draft.elements.push(arch); + centre = arch.frame; + } + const stampPhoto = landmark ? photo : second; + const sw = W * 0.2; + const stamp = frameOf(W - m - sw, m, sw, sw * 1.2); + if (stampPhoto) { + const turn = keptTurn(k, k.rng.sign() * k.rng.range(4, 8)); + const clear = !centre || sharedArea(turnedBox(stamp, turn), centre) === 0; + if (clear) { + const look = { + turn, + edge: { edge: 'perforated' as const, size: mmOn(k, 1.4) }, + border: { width: mmOn(k, 1.6), color: WHITE }, + }; + draft.elements.push(printEl(k, stampPhoto, stamp, look)); + } + } + // The cancel lines of a posted card, under the stamp's corner where they hide nothing. + const wave = frameOf(stamp.x + stamp.w - W * 0.2, stamp.y + stamp.h + mmOn(k, 3), W * 0.2, W * 0.045); + if (!centre || sharedArea(wave, centre) === 0) draft.elements.push(shapeEl(k, 'wave', wave, inkOn(c, ground))); + const y = top + tall + mmOn(k, 8); + addLockup(k, draft, { box: frameOf(m, y, W - m * 2, H - m - y), ground, fallback: 'title-script-year' }); + return draft; +} diff --git a/shared/src/book/design/design-cover-kit.ts b/shared/src/book/design/design-cover-kit.ts new file mode 100644 index 000000000..89ef2ba50 --- /dev/null +++ b/shared/src/book/design/design-cover-kit.ts @@ -0,0 +1,509 @@ +import { compareCodeUnits } from '../../common/compare'; +import type { AutoEntry, AutoPhoto } from '../auto/auto-types'; +import { contrastRatio, deltaE, readableInk } from '../book-colour'; +import { bookFontHasItalic, bookFontRole, nearestBookWeight } from '../book-fonts'; +import { buildBookLockup, findBookLockup, type BookLockupPalette, type BookLockupWords } from '../book-lockups'; +import { solveBookTextFit } from '../book-text-fit'; +import type { + BookElement, + BookFrame, + BookPhotoElement, + BookShapeElement, + BookShapeId, + BookTextElement, +} from '../book.schema'; +import { bookThemeFont, isBookFontFamily, mixHex, mutedOn } from '../theme/book-theme'; +import type { BookFontSlot, BookThemeFont } from '../theme/book-theme.schema'; +import { CALM_ART, withinTilt } from './design-art'; +import { bleedPage, frameOf, photoEl, type ComposeCtx } from './design-compose'; +import { titleWords, type CoverWords } from './design-cover-words'; +import { designRng, type DesignRng } from './design-random'; +import { turnedBox } from './design-stickers'; + +/** + * What every composed cover is built from (design-cover.ts dispatches to one + * composer per archetype): the page in millimetres, the words by the roles a + * lockup names them (the title and subtitle bound to the journey unless the + * person gave their own, the year, the month, the country), the photos the + * book holds with the cover photo first, the seed of every variation, and the + * parts a cover is laid with. Every colour comes from the book's look and + * reads on what it is set on; every face comes from the theme's slots. + */ + +export interface CoverMaterial { + /** The photos a cover may use, the cover photo first. */ + photos: readonly AutoPhoto[]; + /** The entries the book tells, in order, for the year, the month and the place. */ + entries: readonly AutoEntry[]; +} + +export interface CoverKit { + c: ComposeCtx; + rng: DesignRng; + title: { words: string; bound: boolean }; + sub: { words: string; bound: boolean }; + /** The words by lockup role: title, subtitle, year, dates, place, kicker. */ + words: BookLockupWords; + /** Where the journey went, as written in the journal: its stops, in lower case. */ + places: readonly string[]; + photos: readonly AutoPhoto[]; + /** The lockup the directives ask the title to be set as, in place of the archetype's own. */ + lockup: string | undefined; + /** The page: its width and height, the bleed, the margin and the safe area, in millimetres. */ + W: number; + H: number; + b: number; + m: number; + safe: number; +} + +/** The page a part is laid on: its context and its size, a cover's or a spread's. */ +type PageKit = Pick; + +/** The kit of a page of the book that is not the cover: the page size from the grid. */ +export function pageKit(c: ComposeCtx): PageKit { + return { c, W: c.grid.pageWidth, H: c.grid.pageHeight, b: c.grid.bleed }; +} + +/** A composed cover before it becomes a page: its elements back to front, its ground and its lockups. */ +export interface CoverDraft { + elements: BookElement[]; + /** The page's colour; the paper when left out. */ + background?: string; + /** The ids of each lockup's parts, made one group each. */ + groups: string[][]; +} + +const r2 = (v: number) => Math.round(v * 100) / 100; +export const mmToPt = (mm: number) => Math.round(((mm * 72) / 25.4) * 4) / 4; + +/** A date of the journey as an instant at its local midnight, as formatBookDate reads it. */ +const dayOf = (iso: string) => new Date(`${iso}T00:00:00`); + +/** The year or years the journey ran: "2026", or "2025/2026" across a new year; empty without dates. */ +function yearOf(dates: readonly string[]): string { + const first = dates[0]?.slice(0, 4); + const last = dates[dates.length - 1]?.slice(0, 4); + if (!first || !last) return ''; + return first === last ? first : `${first}/${last}`; +} + +/** The month the journey ran in ("May 2026"), or its year when it ran over several months. */ +function monthOf(dates: readonly string[], locale: string): string { + const first = dates[0]; + const last = dates[dates.length - 1]; + if (!first || !last) return ''; + if (first.slice(0, 7) !== last.slice(0, 7)) return yearOf(dates); + const at = dayOf(first); + return Number.isNaN(at.getTime()) ? '' : at.toLocaleDateString(locale, { month: 'long', year: 'numeric' }); +} + +/** A country's name in the book's language, or the code when the platform does not know it. */ +function countryName(code: string, locale: string): string { + try { + return new Intl.DisplayNames([locale], { type: 'region' }).of(code.toUpperCase()) ?? code; + } catch { + return code; + } +} + +/** The place a cover names: the one country the journey stayed in, else where it began. */ +function placeOf(entries: readonly AutoEntry[], locale: string): string { + const countries = [...new Set(entries.flatMap((e) => (e.country ? [e.country] : [])))]; + const [only] = countries; + if (only && countries.length === 1) return countryName(only, locale); + return entries.find((e) => e.location?.trim())?.location?.trim() ?? ''; +} + +/** The kit a cover is composed with. */ +export function coverKit(c: ComposeCtx, key: string, words: CoverWords, material: CoverMaterial): CoverKit { + const title = titleWords(words.ownTitle, words.title); + const sub = titleWords(words.ownSubtitle, words.subtitle ?? ''); + const dates = material.entries.flatMap((e) => (e.date ? [e.date] : [])).sort(compareCodeUnits); + const month = monthOf(dates, c.locale); + const lockupWords: BookLockupWords = { + title: title.words.trim(), + subtitle: sub.words.trim(), + year: yearOf(dates), + dates: month, + kicker: sub.words.trim() ? month : '', + place: placeOf(material.entries, c.locale), + }; + const { pageWidth: W, pageHeight: H, bleed: b, margin: m, safe } = c.grid; + const seed = (c.art ?? CALM_ART).seed; + const lockup = c.directives.cover?.lockup; + const places = [ + ...new Set(material.entries.flatMap((e) => (e.location?.trim() ? [e.location.trim().toLowerCase()] : []))), + ]; + const rng = designRng(seed, `cover:${key}`); + return { c, rng, title, sub, words: lockupWords, places, photos: material.photos, lockup, W, H, b, m, safe }; +} + +// ── colours ──────────────────────────────────────────────────────────────── + +/** The ink that reads on a ground: the book's own at 4.5:1, else near black or white. */ +export function inkOn(c: ComposeCtx, ground: string): string { + return contrastRatio(c.look.ink, ground) >= 4.5 ? c.look.ink : readableInk(ground); +} + +/** + * A ground small words can be set on: the colour itself when near black or + * white reads on it at 4.6:1, else the colour taken towards the dark until + * white does. A mid-tone accent reads under large type but not under a year + * or a date line in spaced capitals. + */ +export function deepened(colour: string): string { + let ground = colour; + for (let step = 0; step < 8 && contrastRatio(readableInk(ground), ground) < 4.6; step++) { + ground = mixHex(ground, '#000000', 0.12); + } + return ground; +} + +/** + * A colour of the book's palette that stands out on a ground, for a shape laid + * on it: the first accent plainly another colour (delta E over 18) and a shade + * lighter or darker, else the ink. Shapes only; words take `inkOn`. + */ +export function standOut(c: ComposeCtx, ground: string, skip: readonly string[] = []): string { + const p = c.look.theme.palette; + const tried = [c.look.accent, p.accent2, p.accent3].filter((h) => !skip.includes(h)); + return tried.find((h) => deltaE(h, ground) > 18 && contrastRatio(h, ground) >= 1.4) ?? inkOn(c, ground); +} + +/** + * A lockup's tones on a ground: the ground as its paper, an ink and a muted + * ink that read on it, and an accent that reads on it at 4.5:1, so the words + * a lockup sets in paper on an accent shape read just as well. + */ +function lockupTones(c: ComposeCtx, ground: string): BookLockupPalette { + const ink = inkOn(c, ground); + const p = c.look.theme.palette; + const accent = [c.look.accent, p.accent3, p.accent2].find((h) => contrastRatio(h, ground) >= 4.5) ?? ink; + return { ink, accent, paper: ground, muted: mutedOn(ground, ink) }; +} + +// ── type ─────────────────────────────────────────────────────────────────── + +/** The theme's face for a slot, when this build sets it. */ +function slotFace(c: ComposeCtx, slot: BookFontSlot): BookThemeFont | null { + const face = bookThemeFont(c.look.theme, slot); + return face && isBookFontFamily(face.font) ? face : null; +} + +/** The face a slot is set in: the theme's own, else its heading face (a theme without a script sets that line in it). */ +function faceFor(c: ComposeCtx, slot: BookFontSlot): BookThemeFont | null { + return slotFace(c, slot) ?? slotFace(c, 'heading'); +} + +/** + * The slot a lockup's text is set in: the display face for its title, a + * script for a script, the body face for running words, the heading face for + * every other line (a subtitle in a display face included, so the title + * stays the one display line); a mono line keeps its face. + */ +function slotOf(font: string): BookFontSlot | null { + const role = bookFontRole(font); + if (role === 'mono') return null; + return role === 'display' ? 'heading' : role; +} + +/** Under the title, no line of a lockup is set at more than this share of its size. */ +const UNDER_TITLE = 1 / 2.2; + +/** A lockup's other lines kept under the title: one set larger than the share is fitted at it. */ +function underTitle(made: BookElement[], title: string | undefined): BookElement[] { + const top = made.find((e): e is BookTextElement => e.kind === 'text' && !!title && e.text.trim() === title); + if (!top) return made; + const most = Math.max(7, Math.floor(top.size * UNDER_TITLE * 4) / 4); + return made.map((e) => { + if (e === top || e.kind !== 'text' || e.size <= most || bookFontRole(e.font) === 'script') return e; + const capped = { ...e, fitMax: most }; + return { ...capped, size: solveBookTextFit(capped, most) }; + }); +} + +/** A text set in a face, at a weight and slant the family has, refitted to its frame. */ +function setIn(el: BookTextElement, face: BookThemeFont | null): BookTextElement { + if (!face) return el; + const font = face.font as BookTextElement['font']; + const weight = nearestBookWeight(font, face.weight ?? el.weight) as BookTextElement['weight']; + const italic = (face.italic ?? el.italic) && bookFontHasItalic(font, weight); + const next = { ...el, font, weight, italic }; + return el.fit === 'shrink' ? { ...next, size: solveBookTextFit(next, el.fitMax ?? el.size) } : next; +} + +/** How the texts of a placed lockup are bound and which one is set in the display face. */ +interface LockupBinding { + /** The words set in the theme's display face: a cover's title. */ + display?: string; + /** The binding a text of these words keeps, or null for none. */ + bind?: (words: string) => BookTextElement['binding']; +} + +/** + * A lockup in a box on a ground, turned by `turn` degrees, its texts in the + * theme's faces (each in the slot its own face stands for) and bound as + * `binding` says; empty when none of its texts has words. + */ +export function lockupOn( + c: ComposeCtx, + at: { id: string; box: BookFrame; ground: string; words: BookLockupWords; turn?: number; photoId?: number | null }, + binding: LockupBinding = {}, +): BookElement[] { + const lockup = findBookLockup(at.id); + if (!lockup) return []; + const turned = at.turn ? { ...lockup, rotation: (lockup.rotation ?? 0) + at.turn } : lockup; + const made = buildBookLockup(turned, at.box, { + words: at.words, + palette: lockupTones(c, at.ground), + photoId: at.photoId ?? null, + newId: c.newId, + }); + const set = made.map((el) => { + if (el.kind !== 'text') return el; + const words = el.text.trim(); + const slot: BookFontSlot | null = binding.display && words === binding.display ? 'display' : slotOf(el.font); + const faced = setIn(el, slot ? faceFor(c, slot) : null); + const bound = binding.bind?.(words) ?? null; + return bound ? { ...faced, binding: bound } : faced; + }); + return underTitle(set, binding.display); +} + +/** A cover's lockup: the title in the display face, the title and subtitle bound to the journey when they are its words. */ +function coverLockup(k: CoverKit, id: string, box: BookFrame, ground: string, turn = 0): BookElement[] { + const title = k.title.words.trim(); + const sub = k.sub.words.trim(); + const bind = (words: string): BookTextElement['binding'] => { + if (title && words === title) return k.title.bound ? { source: 'journey.title' } : null; + return sub && words === sub && k.sub.bound ? { source: 'journey.subtitle' } : null; + }; + const at = { id, box, ground, words: k.words, turn, photoId: k.photos[0]?.photoId ?? null }; + return lockupOn(k.c, at, { display: title, bind }); +} + +/** Parts laid on a draft, made one group when they are several. */ +function laid(draft: CoverDraft, made: BookElement[]): BookElement[] { + draft.elements.push(...made); + if (made.length > 1) draft.groups.push(made.map((e) => e.id)); + return made; +} + +/** + * The lockup the directives ask the title to be set as, in a box: its parts + * when it sets the cover's title, else none, and the notes say why (a quote + * or a caption lockup has no place for a title). + */ +export function askedLockup(k: CoverKit, box: BookFrame, ground: string, turn = 0): BookElement[] { + if (!k.lockup) return []; + const made = coverLockup(k, k.lockup, box, ground, turn); + const title = k.title.words.trim(); + if (made.length && (!title || made.some((e) => e.kind === 'text' && e.text.trim() === title))) return made; + k.c.notes.push(`the lockup ${k.lockup} has no place for the cover's title, so the cover sets the title its own way`); + return []; +} + +/** Whether a lockup brings an ornament of its own (a ring, a disc, a sun, a band), not hairlines alone. */ +export function lockupOrnamented(id: string): boolean { + return !!findBookLockup(id)?.parts.some((p) => p.kind === 'shape' && Math.min(p.w, p.h) >= 3); +} + +/** The asked lockup laid on a draft as one group, else the `fallback` lockup; the parts laid, none when neither sets words. */ +export function addLockup( + k: CoverKit, + draft: CoverDraft, + at: { box: BookFrame; ground: string; turn?: number; fallback?: string }, +): BookElement[] { + const asked = askedLockup(k, at.box, at.ground, at.turn ?? 0); + if (asked.length || !at.fallback) return laid(draft, asked); + return laid(draft, coverLockup(k, at.fallback, at.box, at.ground, at.turn ?? 0)); +} + +/** A lockup of the archetype's own, whatever was asked for the title (a postmark beside it). */ +export function placeLockup( + k: CoverKit, + draft: CoverDraft, + at: { id: string; box: BookFrame; ground: string; turn?: number }, +): BookElement[] { + return laid(draft, coverLockup(k, at.id, at.box, at.ground, at.turn ?? 0)); +} + +interface CoverTextOpts { + slot: BookFontSlot; + /** The largest size, in points; the text is fitted below it. */ + max: number; + color: string; + align?: BookTextElement['align']; + weight?: BookTextElement['weight']; + tracking?: number; + leading?: number; + transform?: BookTextElement['transform']; + rotation?: number; + binding?: BookTextElement['binding']; + outline?: BookTextElement['outline']; +} + +/** One text of a cover in a theme face, as large as its frame holds up to `max`. */ +export function coverText(k: PageKit, words: string, frame: BookFrame, o: CoverTextOpts): BookTextElement { + const face = faceFor(k.c, o.slot); + const font = (face?.font ?? k.c.look.heading) as BookTextElement['font']; + const el: BookTextElement = { + id: k.c.newId('t'), + kind: 'text', + frame: frameOf(frame.x, frame.y, frame.w, frame.h), + rotation: o.rotation ?? 0, + opacity: 1, + locked: false, + text: words, + font, + size: o.max, + weight: nearestBookWeight(font, o.weight ?? face?.weight ?? 400) as BookTextElement['weight'], + italic: false, + align: o.align ?? 'center', + leading: o.leading ?? 1.05, + tracking: o.tracking ?? 0, + color: o.color, + binding: o.binding ?? null, + overridden: false, + vAlign: 'middle', + fit: 'shrink', + fitMax: o.max, + ...(o.transform ? { transform: o.transform } : {}), + ...(o.outline ? { outline: o.outline } : {}), + }; + return { ...el, size: solveBookTextFit(el, o.max) }; +} + +/** The cover title as a text of its own, bound to the journey's unless the person gave theirs. */ +export function titleText(k: CoverKit, frame: BookFrame, o: Omit): BookTextElement { + const binding = k.title.bound ? { source: 'journey.title' as const } : null; + return coverText(k, k.title.words.trim(), frame, { ...o, slot: 'display', binding }); +} + +/** + * A short line of spaced capitals (a year, a month, a subtitle), in the + * heading face, at `max` points times `type.scale` as far as its frame holds. + */ +export function spacedLine(k: PageKit, words: string, frame: BookFrame, color: string, max = 9): BookTextElement { + const size = Math.round(max * (k.c.directives.type?.scale ?? 1) * 4) / 4; + return coverText(k, words, frame, { + slot: 'heading', + max: size, + color, + weight: 600, + tracking: 0.3, + transform: 'upper', + }); +} + +// ── shapes and photos ────────────────────────────────────────────────────── + +/** A filled shape, unturned unless said, with no outline. */ +export function shapeEl( + k: PageKit, + shape: BookShapeId, + frame: BookFrame, + fill: string, + rotation = 0, +): BookShapeElement { + return { + id: k.c.newId('s'), + kind: 'shape', + frame: frameOf(frame.x, frame.y, frame.w, frame.h), + rotation: r2(rotation), + opacity: 1, + locked: false, + shape, + fill, + gradient: 'none', + stroke: null, + strokeWidth: 0, + strokeStyle: 'solid', + radius: 0, + }; +} + +/** Where a span starts so that neither of its ends stops a sliver short of the trim or between trim and bleed. */ +function settle(lo: number, span: number, extent: number, bleed: number): number { + let start = lo; + if (start > -(bleed + 1) && start < 2) start = start < 0.5 ? -(bleed + 1) : 2; + const end = start + span; + if (end < extent + bleed + 1 && end > extent - 2) { + const target = end > extent - 0.5 ? extent + bleed + 1 : extent - 2; + start = target - span; + } + return start; +} + +/** + * A box, turned by `rotation`, moved so that every edge is either well inside + * the page (2 mm or more) or runs past the bleed: never one that stops a + * sliver short of the trim or between the trim and the bleed edge, which a + * print shop would cut badly. + */ +export function clearOfTrim(k: PageKit, frame: BookFrame, rotation = 0): BookFrame { + const turned = turnedBox(frame, rotation); + const dx = (turned.w - frame.w) / 2; + const dy = (turned.h - frame.h) / 2; + const x = settle(frame.x - dx, turned.w, k.W, k.b) + dx; + const y = settle(frame.y - dy, turned.h, k.H, k.b) + dy; + return frameOf(x, y, frame.w, frame.h); +} + +/** The cover photo to the bleed of the page, as a cover takes it. */ +export function bleedPhoto(k: CoverKit, photo: AutoPhoto): BookPhotoElement { + return photoEl(k.c, photo, bleedPage(k.c, 'single'), { bleed: { side: 'single' } }); +} + +interface PrintLook { + turn?: number; + border?: { width: number; color: string }; + shadow?: boolean; + mask?: BookShapeId; + edge?: { edge: NonNullable; size: number }; + cutout?: { width: number; color: string }; +} + +/** A photo laid on the cover as a print: framed at print resolution, turned, with the look asked for. */ +export function printEl(k: PageKit, photo: AutoPhoto, frame: BookFrame, look: PrintLook = {}): BookPhotoElement { + const el = photoEl(k.c, photo, frame, { turn: look.turn ?? 0 }); + return { + ...el, + frameStyle: 'none', + ...(look.border ? { border: look.border } : {}), + ...(look.shadow ? { shadow: { dx: r2(k.W / 160), dy: r2(k.W / 160), color: k.c.look.ink, alpha: 0.22 } } : {}), + ...(look.mask ? { mask: look.mask } : {}), + ...(look.edge ? { edge: look.edge.edge, edgeSize: look.edge.size } : {}), + ...(look.cutout ? { cutout: look.cutout } : {}), + }; +} + +/** A turn the archetype asks for, kept to the most the book's energy takes: a calm book's prints lie nearly straight. */ +export function keptTurn(k: PageKit, turn: number): number { + return withinTilt(k.c.art ?? CALM_ART, turn); +} + +/** Millimetres scaled from a 210 mm page to this one. */ +export const mmOn = (k: PageKit, v: number) => (v * k.W) / 210; + +/** + * How wide a round badge laid at a point over photos may be, from `most` down + * to `least`, so that no photo's detail (its focal point) is hidden under it; + * `least` when even that hides one. + */ +export function badgeSpan( + photos: readonly BookPhotoElement[], + at: [number, number], + most: number, + least: number, +): number { + const hidden = (d: number) => + photos.some((p) => { + const fx = p.frame.x + p.focalX * p.frame.w; + const fy = p.frame.y + p.focalY * p.frame.h; + return Math.abs(fx - at[0]) <= d / 2 + 1 && Math.abs(fy - at[1]) <= d / 2 + 1; + }); + for (let d = most; d > least; d -= (most - least) / 6) if (!hidden(d)) return d; + return least; +} diff --git a/shared/src/book/design/design-cover-photo.ts b/shared/src/book/design/design-cover-photo.ts new file mode 100644 index 000000000..9ea2a0766 --- /dev/null +++ b/shared/src/book/design/design-cover-photo.ts @@ -0,0 +1,337 @@ +import { luminance, parseHex, toHsl } from '../book-colour'; +import type { BookElement, BookFrame, BookShapeId } from '../book.schema'; +import { mixHex } from '../theme/book-theme'; +import { frameOf } from './design-compose'; +import { + addLockup, + askedLockup, + bleedPhoto, + clearOfTrim, + coverText, + deepened, + inkOn, + keptTurn, + lockupOrnamented, + mmOn, + mmToPt, + printEl, + shapeEl, + spacedLine, + standOut, + titleText, + type CoverDraft, + type CoverKit, +} from './design-cover-kit'; +import { sharedArea, turnedBox } from './design-stickers'; + +/** + * The covers led by one photograph: a spine band beside a photo to the bleed, + * a minimal serif page under a large photo with a hairline, and a cut-out page + * of paper shapes round a bordered portrait. + */ + +const WHITE = '#ffffff'; +const OUTLINE_SHARE = 0.07; +const ptMm = (pt: number) => (pt * 25.4) / 72; + +/** The band keeps off the picture's detail: on the side away from it, by the seed when the detail is central. */ +function bandOnLeft(focalX: number, k: CoverKit): boolean { + if (focalX < 0.4) return false; + return focalX > 0.6 || k.rng.chance(0.7); +} + +/** + * Spine band (reference 1): the photo to the bleed, a band of 14 to 18 % of the + * width on the spine side (on the other when the picture's detail is there, + * and now and then by the seed), solid or woven, the title in spaced capitals + * along it, the year at its foot, a script subtitle on the photo, outlined so + * it reads on any part of it. + */ +export function spineBand(k: CoverKit): CoverDraft { + const { c, W, H, b, m, safe } = k; + const draft: CoverDraft = { elements: [], groups: [] }; + const photo = k.photos[0]; + const bled = photo ? bleedPhoto(k, photo) : null; + if (bled) draft.elements.push(bled); + const onLeft = bandOnLeft(bled?.focalX ?? 0.5, k); + const bw = W * k.rng.range(0.14, 0.18); + const fill = deepened(photo ? c.look.accent : standOut(c, c.look.paper)); + const band = shapeEl( + k, + 'rect', + onLeft ? frameOf(-b, -b, bw + b, H + b * 2) : frameOf(W - bw, -b, bw + b, H + b * 2), + fill, + ); + const weave = k.rng.pick(['gingham', 'stripes', 'pinstripe'] as const); + const woven = weave && k.rng.chance(0.5); + draft.elements.push( + woven + ? { + ...band, + paint: { type: 'pattern', pattern: weave, fg: mixHex(fill, WHITE, 0.18), bg: fill, scale: 0.8, angle: 0 }, + } + : band, + ); + const ink = inkOn(c, fill); + const bandX = onLeft ? 0 : W - bw; + // The band's own width, kept off its edges and out of the safe margin. + const innerX = Math.max(bandX + 2, safe + 1); + const innerW = Math.min(bandX + bw - 2, W - safe - 1) - innerX; + const year = k.words.year ?? ''; + if (year) draft.elements.push(spacedLine(k, year, frameOf(innerX, H - m - mmOn(k, 7), innerW, mmOn(k, 7)), ink, 8)); + // A lockup is set on a paper card over the photo: the band is too narrow for one. + const card = frameOf(onLeft ? bw + m : m, H * 0.6, W - bw - m * 2, H * 0.4 - m); + const asked = askedLockup(k, frameOf(card.x + 3, card.y + 3, card.w - 6, card.h - 6), c.look.paper); + if (asked.length) { + draft.elements.push(shapeEl(k, 'rect', card, c.look.paper), ...asked); + if (asked.length > 1) draft.groups.push(asked.map((e) => e.id)); + return draft; + } + const len = H * 0.74; + const thick = bw * 0.56; + const cx = bandX + bw / 2; + const along = frameOf(cx - len / 2, H * 0.46 - thick / 2, len, thick); + let lead = 0; + if (k.title.words.trim()) { + const max = mmToPt(thick * 0.8); + const turn = onLeft ? -90 : 90; + const title = titleText(k, along, { + max, + color: ink, + rotation: turn, + transform: 'upper', + tracking: 0.18, + weight: 700, + }); + draft.elements.push(title); + lead = title.size; + } + const words = k.sub.words.trim(); + if (words) { + const x = onLeft ? bw + m : m; + const frame = frameOf(x, H * 0.7, W - bw - m * 2, H * 0.16); + // A theme without a script sets the line in its heading face, which the band's title must still lead. + const under = lead ? Math.max(8, Math.floor((lead / 2.2) * 4) / 4) : 14; + const max = c.look.theme.fonts.script ? 30 : under; + const set = coverText(k, words, frame, { slot: 'script', max, color: photo ? WHITE : c.look.ink }); + const binding = k.sub.bound ? { source: 'journey.subtitle' as const } : null; + const outline = photo ? { color: '#1a1a1a', width: Math.round(ptMm(set.size) * OUTLINE_SHARE * 100) / 100 } : null; + draft.elements.push({ ...set, binding, ...(outline ? { outline } : {}) }); + } + return draft; +} + +/** + * Minimal serif (reference 7): a white page, a large photo inside the margins + * with a hairline round it, and under it a very large thin serif title as + * wide as the page holds it, the subtitle, a sun between two hairlines and + * the year. A lockup asked for takes the title's place; one that brings no + * ornament of its own keeps the sun under it, so the page is never bare. A + * landscape page gives the title more of its height and the photo less. + */ +export function minimalSerif(k: CoverKit): CoverDraft { + const { c, W, H, m } = k; + const light = luminance(parseHex(c.look.paper) ?? { r: 255, g: 255, b: 255 }) > 0.8; + const ground = light ? c.look.paper : WHITE; + const draft: CoverDraft = { elements: [], groups: [], ...(light ? {} : { background: WHITE }) }; + const photo = k.photos[0]; + const wide = W > H * 1.15; + const ph = H * (wide ? k.rng.range(0.38, 0.42) : k.rng.range(0.5, 0.56)); + if (photo) { + const border = { width: Math.max(0.3, mmOn(k, 0.3)), color: inkOn(c, ground) }; + draft.elements.push(printEl(k, photo, frameOf(m, m, W - m * 2, ph), { border })); + } + const top = photo ? m + ph + mmOn(k, 6) : H * 0.25; + const box = frameOf(m, top, W - m * 2, photo ? H - m - top : H * 0.5); + if (k.lockup) { + const plain = !lockupOrnamented(k.lockup); + const foot = plain ? Math.min(mmOn(k, 10), box.h * 0.2) : 0; + const asked = addLockup(k, draft, { box: frameOf(box.x, box.y, box.w, box.h - foot), ground }); + if (asked.length) { + if (plain) sunRule(k, draft, frameOf(box.x, box.y + box.h - foot, box.w, foot), ground); + return draft; + } + } + serifStack(k, draft, box, ground); + return draft; +} + +/** A sun between two hairlines, centred in a band, made one group. */ +function sunRule(k: CoverKit, draft: CoverDraft, band: BookFrame, ground: string): void { + const d = Math.min(mmOn(k, 6), band.h * 0.8); + const cx = band.x + band.w / 2; + const cy = band.y + band.h / 2; + const bar = band.w * 0.22; + const hair = Math.max(0.3, mmOn(k, 0.3)); + const ink = inkOn(k.c, ground); + const parts = [ + shapeEl(k, 'rect', frameOf(cx - d / 2 - mmOn(k, 2) - bar, cy - hair / 2, bar, hair), ink), + shapeEl(k, 'sun', frameOf(cx - d / 2, cy - d / 2, d, d), standOut(k.c, ground)), + shapeEl(k, 'rect', frameOf(cx + d / 2 + mmOn(k, 2), cy - hair / 2, bar, hair), ink), + ]; + draft.elements.push(...parts); + draft.groups.push(parts.map((e) => e.id)); +} + +/** + * The archetype's own words in a box: the title in the thin display face as + * large as the box's width and height hold it, the subtitle under it, the + * sun between hairlines and the year at the foot, the fixed lines made + * smaller together when the box is short. + */ +function serifStack(k: CoverKit, draft: CoverDraft, box: BookFrame, ground: string): void { + const ink = inkOn(k.c, ground); + const sub = k.sub.words.trim(); + const year = k.words.year ?? ''; + const lines = { sub: sub ? mmOn(k, 9) : 0, rule: mmOn(k, 11), year: year ? mmOn(k, 8) : 0 }; + const fixed = lines.sub + lines.rule + lines.year; + const titleH = Math.max(box.h * 0.4, box.h - fixed); + const f = Math.min(1, (box.h - titleH) / Math.max(fixed, 1)); + let y = box.y; + if (k.title.words.trim()) { + const frame = frameOf(box.x, y, box.w, titleH); + // In the display face at its own weight, the thin serif a minimal theme gives it; the theme links it so. + draft.elements.push(titleText(k, frame, { max: mmToPt(titleH * 0.8), color: ink, leading: 1 })); + } + y += titleH; + if (sub) { + const text = coverText(k, sub, frameOf(box.x, y + mmOn(k, 2) * f, box.w, mmOn(k, 6) * f), { + slot: 'heading', + max: 11, + color: ink, + tracking: 0.04, + }); + draft.elements.push(k.sub.bound ? { ...text, binding: { source: 'journey.subtitle' } } : text); + y += lines.sub * f; + } + sunRule(k, draft, frameOf(box.x, y, box.w, lines.rule * f), ground); + y += lines.rule * f; + if (year) draft.elements.push(spacedLine(k, year, frameOf(box.x, y + mmOn(k, 1) * f, box.w, mmOn(k, 6) * f), ink, 8)); +} + +/** Saturated enough to be the page of a cut-out cover. */ +function saturated(hex: string): boolean { + const rgb = parseHex(hex); + if (!rgb) return false; + const hsl = toHsl(rgb); + return hsl.s >= 0.35 && hsl.l >= 0.2 && hsl.l <= 0.65; +} + +/** The paper cut-outs a cut-out cover is made of. */ +const CUTOUTS: readonly BookShapeId[] = [ + 'leaf-monstera', + 'leaf-palm', + 'leaf-long', + 'coral-1', + 'coral-2', + 'seaweed', + 'organic-1', + 'organic-2', + 'organic-3', + 'star-organic', + 'sun-cutout', +]; + +/** + * Cut-out (reference 4): a saturated page, a spaced date line over a condensed + * title at about a quarter of its height, paper cut-outs in two tones round a + * portrait photo in a thick white border, a small one over its edge, and the + * place in small capitals under it. + */ +export function cutout(k: CoverKit): CoverDraft { + const { c, W, H, m } = k; + const p = c.look.theme.palette; + const ground = deepened([c.look.paper, c.look.accent, p.accent3, p.accent2].find(saturated) ?? c.look.accent); + const draft: CoverDraft = { elements: [], groups: [], ...(ground === c.look.paper ? {} : { background: ground }) }; + const ink = inkOn(c, ground); + const texts: BookElement[] = []; + const zone = frameOf(m, m, W - m * 2, H * 0.34 - m); + const asked = askedLockup(k, zone, ground); + if (asked.length) { + texts.push(...asked); + if (asked.length > 1) draft.groups.push(asked.map((e) => e.id)); + } else { + const line = k.words.dates || k.words.year || ''; + if (line) texts.push(spacedLine(k, line, frameOf(m, m, W - m * 2, mmOn(k, 8)), ink, 9)); + const titleFrame = frameOf(m, m + mmOn(k, 11), W - m * 2, H * 0.25); + if (k.title.words.trim()) { + const look = { + max: mmToPt(H * 0.2), + color: ink, + transform: 'upper' as const, + tracking: 0.04, + weight: 800 as const, + }; + texts.push(titleText(k, titleFrame, { ...look, leading: 0.95 })); + } + } + const photo = k.photos[0]; + const pw = W * 0.48; + const ph = H * 0.43; + const turn = keptTurn(k, k.rng.range(-3, 3)); + const at = frameOf((W - pw) / 2 + k.rng.range(-0.04, 0.04) * W, H * 0.4, pw, ph); + const print = photo ? printEl(k, photo, at, { turn, border: { width: mmOn(k, 4.5), color: WHITE } }) : null; + const below = turnedBox(print?.frame ?? at, turn); + const place = k.words.place ?? ''; + if (place && below.y + below.h + mmOn(k, 11) < H - m) { + const w = below.w * 0.5; + texts.push( + spacedLine(k, place, frameOf(below.x + (below.w - w) / 2, below.y + below.h + mmOn(k, 4), w, mmOn(k, 6)), ink, 7), + ); + } + const first = standOut(c, ground); + const tones = [first, standOut(c, ground, [first])]; + const keepClear = texts.map((t) => turnedBox(t.frame, t.rotation)); + const shapes = cutoutShapes(k, print?.frame ?? at, tones, keepClear, print); + draft.elements.push(...shapes.behind); + if (print) draft.elements.push(print); + draft.elements.push(...shapes.front, ...texts); + return draft; +} + +/** + * The cut-outs round the photo: up to five behind it, at its sides and lower + * corners and partly off the page, one small one over its edge that keeps off + * the picture's detail, none under the words. + */ +function cutoutShapes( + k: CoverKit, + photo: BookFrame, + tones: readonly string[], + keepClear: readonly BookFrame[], + print: { frame: BookFrame; focalX: number; focalY: number } | null, +): { behind: BookElement[]; front: BookElement[] } { + const ids = k.rng.shuffle(CUTOUTS); + const cx = photo.x + photo.w / 2; + const anchors: [number, number][] = [ + [photo.x - photo.w * 0.2, photo.y + photo.h * 0.45], + [photo.x + photo.w * 1.2, photo.y + photo.h * 0.55], + [photo.x - photo.w * 0.35, photo.y + photo.h * 0.98], + [photo.x + photo.w * 1.35, photo.y + photo.h * 1.0], + [cx - photo.w * 0.62, photo.y + photo.h * 0.08], + ]; + const clashes = (box: BookFrame) => keepClear.some((t) => sharedArea(t, box) > 0); + const behind: BookElement[] = []; + anchors.forEach(([x, y], i) => { + const size = k.W * k.rng.range(0.22, 0.32); + const turn = k.rng.range(-40, 40); + const frame = clearOfTrim(k, frameOf(x - size / 2, y - size / 2, size, size), turn); + if (clashes(turnedBox(frame, turn))) return; + behind.push(shapeEl(k, ids[i % ids.length] as BookShapeId, frame, tones[i % 2] as string, turn)); + }); + const front: BookElement[] = []; + if (print) { + const size = k.W * 0.16; + const frame = frameOf(photo.x - size * 0.45, photo.y + photo.h - size * 0.6, size, size); + const focal: [number, number] = [ + print.frame.x + print.focalX * print.frame.w, + print.frame.y + print.focalY * print.frame.h, + ]; + const box = turnedBox(frame, 20); + const onDetail = focal[0] >= box.x && focal[0] <= box.x + box.w && focal[1] >= box.y && focal[1] <= box.y + box.h; + if (!onDetail && !clashes(box)) { + front.push(shapeEl(k, ids[anchors.length % ids.length] as BookShapeId, frame, tones[1] as string, 20)); + } + } + return { behind, front }; +} diff --git a/shared/src/book/design/design-cover-words.ts b/shared/src/book/design/design-cover-words.ts new file mode 100644 index 000000000..4c09a6e83 --- /dev/null +++ b/shared/src/book/design/design-cover-words.ts @@ -0,0 +1,13 @@ +/** The words a cover and the pages around the chapters are set from, and whose they are. */ + +export interface CoverWords { + title: string; + subtitle: string | null; + ownTitle?: string; + ownSubtitle?: string; +} + +/** A title the journey owns, bound to it, or the person's own words, not bound. */ +export function titleWords(own: string | undefined, journey: string): { words: string; bound: boolean } { + return own !== undefined && own.trim() ? { words: own, bound: false } : { words: journey, bound: true }; +} diff --git a/shared/src/book/design/design-cover.spec.ts b/shared/src/book/design/design-cover.spec.ts new file mode 100644 index 000000000..f4da34652 --- /dev/null +++ b/shared/src/book/design/design-cover.spec.ts @@ -0,0 +1,283 @@ +import { BOOK_COVER_ARCHETYPES, type BookCoverArchetype } from '../book-archetypes'; +import { bookSpreadSchema, type BookElement, type BookSpread } from '../book.schema'; +import { pageFindings } from '../describe-spread'; +import { BOOK_STICKERS } from '../stickers/sticker-catalogue'; +import { designBook, type BookDesignInput, type BookDesignResult } from './design-book'; +import type { BookDesignDirectivesInput } from './design-directives.schema'; +import { turnedBox } from './design-stickers'; + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** + * The seven composed covers (design-cover.ts) on the fixture journeys: each + * a valid page, its words inside the page, nothing left for the repair to + * flag, its archetype's signature there, the same for one seed and another + * for another seed, and the cover of a design without an archetype exactly + * the engine's own. + */ + +type Name = 'rich' | 'sparse' | 'nostats'; +const here = (file: string) => fileURLToPath(new URL(file, import.meta.url)); +const fixtures = JSON.parse(readFileSync(here('./design-book.fixtures.json'), 'utf8')) as Record< + Name, + { input: BookDesignInput } +>; + +/** The journey with ids minted by a counter and the sticker library at hand. */ +function input(name: Name, stickers = true): BookDesignInput { + let n = 0; + return { ...fixtures[name].input, newId: (p) => `${p}-${++n}`, ...(stickers ? { stickers: BOOK_STICKERS } : {}) }; +} + +function design(name: Name, directives: BookDesignDirectivesInput, stickers = true): BookDesignResult { + return designBook(null, input(name, stickers), directives); +} + +const coverOf = (r: BookDesignResult) => r.doc.spreads[0] as BookSpread; +const archetype = (a: BookCoverArchetype, more: BookDesignDirectivesInput = {}): BookDesignDirectivesInput => ({ + style: 'poster-pop', + brief: { energy: 'lively' }, + ...more, + cover: { ...more.cover, archetype: a }, +}); + +const kinds = (s: BookSpread, kind: BookElement['kind']) => s.elements.filter((e) => e.kind === kind); +const shapes = (s: BookSpread) => s.elements.flatMap((e) => (e.kind === 'shape' ? [e] : [])); +const photos = (s: BookSpread) => s.elements.flatMap((e) => (e.kind === 'photo' ? [e] : [])); +const texts = (s: BookSpread) => s.elements.flatMap((e) => (e.kind === 'text' ? [e] : [])); + +/** What makes each archetype that archetype, read off its cover. */ +const SIGNATURE: Record boolean> = { + 'spine-band': (s, W, H) => + shapes(s).some((e) => e.frame.h >= H && e.frame.w >= W * 0.14 && e.frame.w <= W * 0.18 + 4) && + texts(s).some((t) => Math.abs(t.rotation) === 90 && t.binding?.source === 'journey.title'), + 'poster-pop': (s, W) => + shapes(s).some((e) => e.shape === 'sunburst-24') && + shapes(s).some((e) => e.shape === 'rect' && e.frame.w >= W) && + photos(s).some((p) => Math.abs(p.rotation) >= 3 && Math.abs(p.rotation) <= 6 && !!p.border), + cutout: (s) => + shapes(s).filter((e) => /^(leaf|coral|seaweed|organic|star-organic|sun-cutout)/.test(e.shape)).length >= 2 && + photos(s).some((p) => (p.border?.width ?? 0) >= 4), + postcard: (s) => + (kinds(s, 'sticker').length > 0 || photos(s).some((p) => p.mask === 'arch-tall')) && + shapes(s).some((e) => e.shape === 'wave'), + 'grid-badge': (s, W) => + photos(s).filter((p) => p.frame.w >= W * 0.4 && p.rotation === 0).length >= 3 && + shapes(s).some((e) => e.shape === 'ellipse' && e.fill !== null && Math.abs(e.frame.x + e.frame.w / 2 - W / 2) < 1), + 'minimal-serif': (s) => + photos(s).some((p) => !!p.border && p.border.width <= 0.5) && shapes(s).some((e) => e.shape === 'sun'), + 'stamp-collage': (s) => + shapes(s).some((e) => e.paint?.type === 'pattern' && e.paint.pattern === 'checker') && + photos(s).some((p) => p.mask === 'arch-tall') && + photos(s).some((p) => p.edge === 'perforated') && + shapes(s).some((e) => e.shape === 'ellipse' && e.fill === null), +}; + +/** Every word on a cover lies on the page, turned with its frame; every other element at least touches it. */ +function onPage(s: BookSpread, W: number, H: number): string[] { + return s.elements.flatMap((e) => { + const b = turnedBox(e.frame, e.rotation); + const touches = b.x < W && b.y < H && b.x + b.w > 0 && b.y + b.h > 0; + const within = b.x >= 0 && b.y >= 0 && b.x + b.w <= W && b.y + b.h <= H; + return (e.kind === 'text' ? within : touches) ? [] : [e.id]; + }); +} + +describe('the composed covers', () => { + for (const a of BOOK_COVER_ARCHETYPES) { + it(`DSNCOV-001: composes ${a} as a valid cover with its signature, its words on the page and no fault left`, () => { + for (const name of ['rich', 'sparse', 'nostats'] as const) { + const r = design(name, archetype(a)); + const cover = coverOf(r); + const { pageWidth: W, pageHeight: H } = r.doc.page; + expect(cover.role).toBe('cover'); + expect(bookSpreadSchema.safeParse(cover).success).toBe(true); + expect({ name, off: onPage(cover, W, H) }).toEqual({ name, off: [] }); + const faults = pageFindings(cover, r.doc.page, input(name).photos).filter((f) => f.severity !== 'info'); + expect({ name, faults }).toEqual({ name, faults: [] }); + expect({ name, signature: SIGNATURE[a](cover, W, H) }).toEqual({ name, signature: true }); + // A journey of four photos or more fills the whole grid. + if (a === 'grid-badge' && name !== 'sparse') + expect(photos(cover).filter((p) => p.rotation === 0)).toHaveLength(4); + } + }); + + it(`DSNCOV-002: composes ${a} the same way for one seed and another way for another`, () => { + const once = coverOf(design('rich', archetype(a, { seed: 3 }))); + const again = coverOf(design('rich', archetype(a, { seed: 3 }))); + const other = coverOf(design('rich', archetype(a, { seed: 4 }))); + expect(JSON.stringify(again)).toBe(JSON.stringify(once)); + expect(JSON.stringify(other.elements)).not.toBe(JSON.stringify(once.elements)); + }); + } + + it('DSNCOV-003: keeps to every page format, and to a library without stickers', () => { + for (const preset of ['a5-landscape', 'a4-portrait', 'square-300'] as const) { + for (const a of BOOK_COVER_ARCHETYPES) { + const r = design('rich', archetype(a, { preset }), false); + const cover = coverOf(r); + const faults = pageFindings(cover, r.doc.page, input('rich').photos).filter((f) => f.severity !== 'info'); + expect({ preset, a, faults }).toEqual({ preset, a, faults: [] }); + expect(kinds(cover, 'sticker')).toEqual([]); + } + } + }); + + it('DSNCOV-004: a cover without an archetype is the engine own, brief or none', () => { + const plain = coverOf(design('rich', { style: 'poster-pop' })); + const briefed = coverOf(design('rich', { style: 'poster-pop', brief: { energy: 'calm' } })); + expect(JSON.stringify(briefed)).toBe(JSON.stringify(plain)); + expect(plain.elements.map((e) => e.kind)).toEqual(['photo', 'shape', 'text', 'text']); + }); + + it('DSNCOV-005: binds the title to the journey, and sets the title in the theme display face', () => { + for (const a of BOOK_COVER_ARCHETYPES) { + const cover = coverOf(design('rich', archetype(a, { style: 'cutout' }))); + const title = texts(cover).find((t) => t.binding?.source === 'journey.title'); + expect({ a, title: title?.text }).toEqual({ a, title: 'Atlantic coast' }); + expect({ a, font: title?.font }).toEqual({ a, font: 'shoulders' }); + } + const own = coverOf(design('rich', archetype('poster-pop', { cover: { title: 'Our coast' } }))); + expect(texts(own).some((t) => t.text === 'Our coast' && t.binding === null)).toBe(true); + }); + + it('DSNCOV-006: a poster takes three to six stickers of the theme, none with decoration none', () => { + const tropical = coverOf(design('rich', archetype('poster-pop', { style: 'tropical' }))); + const stickers = kinds(tropical, 'sticker'); + expect(stickers.length).toBeGreaterThanOrEqual(3); + expect(stickers.length).toBeLessThanOrEqual(6); + const motif = new Set(BOOK_STICKERS.filter((s) => s.theme === 'tropical').map((s) => s.id)); + expect(stickers.every((s) => s.kind === 'sticker' && motif.has(s.stickerId))).toBe(true); + const bare = coverOf(design('rich', archetype('poster-pop', { style: 'tropical', brief: { decoration: 'none' } }))); + expect(kinds(bare, 'sticker')).toEqual([]); + }); + + it('DSNCOV-007: a postcard draws the landmark of a city the journey stopped in, never another place', () => { + const lisbon = coverOf(design('rich', archetype('postcard'))); + expect(kinds(lisbon, 'sticker')).toEqual([]); + expect(photos(lisbon).some((p) => p.mask === 'arch-tall')).toBe(true); + const journey = input('rich'); + const paris = { ...journey, entries: journey.entries.map((e, i) => (i === 3 ? { ...e, location: 'Paris' } : e)) }; + const cover = coverOf(designBook(null, paris, archetype('postcard'))); + const drawn = kinds(cover, 'sticker').map((s) => (s.kind === 'sticker' ? s.stickerId : '')); + expect(['eiffelTower', 'arcDeTriomphe']).toContain(drawn[0]); + }); + + it('DSNCOV-008: sets the title as the lockup asked for, and falls back when that lockup has no place for one', () => { + const r = design('rich', archetype('grid-badge', { cover: { lockup: 'retro-shadow' } })); + const cover = coverOf(r); + expect(shapes(cover).some((e) => e.shape === 'ellipse')).toBe(false); + const title = texts(cover).find((t) => t.binding?.source === 'journey.title'); + expect(title?.outline).toBeTruthy(); + expect(new Set(texts(cover).map((t) => t.group)).size).toBe(1); + + const quote = design('rich', archetype('grid-badge', { cover: { lockup: 'quote-classic' } })); + expect(shapes(coverOf(quote)).some((e) => e.shape === 'ellipse')).toBe(true); + expect(quote.notes.some((n) => n.includes('quote-classic has no place for the cover'))).toBe(true); + }); + + it('DSNCOV-009: a lockup without an archetype is set on the engine own cover, over its scrim', () => { + const cover = coverOf(design('rich', { style: 'riviera', cover: { lockup: 'spaced-date' } })); + const [photo, scrim] = cover.elements; + expect(photo?.kind).toBe('photo'); + expect(scrim?.kind === 'shape' && scrim.gradient).toBe('up'); + const title = texts(cover).find((t) => t.binding?.source === 'journey.title'); + expect(title?.group).toBeTruthy(); + const faults = pageFindings(cover, design('rich', {}).doc.page, input('rich').photos); + expect(faults.filter((f) => f.severity !== 'info')).toEqual([]); + }); + + it('DSNCOV-010: says that an archetype takes the place of a cover layout asked beside it', () => { + const r = design('rich', archetype('minimal-serif', { cover: { template: 'cover-band' } })); + expect(r.notes).toContain('the minimal-serif cover is composed for its archetype, so cover.template is left out'); + }); + + it('DSNCOV-012: keeps the turned prints of an archetype to what a calm brief takes, and is not judged for them', () => { + for (const a of ['poster-pop', 'postcard', 'stamp-collage', 'cutout'] as const) { + const r = design('rich', { brief: { energy: 'calm', theme: 'nordic' }, cover: { archetype: a } }); + const turns = photos(coverOf(r)).map((p) => Math.abs(p.rotation)); + expect({ a, steep: turns.filter((t) => t > 2) }).toEqual({ a, steep: [] }); + expect(r.findings.filter((f) => f.code === 'energy_mismatch')).toEqual([]); + } + // A lively brief keeps the poster's turn of three to six degrees. + const lively = photos(coverOf(design('rich', archetype('poster-pop')))).map((p) => Math.abs(p.rotation)); + expect(lively.some((t) => t >= 3 && t <= 6)).toBe(true); + }); + + it('DSNCOV-013: sets a minimal serif title large in the display face, and keeps its sun under a plain lockup', () => { + const r = design('rich', archetype('minimal-serif', { style: 'minimal-serif' })); + const title = texts(coverOf(r)).find((t) => t.binding?.source === 'journey.title'); + expect(title?.font).toBe('cormorant'); + expect(title?.size).toBeGreaterThanOrEqual(60); + for (const lockup of ['poster-stack', 'minimal-caps', 'retro-shadow'] as const) { + const cover = coverOf(design('rich', archetype('minimal-serif', { cover: { lockup } }))); + expect({ lockup, sun: shapes(cover).some((e) => e.shape === 'sun') }).toEqual({ lockup, sun: true }); + const flat = pageFindings(cover, r.doc.page, input('rich').photos).filter((f) => f.code === 'flat_cover'); + expect({ lockup, flat }).toEqual({ lockup, flat: [] }); + } + // A landscape page gives the title more of its height. + const wide = design('rich', archetype('minimal-serif', { style: 'minimal-serif', preset: 'a5-landscape' })); + const set = texts(coverOf(wide)).find((t) => t.binding?.source === 'journey.title'); + expect(set?.size).toBeGreaterThanOrEqual(48); + }); + + it('DSNCOV-014: holds a composed title to the display tier asked, and says what a cover leaves out', () => { + const asked = design('rich', archetype('poster-pop', { type: { display: 1.2 } })); + const capped = texts(coverOf(asked)).find((t) => t.binding?.source === 'journey.title'); + expect(capped?.size).toBeLessThanOrEqual(33.6); + const free = texts(coverOf(design('rich', archetype('poster-pop')))).find( + (t) => t.binding?.source === 'journey.title', + ); + expect(free?.size).toBeGreaterThan(capped?.size ?? 0); + const badge = design('rich', archetype('grid-badge', { type: { display: 5 } })); + expect(badge.notes.some((n) => n.startsWith('the grid-badge cover sets its title as large as its box holds'))).toBe( + true, + ); + const lockup = design('rich', { style: 'riviera', cover: { lockup: 'poster-stack', template: 'cover-band' } }); + expect(lockup.notes).toContain( + 'the cover sets its title as the lockup poster-stack, so cover.template is left out', + ); + }); + + it('DSNCOV-015: sets the script line of a lockup in the heading face of a theme without a script', () => { + const r = design('rich', archetype('postcard', { style: 'minimal-serif' })); + const cover = coverOf(r); + const faces = new Set(texts(cover).map((t) => t.font)); + expect(faces.has('vibes')).toBe(false); + expect(pageFindings(cover, r.doc.page, input('rich').photos, r.doc.theme).map((f) => f.code)).not.toContain( + 'off_theme', + ); + }); + + it('DSNCOV-011: takes a lockup on every archetype, and composes every archetype for a journey without photos', () => { + const journey = input('rich'); + const bare: BookDesignInput = { + ...journey, + entries: journey.entries.map((e) => ({ ...e, photos: [] })), + candidates: [], + photos: [], + gallery: [], + coverPhotoId: null, + }; + for (const a of BOOK_COVER_ARCHETYPES) { + const r = design('rich', archetype(a, { style: 'riviera', cover: { lockup: 'retro-shadow' } })); + const cover = coverOf(r); + const title = texts(cover).find((t) => t.binding?.source === 'journey.title'); + expect({ a, grouped: !!title?.group }).toEqual({ a, grouped: true }); + const faults = pageFindings(cover, r.doc.page, journey.photos).filter((f) => f.severity !== 'info'); + expect({ a, faults }).toEqual({ a, faults: [] }); + + const without = designBook(null, bare, archetype(a, { style: 'riviera' })); + const plain = coverOf(without); + const { pageWidth: W, pageHeight: H } = without.doc.page; + expect(photos(plain)).toEqual([]); + expect(bookSpreadSchema.safeParse(plain).success).toBe(true); + expect({ a, off: onPage(plain, W, H) }).toEqual({ a, off: [] }); + expect(texts(plain).some((t) => t.binding?.source === 'journey.title')).toBe(true); + const left = pageFindings(plain, without.doc.page, []).filter((f) => f.severity !== 'info'); + expect({ a, left }).toEqual({ a, left: [] }); + } + }); +}); diff --git a/shared/src/book/design/design-cover.ts b/shared/src/book/design/design-cover.ts new file mode 100644 index 000000000..7cc5977ee --- /dev/null +++ b/shared/src/book/design/design-cover.ts @@ -0,0 +1,117 @@ +import type { BookCoverArchetype } from '../book-archetypes'; +import { groupElements } from '../book-groups'; +import { solveBookTextFit } from '../book-text-fit'; +import type { BookElement, BookSpread } from '../book.schema'; +import { frameOf, scrimEl, type ComposeCtx } from './design-compose'; +import { gridBadge, stampCollage } from './design-cover-collage'; +import { postcard, posterPop } from './design-cover-graphic'; +import { + addLockup, + bleedPhoto, + coverKit, + type CoverDraft, + type CoverKit, + type CoverMaterial, +} from './design-cover-kit'; +import { cutout, minimalSerif, spineBand } from './design-cover-photo'; +import { titleWords, type CoverWords } from './design-cover-words'; +import { paperBackground } from './design-style'; + +/** + * The composed covers: `cover.archetype` picks one of seven composers, each + * building its archetype from the book's own material (its photos, its words, + * its theme's colours, faces and motifs, the lockups) with the seed's + * variation, deterministic for one seed. A cover without an archetype is the + * engine's own (design-framing.ts), untouched. + */ + +const COMPOSERS: Readonly CoverDraft>> = { + 'spine-band': spineBand, + 'poster-pop': posterPop, + cutout, + postcard, + 'grid-badge': gridBadge, + 'minimal-serif': minimalSerif, + 'stamp-collage': stampCollage, +}; + +/** A draft as the cover page: on its ground, each lockup one group. */ +function coverSpread(c: ComposeCtx, draft: CoverDraft): BookSpread { + const paper = paperBackground(c.look); + const background = draft.background && draft.background !== c.look.paper ? draft.background : paper; + let spread: BookSpread = { + id: c.newId('sp'), + role: 'cover', + background, + elements: draft.elements, + parked: [], + entryId: null, + }; + for (const ids of draft.groups) spread = groupElements(spread, ids, c.newId('g')); + return spread; +} + +/** + * A composed cover's title held to the display size `type.display` asks for, + * when it asks: set no larger, and said in the notes when the box the cover + * sets it in holds less, since a band, a badge or a stamp sizes the title to + * itself. Nothing changes for a design that leaves `type.display` out. + */ +function displayTier(c: ComposeCtx, draft: CoverDraft, words: CoverWords, what: string): CoverDraft { + const title = titleWords(words.ownTitle, words.title).words.trim(); + if (c.directives.type?.display === undefined || !title) return draft; + const most = c.look.sizes.display; + let short = 0; + const elements = draft.elements.map((e) => { + if (e.kind !== 'text' || e.text.trim() !== title) return e; + if (e.size > most) { + const capped = { ...e, fitMax: most }; + return { ...capped, size: solveBookTextFit(capped, most) }; + } + if (e.size < most - 1) short = Math.max(short, e.size); + return e; + }); + if (short) { + c.notes.push( + `the ${what} cover sets its title as large as its box holds, ${short} pt, under the ${most} pt type.display asks for`, + ); + } + return { ...draft, elements }; +} + +/** The cover of an archetype, composed from the book's photos and entries. */ +export function composeArchetypeCover( + c: ComposeCtx, + archetype: BookCoverArchetype, + words: CoverWords, + material: CoverMaterial, +): BookSpread { + if (c.directives.cover?.template) { + c.notes.push(`the ${archetype} cover is composed for its archetype, so cover.template is left out`); + } + const draft = COMPOSERS[archetype](coverKit(c, archetype, words, material)); + return coverSpread(c, displayTier(c, draft, words, archetype)); +} + +/** + * The engine's own cover with its title set as a lockup: the photo to the + * bleed, a scrim over its foot and the lockup on the scrim, or the lockup alone + * in the middle of the paper. + */ +export function lockupCover(c: ComposeCtx, words: CoverWords, material: CoverMaterial): BookSpread { + const k = coverKit(c, 'lockup', words, material); + if (c.directives.cover?.template) { + c.notes.push(`the cover sets its title as the lockup ${k.lockup ?? ''}, so cover.template is left out`); + } + const { W, H, b, m } = k; + const draft: CoverDraft = { elements: [], groups: [] }; + const photo = k.photos[0]; + const top = H * 0.56; + if (photo) { + const scrim: BookElement = scrimEl(c, frameOf(-b, top, W + b * 2, H - top + b)); + draft.elements.push(bleedPhoto(k, photo), scrim); + } + const box = photo ? frameOf(m, top + m * 0.4, W - m * 2, H - top - m * 1.4) : frameOf(m, H * 0.3, W - m * 2, H * 0.4); + addLockup(k, draft, { box, ground: photo ? c.look.scrim : c.look.paper, fallback: 'poster-stack' }); + return coverSpread(c, displayTier(c, draft, words, `${k.lockup ?? 'poster-stack'} lockup`)); +} diff --git a/shared/src/book/design/design-directives.schema.ts b/shared/src/book/design/design-directives.schema.ts index 5f78084b9..6f00d01ef 100644 --- a/shared/src/book/design/design-directives.schema.ts +++ b/shared/src/book/design/design-directives.schema.ts @@ -4,6 +4,13 @@ import { bookCoverTemplateIdSchema } from '../book-ops.schema'; import { MAX_BOOK_TITLE } from '../book.schema'; import { bookThemeCatalogue } from '../theme/book-theme-describe'; import { BOOK_THEME_IDS } from '../theme/book-theme.schema'; +import { + MAX_BOOK_DESIGN_OVERRIDES, + bookCoverArchetypeSchema, + bookCoverLockupSchema, + bookDesignBriefSchema, + bookDesignPageOverrideSchema, +} from './design-brief.schema'; import { z } from 'zod'; @@ -43,8 +50,20 @@ export const bookDesignDirectivesSchema = z.strictObject({ .enum(['airy', 'balanced', 'rich']) .describe('Photos per spread: airy 1 to 2, balanced 2 to 4, rich 4 to 6.') .default('balanced'), - /** Inner spreads, the covers and the first and last page not counted. */ - pages: z.strictObject({ min: pageCount.optional(), max: pageCount.optional() }).optional(), + /** The art direction (design-brief.schema.ts): energy, decoration, collages and the theme. */ + brief: bookDesignBriefSchema.optional(), + /** Inner spreads, the covers and the first and last page not counted; `overrides` recompose single pages. */ + pages: z + .strictObject({ + min: pageCount.optional(), + max: pageCount.optional(), + overrides: z + .array(bookDesignPageOverrideSchema) + .max(MAX_BOOK_DESIGN_OVERRIDES) + .describe('Pages of the plan given another beat or layout, by index.') + .optional(), + }) + .optional(), scope: z.strictObject({ entryIds: ids200.optional(), from: isoDate.optional(), to: isoDate.optional() }).optional(), chapters: z .enum(['entry', 'day', 'place']) @@ -66,6 +85,8 @@ export const bookDesignDirectivesSchema = z.strictObject({ cover: z .strictObject({ template: bookCoverTemplateIdSchema.optional(), + archetype: bookCoverArchetypeSchema.optional(), + lockup: bookCoverLockupSchema.optional(), title: z.string().max(MAX_BOOK_TITLE).optional(), subtitle: z.string().max(MAX_BOOK_TITLE).optional(), }) diff --git a/shared/src/book/design/design-framing.ts b/shared/src/book/design/design-framing.ts index 738a0e0f5..bb4452532 100644 --- a/shared/src/book/design/design-framing.ts +++ b/shared/src/book/design/design-framing.ts @@ -18,6 +18,9 @@ import { textEl, type ComposeCtx, } from './design-compose'; +import { composeArchetypeCover, lockupCover } from './design-cover'; +import type { CoverMaterial } from './design-cover-kit'; +import { titleWords, type CoverWords } from './design-cover-words'; import { paperBackground } from './design-style'; /** @@ -36,24 +39,22 @@ const single = (c: ComposeCtx, role: BookSpread['role'], elements: BookElement[] entryId: null, }); -/** A title the journey owns, bound to it, or the person's own words, not bound. */ -function titleWords(own: string | undefined, journey: string): { words: string; bound: boolean } { - return own !== undefined && own.trim() ? { words: own, bound: false } : { words: journey, bound: true }; -} - -interface CoverWords { - title: string; - subtitle: string | null; - ownTitle?: string; - ownSubtitle?: string; -} - /** * The cover: a photo to the bleed with the title on a scrim at its foot, or * the title alone on the paper. The title starts at the display size and is - * set smaller only as far as its box needs. + * set smaller only as far as its box needs. An archetype the directives name + * composes the cover instead (design-cover.ts), from `more`, the photos and + * entries the book holds; a lockup without one sets the title on the scrim. */ -export function composeCover(c: ComposeCtx, words: CoverWords, photo: AutoPhoto | null): BookSpread { +export function composeCover( + c: ComposeCtx, + words: CoverWords, + photo: AutoPhoto | null, + more: CoverMaterial = { photos: photo ? [photo] : [], entries: [] }, +): BookSpread { + const archetype = c.directives.cover?.archetype; + if (archetype) return composeArchetypeCover(c, archetype, words, more); + if (c.directives.cover?.lockup) return lockupCover(c, words, more); const { pageWidth: PW, pageHeight: H, bleed: b, margin: m } = c.grid; const els: BookElement[] = []; const title = titleWords(words.ownTitle, words.title); diff --git a/shared/src/book/design/design-inner.ts b/shared/src/book/design/design-inner.ts index d991840c2..d0da052f8 100644 --- a/shared/src/book/design/design-inner.ts +++ b/shared/src/book/design/design-inner.ts @@ -4,6 +4,7 @@ import type { BookElement, BookSpread } from '../book.schema'; import type { BookDesignInput } from './design-book'; import type { ComposeCtx } from './design-compose'; import { composeCountries, composeStations, composeSummary } from './design-framing'; +import { composeLively, livelyPlans, rhythmBeat } from './design-lively-pacing'; import type { DesignedSpread } from './design-merge'; import { buildChapters, @@ -32,6 +33,7 @@ import { type FlowItem, type Words, } from './design-spreads'; +import { decorateSpread } from './design-stickers'; /** * The inner pages of a designed book: the journey at a glance and its @@ -161,10 +163,18 @@ function chapterWords(c: ComposeCtx, chapter: DesignChapter, told: ReadonlySet p.beat === 'quiet'); - const done = composePlanned(c, plan, hand, { flow, later }); - const spread: BookSpread = innerSpread(c, done.elements, entryId); + const done = composePlanned(c, plan, hand, { flow, later }, start + i); + const spread: BookSpread = decorateSpread(c, innerSpread(c, done.elements, entryId), `deco:${start + i}`); const rest = done.rest ?? flow; for (const id of endedOn(flow, rest)) ends.set(id, spread.id); flow = rest; @@ -322,6 +332,7 @@ export function composeDesignedInner( } const shares = shareSpreads(chapters, chapters.map(naturalOf), budget); const rhythm = newRhythm(); + const lively = { seen: 0 }; const plan = { directives: c.directives, page: c.page, infos: c.infos }; const body: DesignedSpread[] = []; // By entry id, the page its story is set to the end on. @@ -334,12 +345,12 @@ export function composeDesignedInner( return; } const from = rhythm.beats.length; - const plans = planChapter(chapter, shares[i] ?? 1, rhythm, plan, leftOut); + const plans = livelyPlans(c, planChapter(chapter, shares[i] ?? 1, rhythm, plan, leftOut), lively); const made = chapterSpreads(c, chapter, plans, body.length, told); settleRhythm( rhythm, from, - made.spreads.map((d) => d.beat), + made.spreads.map((d) => rhythmBeat(d.beat)), ); body.push(...made.spreads); for (const [id, at] of made.ends) ends.set(id, at); diff --git a/shared/src/book/design/design-lively-pacing.ts b/shared/src/book/design/design-lively-pacing.ts new file mode 100644 index 000000000..9ac991c5c --- /dev/null +++ b/shared/src/book/design/design-lively-pacing.ts @@ -0,0 +1,110 @@ +import { CALM_ART } from './design-art'; +import { BOOK_DESIGN_LIVELY_BEATS, type BookDesignLivelyBeat } from './design-brief.schema'; +import type { ComposeCtx } from './design-compose'; +import { + composeCollage, + composeGridBadge, + composeScriptOverlay, + composeStampRow, + composeTwoTone, +} from './design-lively'; +import type { BookDesignBeat, PlannedSpread } from './design-pacing'; +import { designRng } from './design-random'; +import type { Composed } from './design-spreads'; + +/** + * Where a lively or loud book is lively: about two in five of its hero and + * grid spreads become one of the lively beats (design-lively.ts), evenly + * spread through the book from an offset the seed picks, the first of them + * always among them, so a book with one such spread has one lively page. A + * hero becomes a script overlay or a two-tone split, a grid a collage, a + * badge grid or a stamp row; with `collage` asked for, every grid that + * changes is a collage. A calm book asked for collages turns two in five of + * its grids into collages and changes nothing else. The pacing is planned on the calm beats + * first and keeps their rhythm: a lively page counts as the beat it took the + * place of. + */ + +/** The share of hero and grid spreads a lively book turns into lively ones. */ +const LIVELY_SHARE = 0.4; + +/** Where the count of eligible spreads starts, by the seed: each a place where the share turns one, so the first does. */ +const OFFSETS = [2, 4] as const; + +/** The beat a lively spread stands for in the book's rhythm: a hero's or a grid's. */ +const STANDS_FOR: Readonly> = { + 'script-overlay': 'hero', + 'two-tone': 'hero', + collage: 'grid', + 'grid-badge': 'grid', + 'stamp-row': 'grid', +}; + +/** Whether a beat is one of the lively ones. */ +export const isLivelyBeat = (beat: BookDesignBeat): beat is BookDesignLivelyBeat => + (BOOK_DESIGN_LIVELY_BEATS as readonly string[]).includes(beat); + +/** The beat as the rhythm counts it. */ +export const rhythmBeat = (beat: BookDesignBeat): BookDesignBeat => (isLivelyBeat(beat) ? STANDS_FOR[beat] : beat); + +/** How many hero and grid spreads the book has passed, across its chapters. */ +interface LivelyCount { + seen: number; +} + +/** The lively beat a hero or a grid becomes, by the seed. */ +function livelyFor(c: ComposeCtx, plan: PlannedSpread, at: number): BookDesignBeat { + const art = c.art ?? CALM_ART; + const rng = designRng(art.seed, `beat:${at}`); + if (plan.beat === 'hero') { + const script = c.look.theme.fonts.script !== null; + return script && rng.chance(0.5) ? 'script-overlay' : 'two-tone'; + } + if (art.collage) return 'collage'; + return rng.pick(['collage', 'grid-badge', 'stamp-row'] as const) ?? 'collage'; +} + +/** + * A chapter's plan with two in five of the book's hero and grid spreads made + * lively; the plan as it is for a calm book, but for the grids it asked to + * have as collages. + */ +export function livelyPlans(c: ComposeCtx, plans: readonly PlannedSpread[], count: LivelyCount): PlannedSpread[] { + const art = c.art ?? CALM_ART; + const calm = art.energy === 'calm'; + if (calm && !art.collage) return [...plans]; + const offset = designRng(art.seed, 'beat:offset').pick(OFFSETS) ?? OFFSETS[0]; + return plans.map((plan) => { + const grid = plan.beat === 'grid' && plan.photos.length >= 4; + // A calm book's heroes stay heroes: only its grids gather as the collages it asked for. + const eligible = grid || (!calm && plan.beat === 'hero' && plan.entry !== null); + if (!eligible) return plan; + const i = count.seen + offset; + count.seen += 1; + const turns = Math.floor((i + 1) * LIVELY_SHARE) > Math.floor(i * LIVELY_SHARE); + return turns ? { ...plan, beat: livelyFor(c, plan, count.seen) } : plan; + }); +} + +/** + * A lively spread, composed: null for a beat that is not lively, or one whose + * photos or entry it lacks, which the caller composes as before. + */ +export function composeLively(c: ComposeCtx, plan: PlannedSpread, hand: 'left' | 'right', at: number): Composed | null { + if (!isLivelyBeat(plan.beat)) return null; + const rng = designRng((c.art ?? CALM_ART).seed, `${plan.beat}:${at}`); + const entry = plan.entry ?? plan.chapter.entries[0] ?? null; + const [first] = plan.photos; + switch (plan.beat) { + case 'collage': + return plan.photos.length ? composeCollage(c, plan.photos, hand, rng) : null; + case 'grid-badge': + return plan.photos.length >= 2 ? composeGridBadge(c, plan.photos, entry, hand, rng) : null; + case 'stamp-row': + return plan.photos.length ? composeStampRow(c, plan.photos, entry, hand, rng) : null; + case 'script-overlay': + return entry && first ? composeScriptOverlay(c, entry, first, hand, rng) : null; + case 'two-tone': + return entry && first ? composeTwoTone(c, entry, first, hand, rng) : null; + } +} diff --git a/shared/src/book/design/design-lively.spec.ts b/shared/src/book/design/design-lively.spec.ts new file mode 100644 index 000000000..51828dd52 --- /dev/null +++ b/shared/src/book/design/design-lively.spec.ts @@ -0,0 +1,379 @@ +import type { AutoEntry, AutoPhoto } from '../auto/auto-types'; +import { bookElementSchema, type BookElement, type BookSpread } from '../book.schema'; +import { pageFindings } from '../describe-spread'; +import { BOOK_STICKERS } from '../stickers/sticker-catalogue'; +import { designArt } from './design-art'; +import { designBook, type BookDesignInput, type BookDesignResult } from './design-book'; +import { BOOK_DESIGN_LIVELY_BEATS } from './design-brief.schema'; +import type { ComposeCtx } from './design-compose'; +import { bookDesignDirectivesSchema, type BookDesignDirectivesInput } from './design-directives.schema'; +import { + composeCollage, + composeGridBadge, + composeScriptOverlay, + composeStampRow, + composeTwoTone, +} from './design-lively'; +import { composeLively, livelyPlans, rhythmBeat } from './design-lively-pacing'; +import type { DesignChapter, PlannedSpread } from './design-pacing'; +import { designRng } from './design-random'; +import { innerSpread } from './design-spreads'; +import { turnedBox } from './design-stickers'; +import { designGrid, designLook } from './design-style'; + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** + * The lively spreads (design-lively.ts, design-lively-pacing.ts): two in + * five hero and grid spreads of a lively or loud book become collages, + * script overlays, badge grids, stamp rows or two-tone splits, none of a calm + * one; each is a valid double page with its signature, nothing across the + * fold that should not be, its words on the page; and a lively book designed + * whole keeps every page clean, decorated at its level. + */ + +type Name = 'rich' | 'sparse' | 'nostats'; +const here = (file: string) => fileURLToPath(new URL(file, import.meta.url)); +const fixtures = JSON.parse(readFileSync(here('./design-book.fixtures.json'), 'utf8')) as Record< + Name, + { input: BookDesignInput } +>; +const rich = fixtures.rich.input; + +function input(name: Name): BookDesignInput { + let n = 0; + return { ...fixtures[name].input, newId: (p) => `${p}-${++n}`, stickers: BOOK_STICKERS }; +} + +const design = (name: Name, directives: BookDesignDirectivesInput): BookDesignResult => + designBook(null, input(name), directives); + +/** A composing context as design_book makes one, for the rich journey. */ +function ctxOf(raw: BookDesignDirectivesInput): ComposeCtx { + const directives = bookDesignDirectivesSchema.parse(raw); + const page = rich.page; + const look = designLook(directives, page, rich.photos); + let n = 0; + const labels = { + day: 'DAY', + stations: 'Stops', + summary: 'At a glance', + countries: 'Countries', + distance: '', + pros: '', + cons: '', + }; + return { + look, + page, + grid: designGrid(page), + newId: (p) => `${p}-${++n}`, + locale: 'en-GB', + infos: new Map(rich.photos.map((p) => [p.photoId, p])), + travel: { locale: 'en-GB', labels, stats: null, path: [], entries: [] }, + directives, + notes: [], + art: designArt(directives, look.theme, BOOK_STICKERS), + }; +} + +const entry = rich.entries.find((e) => e.id === 3) as AutoEntry; +const gallery: AutoPhoto[] = rich.entries.flatMap((e) => e.photos); +const chapter = (photos: AutoPhoto[]): DesignChapter => ({ + key: 'e3', + label: null, + entries: [entry], + photos, + stations: false, + burst: [], +}); +const plan = (beat: PlannedSpread['beat'], photos: AutoPhoto[], withEntry = beat === 'hero'): PlannedSpread => ({ + beat, + chapter: chapter(photos), + entry: withEntry ? entry : null, + photos, +}); + +const PW = rich.page.pageWidth; +const H = rich.page.pageHeight; + +/** The words and photos that cross the fold: none may, but a photo across the whole spread. */ +function acrossFold(elements: readonly BookElement[]): string[] { + return elements.flatMap((e) => { + if (e.kind !== 'photo' && e.kind !== 'text') return []; + const b = turnedBox(e.frame, e.rotation); + const crosses = b.x < PW - 1 && b.x + b.w > PW + 1; + const spread = e.kind === 'photo' && b.w >= PW * 2; + return crosses && !spread ? [e.id] : []; + }); +} + +/** Words that leave the spread. */ +const wordsOff = (elements: readonly BookElement[]) => + elements.flatMap((e) => { + if (e.kind !== 'text') return []; + const b = turnedBox(e.frame, e.rotation); + return b.x < 0 || b.y < 0 || b.x + b.w > PW * 2 || b.y + b.h > H ? [e.id] : []; + }); + +describe('where a book is lively', () => { + it('DSNLIV-001: turns two in five hero and grid spreads of a lively book, from the seed, none of a calm one', () => { + const plans = [ + ...Array.from({ length: 5 }, () => plan('hero', gallery.slice(0, 1))), + ...Array.from({ length: 5 }, () => plan('grid', gallery.slice(1, 6))), + plan('duo', gallery.slice(0, 2)), + plan('grid', gallery.slice(0, 3)), + plan('hero', gallery.slice(0, 1), false), + ]; + for (const seed of [0, 1, 2, 3, 4]) { + const count = { seen: 0 }; + const made = livelyPlans(ctxOf({ style: 'poster-pop', brief: { energy: 'lively' }, seed }), plans, count); + const lively = made.filter((p) => (BOOK_DESIGN_LIVELY_BEATS as readonly string[]).includes(p.beat)); + expect({ seed, turned: lively.length }).toEqual({ seed, turned: 4 }); + expect(count.seen).toBe(10); + // A duo, a grid of three and a hero without its entry are left as they are. + expect(made.slice(10).map((p) => p.beat)).toEqual(['duo', 'grid', 'hero']); + made.forEach((p, i) => { + if (plans[i]?.beat === 'hero' && p.beat !== 'hero') expect(['script-overlay', 'two-tone']).toContain(p.beat); + if (plans[i]?.beat === 'grid' && p.beat !== 'grid') + expect(['collage', 'grid-badge', 'stamp-row']).toContain(p.beat); + }); + } + const calm = livelyPlans(ctxOf({ style: 'poster-pop' }), plans, { seen: 0 }); + expect(calm.map((p) => p.beat)).toEqual(plans.map((p) => p.beat)); + const collages = livelyPlans(ctxOf({ style: 'poster-pop', brief: { energy: 'loud', collage: true } }), plans, { + seen: 0, + }); + expect( + collages.filter((p, i) => plans[i]?.beat === 'grid' && p.beat !== 'grid').every((p) => p.beat === 'collage'), + ).toBe(true); + }); + + it('DSNLIV-012: makes the first hero or grid of a lively book lively, whatever the seed', () => { + for (const seed of [0, 1, 2, 3, 4, 5]) { + const [one] = livelyPlans( + ctxOf({ style: 'riviera', brief: { energy: 'lively' }, seed }), + [plan('hero', gallery.slice(0, 1))], + { + seen: 0, + }, + ); + expect({ seed, lively: ['script-overlay', 'two-tone'].includes(one?.beat ?? '') }).toEqual({ + seed, + lively: true, + }); + } + }); + + it('DSNLIV-013: turns two in five grids of a calm book asked for collages into collages, and nothing else', () => { + const plans = [ + ...Array.from({ length: 5 }, () => plan('hero', gallery.slice(0, 1))), + ...Array.from({ length: 5 }, () => plan('grid', gallery.slice(1, 6))), + ]; + const made = livelyPlans(ctxOf({ style: 'riviera', brief: { energy: 'calm', collage: true } }), plans, { seen: 0 }); + expect(made.slice(0, 5).map((p) => p.beat)).toEqual(Array(5).fill('hero')); + expect(made.slice(5).filter((p) => p.beat === 'collage')).toHaveLength(2); + expect(made.slice(5).every((p) => p.beat === 'collage' || p.beat === 'grid')).toBe(true); + // The book designed so has its collages, its prints turned within the calm book's few degrees. + const r = design('rich', { style: 'riviera', brief: { energy: 'calm', collage: true } }); + const collages = r.plan.filter((row) => row.beat === 'collage'); + expect(collages.length).toBeGreaterThan(0); + for (const row of collages) { + const prints = (r.doc.spreads[row.index]?.elements ?? []).filter((e) => e.kind === 'photo'); + expect(prints.every((e) => Math.abs(e.rotation) >= 1 && Math.abs(e.rotation) <= 2)).toBe(true); + } + expect(r.findings.filter((f) => f.code === 'energy_mismatch')).toEqual([]); + }); + + it('DSNLIV-002: counts a lively spread as the beat it took the place of, in the book rhythm', () => { + expect(rhythmBeat('script-overlay')).toBe('hero'); + expect(rhythmBeat('two-tone')).toBe('hero'); + for (const beat of ['collage', 'grid-badge', 'stamp-row'] as const) expect(rhythmBeat(beat)).toBe('grid'); + for (const beat of ['hero', 'grid', 'duo', 'quiet', 'opener'] as const) expect(rhythmBeat(beat)).toBe(beat); + }); +}); + +describe('the lively spreads', () => { + const c = () => ctxOf({ style: 'riviera', brief: { energy: 'lively' } }); + const rng = () => designRng(0, 'spec'); + const valid = (elements: readonly BookElement[]) => elements.every((e) => bookElementSchema.safeParse(e).success); + + it('DSNLIV-003: a collage lays the photos as turned, framed prints on both pages, none across the fold or over a detail', () => { + const made = composeCollage(c(), gallery.slice(0, 5), 'left', rng()).elements; + const prints = made.flatMap((e) => (e.kind === 'photo' ? [e] : [])); + expect(prints).toHaveLength(5); + expect(valid(made)).toBe(true); + expect(prints.every((p) => Math.abs(p.rotation) >= 3 && p.frameStyle !== 'none')).toBe(true); + expect(prints.filter((p) => p.frame.x < PW)).toHaveLength(3); + expect(acrossFold(made)).toEqual([]); + // A later print never lies over the detail of one under it. + prints.forEach((under, i) => { + const fx = under.frame.x + under.focalX * under.frame.w; + const fy = under.frame.y + under.focalY * under.frame.h; + for (const above of prints.slice(i + 1)) { + const b = turnedBox(above.frame, above.rotation); + expect(fx >= b.x && fx <= b.x + b.w && fy >= b.y && fy <= b.y + b.h).toBe(false); + } + }); + }); + + it('DSNLIV-004: a script overlay sets the place in a giant outlined script over the photo across the spread', () => { + const made = composeScriptOverlay(c(), entry, gallery[0] as AutoPhoto, 'left', rng()).elements; + expect(valid(made)).toBe(true); + const [photo] = made; + expect(photo?.kind === 'photo' && photo.frame.w >= PW * 2).toBe(true); + const word = made.find((e) => e.kind === 'text' && e.binding?.source === 'entry.location'); + expect(word?.kind === 'text' && word.text).toBe('Porto'); + expect(word?.kind === 'text' && word.size).toBeGreaterThanOrEqual(60); + expect(word?.kind === 'text' && (word.outline?.width ?? 0) > 0).toBe(true); + expect(word?.kind === 'text' && word.font).toBe(c().look.theme.fonts.script?.font); + const labels = made.filter((e) => e.kind === 'text' && !!e.highlight); + expect(labels.map((e) => (e.kind === 'text' ? e.binding?.source : null))).toEqual(['entry.title', 'entry.date']); + // The words stand on the page away from the photo's lead. + expect(made.filter((e) => e.kind === 'text').every((e) => e.frame.x > PW)).toBe(true); + expect(wordsOff(made)).toEqual([]); + }); + + it('DSNLIV-005: a two-tone split sets the title on a colour page of two blocks beside a photo to its bleed', () => { + const made = composeTwoTone(c(), entry, gallery[0] as AutoPhoto, 'right', rng()).elements; + expect(valid(made)).toBe(true); + const blocks = made.flatMap((e) => (e.kind === 'shape' ? [e] : [])); + expect(blocks).toHaveLength(2); + expect(blocks.every((b) => b.frame.x + b.frame.w <= PW + 0.01)).toBe(true); + expect(blocks[0]?.fill).not.toBe(blocks[1]?.fill); + const photo = made.find((e) => e.kind === 'photo'); + expect(photo?.frame.x).toBe(PW); + const title = made.find((e) => e.kind === 'text' && e.binding?.source === 'entry.title'); + expect(title?.kind === 'text' && title.size).toBeGreaterThan(c().look.sizes.heading); + expect(acrossFold(made)).toEqual([]); + expect(wordsOff(made)).toEqual([]); + }); + + it('DSNLIV-006: a badge grid lays the photos edge to edge with a round badge of the place, off their details', () => { + for (const count of [4, 6]) { + const made = composeGridBadge(c(), gallery.slice(0, count), entry, 'left', rng()).elements; + expect(valid(made)).toBe(true); + const cells = made.filter((e) => e.kind === 'photo' && e.rotation === 0); + expect(cells).toHaveLength(count); + const disc = made.find((e) => e.kind === 'shape' && e.shape === 'ellipse' && e.fill !== null); + expect(disc).toBeDefined(); + const centre = (disc?.frame.x ?? 0) + (disc?.frame.w ?? 0) / 2; + expect(Math.abs(centre - PW / 2)).toBeLessThan(1); + expect(made.some((e) => e.kind === 'text' && e.text === 'Porto')).toBe(true); + expect(acrossFold(made)).toEqual([]); + } + }); + + it('DSNLIV-007: a stamp row sets the photos as perforated stamps on both pages, and a postmark of the place', () => { + const made = composeStampRow(c(), gallery.slice(0, 5), entry, 'left', rng()).elements; + expect(valid(made)).toBe(true); + const stamps = made.flatMap((e) => (e.kind === 'photo' ? [e] : [])); + expect(stamps).toHaveLength(5); + expect(stamps.every((s) => s.edge === 'perforated' && !!s.border && s.frame.w <= PW * 0.4 + 0.01)).toBe(true); + expect(made.some((e) => e.kind === 'text' && e.curve?.type === 'circle' && e.text === 'Porto')).toBe(true); + expect(acrossFold(made)).toEqual([]); + expect(wordsOff(made)).toEqual([]); + }); + + it('DSNLIV-014: a badge and a postmark are one group each, and the stamps lean as the book turns its prints', () => { + const grouped = (elements: readonly BookElement[]) => { + const parts = elements.filter((e) => e.kind !== 'photo' && e.kind !== 'shape' && e.kind !== 'sticker'); + return parts.length > 0 && parts.every((e) => e.group !== undefined && e.group === parts[0]?.group); + }; + expect(grouped(composeGridBadge(c(), gallery.slice(0, 4), entry, 'left', rng()).elements)).toBe(true); + const row = composeStampRow(c(), gallery.slice(0, 5), entry, 'left', rng()).elements; + expect(grouped(row)).toBe(true); + const stamps = row.filter((e) => e.kind === 'photo'); + expect(stamps.every((e) => Math.abs(e.rotation) >= 3 && Math.abs(e.rotation) <= 4.5)).toBe(true); + const calm = composeStampRow(ctxOf({ style: 'riviera' }), gallery.slice(0, 5), entry, 'left', rng()).elements; + expect(calm.filter((e) => e.kind === 'photo').every((e) => Math.abs(e.rotation) <= 2)).toBe(true); + }); + + it('DSNLIV-008: a lively beat its page cannot hold is composed as before', () => { + const ctx = c(); + expect(composeLively(ctx, plan('collage', []), 'left', 0)).toBeNull(); + expect(composeLively(ctx, plan('script-overlay', gallery.slice(0, 1), false), 'left', 0)).not.toBeNull(); + expect(composeLively(ctx, { ...plan('two-tone', []), entry }, 'left', 0)).toBeNull(); + expect(composeLively(ctx, plan('duo', gallery.slice(0, 2)), 'left', 0)).toBeNull(); + const spread = innerSpread( + ctx, + composeLively(ctx, plan('stamp-row', gallery.slice(0, 2)), 'right', 3)?.elements ?? [], + 3, + ); + expect(spread.elements.length).toBeGreaterThan(0); + }); +}); + +describe('a lively book', () => { + const inner = (r: BookDesignResult) => r.doc.spreads.filter((s) => s.role === 'inner'); + const stickers = (s: BookSpread) => s.elements.filter((e) => e.kind === 'sticker'); + + it('DSNLIV-009: is designed with lively pages, every page clean, the same for one seed', () => { + for (const name of ['rich', 'nostats'] as const) { + const directives: BookDesignDirectivesInput = { + style: 'tropical', + brief: { energy: 'loud', decoration: 'rich' }, + }; + const r = design(name, directives); + expect(r.plan.some((row) => (BOOK_DESIGN_LIVELY_BEATS as readonly string[]).includes(row.beat))).toBe(true); + for (const spread of r.doc.spreads) { + const left = pageFindings(spread, r.doc.page, fixtures[name].input.photos).filter((f) => f.severity !== 'info'); + expect({ name, spread: spread.id, left }).toEqual({ name, spread: spread.id, left: [] }); + } + expect(JSON.stringify(design(name, directives).doc)).toBe(JSON.stringify(r.doc)); + } + }); + + it('DSNLIV-010: is decorated at its level from the theme motifs, never over words, and a calm book not at all', () => { + const motif = new Set(BOOK_STICKERS.filter((s) => s.theme === 'tropical').map((s) => s.id)); + const rich3 = inner(design('rich', { style: 'tropical', brief: { energy: 'lively', decoration: 'rich' } })); + const counts = rich3.map((s) => stickers(s).length); + expect(Math.max(...counts)).toBeLessThanOrEqual(6); + expect(counts.filter((n) => n >= 3).length).toBeGreaterThan(counts.length / 2); + for (const s of rich3) { + expect(stickers(s).every((k) => k.kind === 'sticker' && motif.has(k.stickerId))).toBe(true); + const covering = pageFindings(s, rich.page, rich.photos).filter( + (f) => f.code === 'overlap' && f.message.startsWith('sticker'), + ); + expect(covering).toEqual([]); + } + const light = inner(design('rich', { style: 'tropical', brief: { energy: 'lively', decoration: 'light' } })); + expect(Math.max(...light.map((s) => stickers(s).length))).toBeLessThanOrEqual(2); + const calm = inner(design('rich', { style: 'tropical' })); + expect(calm.flatMap(stickers)).toEqual([]); + const bare = inner(design('rich', { style: 'tropical', brief: { energy: 'loud', decoration: 'none' } })); + expect(bare.flatMap(stickers)).toEqual([]); + }); + + it('DSNLIV-015: is never judged against its brief for what the engine made of it, decorated or not', () => { + const briefs = [ + { energy: 'calm', decoration: 'light' }, + { energy: 'lively', decoration: 'none' }, + { energy: 'loud', decoration: 'none' }, + { energy: 'lively' }, + ] as const; + for (const name of ['rich', 'sparse', 'nostats'] as const) { + for (const theme of ['nordic', 'poster-pop'] as const) { + for (const brief of briefs) { + const r = design(name, { brief: { ...brief, theme }, cover: { archetype: 'poster-pop' } }); + const said = r.findings.filter((f) => f.code === 'energy_mismatch').map((f) => f.message); + expect({ name, theme, brief, said }).toEqual({ name, theme, brief, said: [] }); + } + } + } + // A lively book turns and frames its prints on the calm pages too. + const lively = inner(design('rich', { brief: { energy: 'lively', decoration: 'none', theme: 'nordic' } })); + const prints = lively.flatMap((s) => s.elements.filter((e) => e.kind === 'photo' && Math.abs(e.rotation) >= 3)); + expect(prints.some((e) => e.kind === 'photo' && e.frameStyle === 'white')).toBe(true); + }); + + it('DSNLIV-011: takes the theme of its brief as its style, and says what of the brief it could not keep', () => { + const r = design('rich', { style: 'classic', brief: { energy: 'lively', decoration: 'light', theme: 'nordic' } }); + expect(r.doc.theme?.id).toBe('nordic'); + expect(r.findings.filter((f) => f.code === 'energy_mismatch').every((f) => f.severity === 'info')).toBe(true); + const unbriefed = design('rich', { style: 'nordic' }); + expect(unbriefed.findings.some((f) => f.code === 'energy_mismatch')).toBe(false); + }); +}); diff --git a/shared/src/book/design/design-lively.ts b/shared/src/book/design/design-lively.ts new file mode 100644 index 000000000..76d97c800 --- /dev/null +++ b/shared/src/book/design/design-lively.ts @@ -0,0 +1,395 @@ +import type { AutoEntry, AutoPhoto } from '../auto/auto-types'; +import type { BookLockupWords } from '../book-lockups'; +import { formatBookDate } from '../book-text'; +import type { BookElement, BookFrame, BookPhotoElement, BookTextElement } from '../book.schema'; +import { CALM_ART } from './design-art'; +import { + bleedPage, + bleedSpread, + frameOf, + liveBox, + other, + pageX, + photoEl, + scaled, + type ComposeCtx, + type Side, +} from './design-compose'; +import { + badgeSpan, + coverText, + deepened, + inkOn, + lockupOn, + mmToPt, + pageKit, + printEl, + shapeEl, + spacedLine, + standOut, +} from './design-cover-kit'; +import type { DesignRng } from './design-random'; +import type { Composed } from './design-spreads'; +import { turnedBox } from './design-stickers'; + +/** + * The lively spreads (design-lively-pacing.ts decides where they go): a + * collage of turned, framed prints; one photo across the spread under a giant + * script word; a photo grid round a title badge; a row of photos as postage + * stamps under a postmark; a colour page in two tones holding the title + * beside a photo to the bleed. They set no story, so a chapter's words wait + * for its next page that takes words, as after a hero. + * + * Words on a picture bring their own ground (an outline, a band), words on a + * colour are set in an ink that reads on it, and no photo's detail is hidden + * under anything opaque: each page is as clean as the calm ones. + */ + +type Hand = 'left' | 'right'; + +const WHITE = '#ffffff'; +const OUTLINE_SHARE = 0.07; +const ptMm = (pt: number) => (pt * 25.4) / 72; + +/** + * The turn of a lively print, least and most: the book's own range, so a + * lively or loud page turns its prints at least the 3 degrees that read as + * laid on purpose, and a collage in a calm book (asked for by the brief or a + * page override) lies within the calm book's few degrees. + */ +function tilt(c: ComposeCtx): readonly [number, number] { + const [lo, hi] = (c.art ?? CALM_ART).tilt; + return [Math.max(lo, Math.min(1, hi)), hi]; +} + +/** The words of an entry a badge or a postmark sets, and the bindings that keep them in sync with the journal. */ +function entryWords( + c: ComposeCtx, + entry: AutoEntry | null, +): { words: BookLockupWords; bind: (w: string) => BookTextElement['binding'] } { + const place = entry?.location?.trim() ?? ''; + const title = entry?.title?.trim() ?? ''; + const date = entry?.date ? formatBookDate(entry.date, c.locale) : ''; + const words: BookLockupWords = { + title: place || title, + place: place || title, + dates: date, + kicker: date, + year: entry?.date?.slice(0, 4) ?? '', + }; + const bind = (w: string): BookTextElement['binding'] => { + if (!entry || !w) return null; + if (w === place) return { source: 'entry.location', entryId: entry.id }; + if (w === title) return { source: 'entry.title', entryId: entry.id }; + return date && w === date && entry.date ? { source: 'entry.date', entryId: entry.id, value: entry.date } : null; + }; + return { words, bind }; +} + +/** The shape of a print for a photo: its own proportions, 4:3 when unknown. */ +function aspectOf(photo: AutoPhoto): number { + return photo.width && photo.height ? photo.width / photo.height : 4 / 3; +} + +/** A lockup's parts made one group when they are several, as a cover's are, so the Studio moves the badge whole. */ +function grouped(c: ComposeCtx, parts: BookElement[]): BookElement[] { + if (parts.length < 2) return parts; + const group = c.newId('g'); + return parts.map((e) => ({ ...e, group })); +} + +/** A box moved inside another, as far as it fits. */ +function within(box: BookFrame, room: BookFrame, rotation: number): BookFrame { + const t = turnedBox(box, rotation); + const dx = Math.max(room.x - t.x, Math.min(0, room.x + room.w - (t.x + t.w))); + const dy = Math.max(room.y - t.y, Math.min(0, room.y + room.h - (t.y + t.h))); + return frameOf(box.x + dx, box.y + dy, box.w, box.h); +} + +/** Where the prints of a collage page lie, by how many: centre x, centre y and size, as shares of the live area. */ +const SCATTER: Readonly> = { + 1: [[0.5, 0.5, 0.78]], + 2: [ + [0.36, 0.34, 0.6], + [0.64, 0.68, 0.6], + ], + 3: [ + [0.3, 0.3, 0.52], + [0.72, 0.36, 0.5], + [0.48, 0.74, 0.54], + ], +}; + +const FRAMES: readonly BookPhotoElement['frameStyle'][] = ['white', 'polaroid', 'tape']; + +const focalOf = (p: BookPhotoElement): [number, number] => [ + p.frame.x + p.focalX * p.frame.w, + p.frame.y + p.focalY * p.frame.h, +]; + +const covers = (box: BookFrame, [x, y]: [number, number]) => + x >= box.x && x <= box.x + box.w && y >= box.y && y <= box.y + box.h; + +/** One page of a collage: up to three prints, turned either way, framed, overlapping a little, none over another's detail. */ +function scatter(c: ComposeCtx, list: readonly AutoPhoto[], side: Side, rng: DesignRng): BookPhotoElement[] { + const room = liveBox(c, side); + const slots = SCATTER[Math.min(3, list.length)] ?? []; + const [lo, hi] = tilt(c); + const short = Math.min(room.w, room.h); + const out: BookPhotoElement[] = []; + list.slice(0, 3).forEach((photo, i) => { + const [fx, fy, share] = slots[i] ?? [0.5, 0.5, 0.6]; + const turn = (i % 2 === 0 ? -1 : 1) * rng.range(lo, hi); + let size = share * short; + let placed: BookPhotoElement | null = null; + for (let tries = 0; tries < 4 && !placed; tries++) { + const aspect = aspectOf(photo); + const w = aspect >= 1 ? size : size * aspect; + const h = aspect >= 1 ? size / aspect : size; + const cx = room.x + room.w * fx + rng.range(-0.03, 0.03) * room.w; + const cy = room.y + room.h * fy + rng.range(-0.03, 0.03) * room.h; + const frame = within(frameOf(cx - w / 2, cy - h / 2, w, h), room, turn); + const el = { ...photoEl(c, photo, frame, { turn }), frameStyle: rng.pick(FRAMES) ?? 'white' }; + const box = turnedBox(el.frame, el.rotation); + if (out.every((under) => !covers(box, focalOf(under)))) placed = el; + else size *= 0.88; + } + if (placed) out.push(placed); + }); + return out; +} + +/** A collage: the photos as turned, framed prints over both pages, the fuller page leading. */ +export function composeCollage(c: ComposeCtx, photos: readonly AutoPhoto[], hand: Hand, rng: DesignRng): Composed { + const lead = Math.min(3, Math.ceil(photos.length / 2)); + const elements = [ + ...scatter(c, photos.slice(0, lead), hand, rng), + ...scatter(c, photos.slice(lead, lead + 3), other(hand), rng), + ]; + return { elements }; +} + +/** A line of words on a band of paper over a photo: a label that reads wherever it lies. */ +function label(c: ComposeCtx, words: string, frame: BookFrame, binding: BookTextElement['binding']): BookTextElement { + const paper = c.look.paper; + const text = spacedLine(pageKit(c), words, frame, inkOn(c, paper), 9); + const pad = scaled(c, 1.6); + return { ...text, binding, highlight: { color: paper, alpha: 1, padX: pad, padY: pad * 0.4, radius: 0 } }; +} + +/** + * Script overlay (reference 3): the photo across the spread, the detail kept + * off the fold, and on the other page a giant word of the place in the + * script face, turned a little and outlined so it reads on any part of the + * picture, with the title and the date on paper bands under it. + */ +export function composeScriptOverlay( + c: ComposeCtx, + entry: AutoEntry, + photo: AutoPhoto, + hand: Hand, + rng: DesignRng, +): Composed { + const sx = c.infos.get(photo.photoId)?.salient?.x; + const focalX = sx !== undefined && (sx < 0.4 || sx > 0.6) ? sx : 0.62; + const els: BookElement[] = [photoEl(c, photo, bleedSpread(c), { bleed: { side: 'left' }, focalX })]; + const box = liveBox(c, other(hand)); + const { words, bind } = entryWords(c, entry); + const word = words.place ?? ''; + const frame = frameOf(box.x + box.w * 0.03, box.y + box.h * 0.24, box.w * 0.94, box.h * 0.32); + let y = frame.y + frame.h + scaled(c, 8); + if (word) { + const turn = -rng.range(3, 8); + const set = coverText(pageKit(c), word, frame, { + slot: 'script', + max: mmToPt(frame.h * 0.75), + color: WHITE, + rotation: turn, + }); + const outline = { color: '#1a1a1a', width: Math.round(ptMm(set.size) * OUTLINE_SHARE * 100) / 100 }; + els.push({ ...set, outline, binding: bind(word) }); + const turned = turnedBox(set.frame, turn); + y = turned.y + turned.h + scaled(c, 6); + } + const lines = [entry.title?.trim() ?? '', words.dates ?? ''].filter((w) => w && w !== word); + for (const line of lines) { + const at = frameOf(box.x + box.w * 0.15, y, box.w * 0.7, scaled(c, 8)); + els.push(label(c, line, at, bind(line))); + y += scaled(c, 12); + } + return { elements: els }; +} + +/** + * Two-tone: the photo to the bleed of one page, the other a block of the + * accent over a band of a second colour, the chapter's title set large on + * the block with the date over it and the place on the band. + */ +export function composeTwoTone( + c: ComposeCtx, + entry: AutoEntry, + photo: AutoPhoto, + hand: Hand, + rng: DesignRng, +): Composed { + const pk = pageKit(c); + const { pageHeight: H } = c.grid; + const side = other(hand); + const page = bleedPage(c, side); + const split = H * rng.range(0.6, 0.68); + const top = deepened(c.look.accent); + const low = deepened(standOut(c, top)); + const els: BookElement[] = [ + photoEl(c, photo, bleedPage(c, hand), { bleed: { side: hand } }), + shapeEl(pk, 'rect', frameOf(page.x, page.y, page.w, split - page.y), top), + shapeEl(pk, 'rect', frameOf(page.x, split, page.w, page.y + page.h - split), low), + ]; + const box = liveBox(c, side); + const { words, bind } = entryWords(c, entry); + if (words.dates) + els.push({ + ...spacedLine(pk, words.dates, frameOf(box.x, box.y, box.w, scaled(c, 8)), inkOn(c, top), 9), + binding: bind(words.dates), + }); + const heading = entry.title?.trim() || entry.location?.trim() || ''; + const headTop = box.y + scaled(c, 14); + const headH = split - scaled(c, 10) - headTop; + if (heading && headH > scaled(c, 20)) { + const max = Math.min(c.look.sizes.heading * 2.4, mmToPt(headH * 0.6)); + const set = coverText(pk, heading, frameOf(box.x, headTop, box.w, headH), { + slot: 'display', + max, + color: inkOn(c, top), + align: 'left', + leading: 1, + }); + els.push({ ...set, binding: bind(heading) }); + } + const place = entry.title?.trim() && entry.location?.trim() ? entry.location.trim() : ''; + if (place) + els.push({ + ...spacedLine(pk, place, frameOf(box.x, split + scaled(c, 8), box.w, scaled(c, 8)), inkOn(c, low), 9), + binding: bind(place), + }); + return { elements: els }; +} + +/** The cells of a grid page: two by two to the bleed of a page, a gutter between them, stopping short of the fold. */ +function quarterCells(c: ComposeCtx, side: Side, g: number): BookFrame[] { + const { pageWidth: PW, pageHeight: H, bleed: b } = c.grid; + const x0 = side === 'right' ? PW + g / 2 : -b; + const x1 = side === 'right' ? PW * 2 + b : PW - g / 2; + const mid = pageX(c, side) + PW / 2; + const cols = [frameOf(x0, 0, mid - g / 2 - x0, 1), frameOf(mid + g / 2, 0, x1 - mid - g / 2, 1)]; + const rows = [frameOf(0, -b, 1, (H - g) / 2 + b), frameOf(0, (H + g) / 2, 1, (H - g) / 2 + b)]; + return rows.flatMap((r) => cols.map((col) => frameOf(col.x, r.y, col.w, r.h))); +} + +/** Two cells to the bleed of a page, one over the other. */ +function halfCells(c: ComposeCtx, side: Side, g: number): BookFrame[] { + const { pageWidth: PW, pageHeight: H, bleed: b } = c.grid; + const x0 = side === 'right' ? PW + g / 2 : -b; + const w = PW + b - g / 2; + return [frameOf(x0, -b, w, (H - g) / 2 + b), frameOf(x0, (H + g) / 2, w, (H - g) / 2 + b)]; +} + +const cell = (c: ComposeCtx, photo: AutoPhoto, frame: BookFrame): BookPhotoElement => ({ + ...photoEl(c, photo, frame), + frameStyle: 'none', + rotation: 0, +}); + +/** + * Grid and badge: four photos to the bleed two by two on one page (two on + * each page when there are only four) with a gutter of paper between them, + * the rest framed on the other page, and a round badge at the leading page's + * centre with the place, the date and the year, kept off every photo's detail. + */ +export function composeGridBadge( + c: ComposeCtx, + photos: readonly AutoPhoto[], + entry: AutoEntry | null, + hand: Hand, + rng: DesignRng, +): Composed { + const { pageWidth: PW, pageHeight: H } = c.grid; + const g = scaled(c, rng.range(4, 6)); + const cells = + photos.length <= 4 ? [...halfCells(c, hand, g), ...halfCells(c, other(hand), g)] : quarterCells(c, hand, g); + const grid = photos.slice(0, 4).flatMap((p, i) => (cells[i] ? [cell(c, p, cells[i] as BookFrame)] : [])); + const rest = photos.slice(4, 6); + const box = liveBox(c, other(hand)); + const framed = rest.map((p, i) => { + const h = rest.length > 1 ? (box.h - c.grid.gutter) / 2 : box.h * 0.7; + const y = rest.length > 1 ? box.y + i * (h + c.grid.gutter) : box.y + (box.h - h) / 2; + return photoEl(c, p, frameOf(box.x, y, box.w, h)); + }); + const els: BookElement[] = [...grid, ...framed]; + const centre: [number, number] = [pageX(c, hand) + PW / 2, H / 2]; + const d = badgeSpan(grid, centre, PW * 0.4, PW * 0.24); + const { words, bind } = entryWords(c, entry); + const at = { + id: 'circle-badge', + box: frameOf(centre[0] - d / 2, centre[1] - d / 2, d, d), + ground: c.look.paper, + words, + }; + els.push(...grouped(c, lockupOn(c, at, { display: words.title, bind }))); + return { elements: els }; +} + +/** + * Stamp row: the photos as postage stamps, perforated in a white border, in + * a row on each page over a band of colour across the spread, each a little + * askew, and a round postmark with the place and the date turned on the + * corner of the second page. + */ +export function composeStampRow( + c: ComposeCtx, + photos: readonly AutoPhoto[], + entry: AutoEntry | null, + hand: Hand, + rng: DesignRng, +): Composed { + const pk = pageKit(c); + const { pageWidth: PW, pageHeight: H, bleed: b, margin: m, gutter: g } = c.grid; + const d = PW * 0.26; + const lead = Math.ceil(photos.length / 2); + const rows: [Side, readonly AutoPhoto[]][] = [ + [hand, photos.slice(0, lead)], + [other(hand), photos.slice(lead)], + ]; + const most = Math.max(1, lead); + // A stamp is a small print: at most two fifths of the page wide, however few there are. + const w = Math.min(PW * 0.4, (PW - m * 2 - (most - 1) * g * 1.5) / most); + const h = Math.min(w * 1.25, H - m * 2 - d - scaled(c, 6)); + const top = m + (H - m * 2 - d - scaled(c, 6) - h) / 2; + const band = shapeEl(pk, 'rect', frameOf(-b, top + h * 0.2, PW * 2 + b * 2, h * 0.6), standOut(c, c.look.paper)); + const els: BookElement[] = [band]; + // Each stamp a little askew, as the book turns its prints: a row stays a row. + const [lo, hi] = tilt(c); + for (const [side, list] of rows) { + const box = liveBox(c, side); + const span = list.length * w + (list.length - 1) * g * 1.5; + list.forEach((photo, i) => { + const frame = frameOf(box.x + (box.w - span) / 2 + i * (w + g * 1.5), top, w, h); + const look = { + turn: rng.sign() * rng.range(lo, Math.min(hi, lo + 1.5)), + edge: { edge: 'perforated' as const, size: scaled(c, 1.4) }, + border: { width: scaled(c, 2), color: WHITE }, + }; + els.push(printEl(pk, photo, frame, look)); + }); + } + const { words, bind } = entryWords(c, entry); + const corner = liveBox(c, other(hand)); + const at = { + id: 'postmark', + box: frameOf(corner.x + corner.w - d, corner.y + corner.h - d, d, d), + ground: c.look.paper, + words, + turn: -rng.range(6, 14), + }; + els.push(...grouped(c, lockupOn(c, at, { display: words.title, bind }))); + return { elements: els }; +} diff --git a/shared/src/book/design/design-overrides.spec.ts b/shared/src/book/design/design-overrides.spec.ts new file mode 100644 index 000000000..b2f494fae --- /dev/null +++ b/shared/src/book/design/design-overrides.spec.ts @@ -0,0 +1,105 @@ +import type { BookDocument, BookSpread } from '../book.schema'; +import { pageFindings } from '../describe-spread'; +import { BOOK_STICKERS } from '../stickers/sticker-catalogue'; +import { designBook, type BookDesignInput, type BookDesignResult } from './design-book'; +import type { BookDesignDirectivesInput } from './design-directives.schema'; + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** + * `pages.overrides` (design-overrides.ts): a page of the plan given another + * beat is composed again from its own photos, in its place and under its id, + * the rest of the design untouched; another layout is laid by the page edit's + * own `layout` operation with ids from the design's counter; what cannot be + * done is said in the notes and the page stays as designed. + */ + +const here = (file: string) => fileURLToPath(new URL(file, import.meta.url)); +const fixtures = JSON.parse(readFileSync(here('./design-book.fixtures.json'), 'utf8')) as { + rich: { input: BookDesignInput }; +}; + +function input(): BookDesignInput { + let n = 0; + return { ...fixtures.rich.input, newId: (p) => `${p}-${++n}`, stickers: BOOK_STICKERS }; +} + +const design = (directives: BookDesignDirectivesInput, doc: BookDocument | null = null): BookDesignResult => + designBook(doc, input(), directives); + +const STYLE = { style: 'riviera' } as const; +const photoIds = (s: BookSpread | undefined) => + (s?.elements ?? []) + .flatMap((e) => (e.kind === 'photo' && e.photoId != null ? [e.photoId] : [])) + .sort((a, b) => a - b); + +const base = design(STYLE); +/** The first page of photos only, a duo: no story, two photos. */ +const duo = base.plan.find((r) => r.beat === 'duo'); +/** The first page that sets a story. */ +const story = base.plan.find((r) => r.beat === 'opener'); + +describe('page overrides', () => { + it('DSNOVR-001: composes a page again as the beat asked, in its place, under its id, from its own photos', () => { + expect(duo).toBeDefined(); + const at = duo?.index ?? 0; + const r = design({ ...STYLE, pages: { overrides: [{ index: at, beat: 'stamp-row' }] } }); + const page = r.doc.spreads[at]; + expect(r.plan[at]?.beat).toBe('stamp-row'); + expect(page?.id).toBe(base.doc.spreads[at]?.id); + expect(photoIds(page)).toEqual(photoIds(base.doc.spreads[at])); + const photos = (page?.elements ?? []).filter((e) => e.kind === 'photo'); + expect(photos.every((e) => e.kind === 'photo' && e.edge === 'perforated')).toBe(true); + // Themed and repaired like every designed page, and nothing else of the design moves. + expect( + pageFindings(page as BookSpread, r.doc.page, fixtures.rich.input.photos).filter((f) => f.severity !== 'info'), + ).toEqual([]); + expect(r.plan[at]?.findings).toEqual({ severe: 0, warn: 0 }); + r.doc.spreads.forEach((s, i) => { + if (i !== at) expect(JSON.stringify(s)).toBe(JSON.stringify(base.doc.spreads[i])); + }); + expect(r.notes).toEqual(base.notes); + }); + + it('DSNOVR-002: lays a page out on the layout asked through the layout operation, with ids of the design', () => { + const at = duo?.index ?? 0; + const r = design({ ...STYLE, pages: { overrides: [{ index: at, template: 'two-up' }] } }); + const page = r.doc.spreads[at] as BookSpread; + expect(photoIds(page)).toEqual(photoIds(base.doc.spreads[at])); + expect(page.elements.every((e) => /^[a-z]+-\d+$/.test(e.id))).toBe(true); + expect(r.plan[at]?.beat).toBe('duo'); + expect(JSON.stringify(design({ ...STYLE, pages: { overrides: [{ index: at, template: 'two-up' }] } }).doc)).toBe( + JSON.stringify(r.doc), + ); + }); + + it('DSNOVR-003: says why it leaves a page as designed: a story, a single page, too few photos, a kept page', () => { + const last = base.doc.spreads.length - 2; + const at = duo?.index ?? 0; + const r = design({ + ...STYLE, + pages: { + overrides: [ + { index: story?.index ?? 0, beat: 'collage' }, + { index: last, beat: 'duo' }, + { index: at, beat: 'grid' }, + ], + }, + }); + expect(r.notes).toEqual([ + `page ${story?.index} sets a story, which a collage page does not, so it stays as designed`, + `page ${last} is not a double page of the design, so its override is left out`, + `page ${at} holds 2 photos, too few for a grid page`, + ]); + expect(JSON.stringify(r.doc)).toBe(JSON.stringify(base.doc)); + + const stored = structuredClone(base.doc); + const locked = stored.spreads[at] as BookSpread; + stored.spreads[at] = { ...locked, elements: locked.elements.map((e, i) => (i === 0 ? { ...e, locked: true } : e)) }; + const kept = design({ ...STYLE, pages: { overrides: [{ index: at, beat: 'collage' }] } }, stored); + expect(kept.plan[at]?.action).toBe('keep'); + expect(kept.notes).toContain(`page ${at} is kept as it was, so its override is left out`); + }); +}); diff --git a/shared/src/book/design/design-overrides.ts b/shared/src/book/design/design-overrides.ts new file mode 100644 index 000000000..6d955ffee --- /dev/null +++ b/shared/src/book/design/design-overrides.ts @@ -0,0 +1,172 @@ +import type { AutoEntry, AutoPhoto } from '../auto/auto-types'; +import { applyBookOps } from '../book-ops'; +import type { BookOpsContext } from '../book-ops-core'; +import type { BookFinding } from '../book-ops.schema'; +import type { BookDocument, BookSpread } from '../book.schema'; +import type { BookDesignOverrideBeat, BookDesignPageOverride } from './design-brief.schema'; +import type { ComposeCtx } from './design-compose'; +import { composeLively } from './design-lively-pacing'; +import type { BookDesignPlanRow } from './design-merge'; +import type { PlannedSpread } from './design-pacing'; +import { composeDuo, composeGrid, composeHero, composeTrio, type Composed } from './design-spreads'; +import { decorateSpread } from './design-stickers'; + +/** + * Pages of a design given another beat or another layout by the directives + * (`pages.overrides`), after the design is made: the page keeps its place, + * its id, its chapter and its photos, and is composed again as the beat asks + * from those photos and its entry's words, or laid out on the template by the + * page edit's own `layout` operation, so a layout the engine learns there is + * one an override may name. Then it is themed and repaired like every other + * designed page. + * + * An override that cannot be honoured is said in the notes and the page stays + * as designed: a page the design keeps as it was, a single page, one that + * sets a story (no recomposed beat sets one), or too few photos for the beat. + */ + +interface OverrideEnv { + c: ComposeCtx; + entries: readonly AutoEntry[]; + /** The engine's own finish for a designed page: themed, repaired, its weights real. */ + finish: (spread: BookSpread) => { spread: BookSpread; findings: BookFinding[] }; + ops: BookOpsContext; +} + +/** The photos a beat needs at least. */ +const LEAST: Readonly> = { + collage: 1, + 'script-overlay': 1, + 'grid-badge': 2, + 'stamp-row': 1, + 'two-tone': 1, + hero: 1, + duo: 2, + trio: 3, + grid: 4, +}; + +/** The beats that need the page's entry for their words. */ +const NEEDS_ENTRY: ReadonlySet = new Set(['script-overlay', 'two-tone', 'hero']); + +/** The photos of a page as the composers take them, in paint order. */ +function photosOf(c: ComposeCtx, spread: BookSpread): AutoPhoto[] { + return spread.elements.flatMap((e) => { + if (e.kind !== 'photo' || e.photoId == null) return []; + const info = c.infos.get(e.photoId); + return [{ photoId: e.photoId, width: info?.width ?? null, height: info?.height ?? null }]; + }); +} + +/** Whether a page sets an entry's story, which none of the beats an override names sets again. */ +const setsStory = (spread: BookSpread) => + spread.elements.some((e) => e.kind === 'text' && e.binding?.source === 'entry.story'); + +/** The page's elements composed as a classic photo beat, or null when the beat needs what the page lacks. */ +function classic( + c: ComposeCtx, + beat: BookDesignOverrideBeat, + plan: PlannedSpread, + hand: 'left' | 'right', +): Composed | null { + const entryId = plan.entry?.id ?? null; + const [first] = plan.photos; + if (beat === 'hero') return plan.entry && first ? composeHero(c, plan.entry, first, hand) : null; + if (beat === 'duo') return composeDuo(c, plan.photos, hand, entryId); + if (beat === 'trio') return composeTrio(c, plan.photos, hand, entryId); + return beat === 'grid' ? composeGrid(c, plan.photos.slice(0, 6), hand, entryId) : null; +} + +/** A page composed again as `beat`, or why it cannot be. */ +function recomposed( + env: OverrideEnv, + spread: BookSpread, + beat: BookDesignOverrideBeat, + index: number, +): BookSpread | string { + const { c } = env; + if (setsStory(spread)) return `page ${index} sets a story, which a ${beat} page does not, so it stays as designed`; + const photos = photosOf(c, spread); + if (photos.length < LEAST[beat]) return `page ${index} holds ${photos.length} photos, too few for a ${beat} page`; + const entry = env.entries.find((e) => e.id === spread.entryId) ?? null; + if (NEEDS_ENTRY.has(beat) && !entry) + return `page ${index} belongs to no entry, so a ${beat} page has no words for it`; + const chapter = { key: `o${index}`, label: null, entries: entry ? [entry] : [], photos, stations: false, burst: [] }; + const plan: PlannedSpread = { beat, chapter, entry, photos }; + const hand = index % 2 === 0 ? 'left' : 'right'; + const made = composeLively(c, plan, hand, index) ?? classic(c, beat, plan, hand); + if (!made) return `page ${index} could not be composed as a ${beat} page`; + const page: BookSpread = { ...spread, elements: made.elements, groups: undefined }; + return decorateSpread(c, page, `deco:o${index}`); +} + +/** A page laid out on a template by the `layout` operation, or why it cannot be. */ +function laidOut( + env: OverrideEnv, + doc: BookDocument, + spread: BookSpread, + template: string, + index: number, +): BookSpread | string { + const spreads = doc.spreads.map((s) => (s.id === spread.id ? spread : s)); + const done = applyBookOps({ ...doc, spreads }, { spreadId: spread.id, ops: [{ op: 'layout', template }] }, env.ops); + if (done.ok === false) return `page ${index} cannot take the layout ${template}: ${done.error.message}`; + const laid = done.doc.spreads.find((s) => s.id === spread.id) ?? spread; + // A layout mints ids of its own; the design's counter mints them again, so one journey designs one book. + const elements = laid.elements.map((e) => ({ ...e, id: env.c.newId(e.kind.charAt(0)) })); + return { ...laid, elements }; +} + +const count = (findings: readonly BookFinding[], severity: BookFinding['severity']) => + findings.filter((f) => f.severity === severity).length; + +/** The photos a page shows, as a plan row names them. */ +const photoIdsOf = (spread: BookSpread) => + spread.elements.flatMap((e) => (e.kind === 'photo' && e.photoId != null ? [e.photoId] : [])); + +/** The design with its overridden pages composed again, its plan following them, and what could not be done. */ +export function applyPageOverrides( + doc: BookDocument, + plan: readonly BookDesignPlanRow[], + overrides: readonly BookDesignPageOverride[], + env: OverrideEnv, +): { doc: BookDocument; plan: BookDesignPlanRow[]; notes: string[] } { + const notes: string[] = []; + const spreads = [...doc.spreads]; + const rows = [...plan]; + for (const o of overrides) { + const spread = spreads[o.index]; + const at = rows.findIndex((r) => r.index === o.index && r.action !== 'drop'); + const row = rows[at]; + if (!spread || !row || spread.role !== 'inner') { + notes.push(`page ${o.index} is not a double page of the design, so its override is left out`); + continue; + } + if (row.action === 'keep') { + notes.push(`page ${o.index} is kept as it was, so its override is left out`); + continue; + } + let next = spread; + let beat = row.beat; + if (o.beat) { + const made = recomposed(env, next, o.beat, o.index); + if (typeof made === 'string') notes.push(made); + else [next, beat] = [made, o.beat]; + } + if (o.template) { + const laid = laidOut(env, { ...doc, spreads }, next, o.template, o.index); + if (typeof laid === 'string') notes.push(laid); + else next = laid; + } + if (next === spread) continue; + const finished = env.finish(next); + spreads[o.index] = finished.spread; + rows[at] = { + ...row, + beat, + photoIds: photoIdsOf(finished.spread), + findings: { severe: count(finished.findings, 'severe'), warn: count(finished.findings, 'warn') }, + }; + } + return { doc: { ...doc, spreads }, plan: rows, notes }; +} diff --git a/shared/src/book/design/design-pacing.ts b/shared/src/book/design/design-pacing.ts index 0835f9dde..cf9222cf0 100644 --- a/shared/src/book/design/design-pacing.ts +++ b/shared/src/book/design/design-pacing.ts @@ -3,6 +3,7 @@ import type { BookPhotoInfo } from '../book-ops-core'; import { bookEntryListItems } from '../book-travel'; import type { BookPageSetup } from '../book.schema'; import { pickBookPhotos, type PhotoPickCandidate } from '../photo-pick'; +import { BOOK_DESIGN_LIVELY_BEATS } from './design-brief.schema'; import type { BookDesignDirectives } from './design-directives.schema'; import { burstOf, heroWorthy, isPanorama, pickEntriesOf, type BookDesignLeftOut } from './design-photos'; @@ -12,7 +13,8 @@ import { burstOf, heroWorthy, isPanorama, pickEntriesOf, type BookDesignLeftOut * words that run long, two, three or a grid of photos, a burst as one grid, a * panorama), paced so the book breathes: never one photo beat three times * running, a loud spread followed by a calm one, at most one hero in four - * spreads. + * spreads. A lively or loud book makes some of them lively afterwards + * (design-lively-pacing.ts). */ export const BOOK_DESIGN_BEATS = [ @@ -33,6 +35,7 @@ export const BOOK_DESIGN_BEATS = [ 'closing', 'last', 'back', + ...BOOK_DESIGN_LIVELY_BEATS, ] as const; export type BookDesignBeat = (typeof BOOK_DESIGN_BEATS)[number]; diff --git a/shared/src/book/design/design-random.ts b/shared/src/book/design/design-random.ts new file mode 100644 index 000000000..11338343c --- /dev/null +++ b/shared/src/book/design/design-random.ts @@ -0,0 +1,65 @@ +/** + * The design engine's seeded variation: which edge a band sits on, how far a + * print is turned, which sticker a page takes. Never random: the same seed and + * the same key give the same numbers on every run and on every machine, so a + * design is reproducible and another `seed` gives another, equally valid one. + * + * FNV-1a over the seed and a key that names the decision, then mulberry32. Not + * for anything that has to be unpredictable: it only spreads choices evenly. + */ + +export interface DesignRng { + /** The next number in [0, 1). */ + next(): number; + /** A number in [lo, hi). */ + range(lo: number, hi: number): number; + /** A whole number from lo to hi, both included. */ + int(lo: number, hi: number): number; + /** One item of a list, or undefined for an empty one. */ + pick(list: readonly T[]): T | undefined; + /** The list in a shuffled order, the list itself left as it is. */ + shuffle(list: readonly T[]): T[]; + /** True with probability p. */ + chance(p: number): boolean; + /** 1 or -1. */ + sign(): 1 | -1; +} + +function hash(seed: number, key: string): number { + let h = (2166136261 ^ seed) >>> 0; + for (let i = 0; i < key.length; i++) { + h ^= key.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + return h; +} + +/** The numbers of one decision of a design, from its seed and a key naming the decision. */ +export function designRng(seed: number, key: string): DesignRng { + let state = hash(seed, key); + const next = () => { + state = (state + 0x6d2b79f5) | 0; + let t = Math.imul(state ^ (state >>> 15), 1 | state); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + const rng: DesignRng = { + next, + range: (lo, hi) => lo + (hi - lo) * next(), + int: (lo, hi) => lo + Math.floor(next() * (hi - lo + 1)), + pick: (list) => (list.length ? list[Math.floor(next() * list.length)] : undefined), + shuffle: (list) => { + const out = [...list]; + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(next() * (i + 1)); + const held = out[i] as (typeof out)[number]; + out[i] = out[j] as (typeof out)[number]; + out[j] = held; + } + return out; + }, + chance: (p) => next() < p, + sign: () => (next() < 0.5 ? -1 : 1), + }; + return rng; +} diff --git a/shared/src/book/design/design-stickers.ts b/shared/src/book/design/design-stickers.ts new file mode 100644 index 000000000..59dd86a92 --- /dev/null +++ b/shared/src/book/design/design-stickers.ts @@ -0,0 +1,201 @@ +import { bookStickerElement } from '../book-sticker'; +import { + MAX_SPREAD_ELEMENTS, + type BookElement, + type BookFrame, + type BookSpread, + type BookStickerElement, +} from '../book.schema'; +import { CALM_ART, STICKERS_PER_SPREAD, type BookDesignSticker } from './design-art'; +import { frameOf, pageX, type ComposeCtx, type Side } from './design-compose'; +import { designRng, type DesignRng } from './design-random'; + +/** + * Stickers laid on a designed page the way a person would stick them: on the + * corner of a print, half over it, or in a free patch of paper, never over + * words, never over the detail of a photo, never in the fold or the safe + * margin, never on another sticker. Which drawing goes where, its size and its + * turn come from the design's seed, so a page is decorated the same way every + * time and differently with another seed. + * + * The drawings come from the theme's motifs (design-art.ts). A page with no + * free spot for a sticker takes fewer than asked, never one in a bad place. + */ + +/** The kinds a sticker keeps clear of: words, figures and maps, and the other stickers. */ +const CLEAR = new Set(['text', 'badge', 'stats', 'list', 'countries', 'icon', 'map', 'sticker']); + +/** Millimetres kept between a sticker and what it keeps clear of, and off the fold. */ +const PAD = 2; +const FOLD_GAP = 5; +/** A sticker covers at most this share of any one photo. */ +const MAX_PHOTO_COVER = 0.3; + +const r2 = (v: number) => Math.round(v * 100) / 100; + +/** The box an element covers, turned with it. */ +export function turnedBox(frame: BookFrame, rotation: number): BookFrame { + if (!rotation) return frame; + const rad = (rotation * Math.PI) / 180; + const w = frame.w * Math.abs(Math.cos(rad)) + frame.h * Math.abs(Math.sin(rad)); + const h = frame.w * Math.abs(Math.sin(rad)) + frame.h * Math.abs(Math.cos(rad)); + return { x: frame.x + frame.w / 2 - w / 2, y: frame.y + frame.h / 2 - h / 2, w, h }; +} + +/** The area two boxes share. */ +export function sharedArea(a: BookFrame, b: BookFrame): number { + const w = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x); + const h = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y); + return w > 0 && h > 0 ? w * h : 0; +} + +const grown = (b: BookFrame, by: number): BookFrame => ({ x: b.x - by, y: b.y - by, w: b.w + by * 2, h: b.h + by * 2 }); +const holds = (b: BookFrame, px: number, py: number) => px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h; + +/** Where a sticker may lie on one page: inside the safe margin, off the fold. */ +function roomOn(c: ComposeCtx, side: Side): BookFrame { + const { pageWidth: PW, pageHeight: H, safe } = c.grid; + const x0 = pageX(c, side) + safe + 1; + const x1 = pageX(c, side) + PW - safe - 1; + const left = side === 'right' ? Math.max(x0, PW + FOLD_GAP) : x0; + const right = side === 'left' ? Math.min(x1, PW - FOLD_GAP) : x1; + return { x: left, y: safe + 1, w: right - left, h: H - safe * 2 - 2 }; +} + +const inside = (room: BookFrame, b: BookFrame) => + b.x >= room.x && b.y >= room.y && b.x + b.w <= room.x + room.w && b.y + b.h <= room.y + room.h; + +/** The points a sticker may be centred on, best first: the corners of the photos, then a lattice of free paper. */ +function spots(c: ComposeCtx, elements: readonly BookElement[], room: BookFrame, rng: DesignRng): [number, number][] { + const corners: [number, number][] = []; + for (const el of elements) { + if (el.kind !== 'photo' || el.photoId == null) continue; + const { x, y, w, h } = el.frame; + corners.push([x, y], [x + w, y], [x, y + h], [x + w, y + h]); + } + const lattice: [number, number][] = []; + for (let i = 0; i < 4; i++) { + for (let j = 0; j < 4; j++) lattice.push([room.x + (room.w * (i + 0.5)) / 4, room.y + (room.h * (j + 0.5)) / 4]); + } + const onPage = corners.filter(([x, y]) => holds(room, x, y)); + return [...rng.shuffle(onPage), ...rng.shuffle(lattice)]; +} + +interface Obstacles { + clear: BookFrame[]; + photos: { box: BookFrame; focal: [number, number] }[]; +} + +function obstaclesOf(elements: readonly BookElement[], avoid: readonly BookFrame[]): Obstacles { + const clear = [...avoid]; + const photos: Obstacles['photos'] = []; + for (const el of elements) { + if (el.hidden) continue; + const box = turnedBox(el.frame, el.rotation); + if (CLEAR.has(el.kind)) clear.push(grown(box, PAD)); + if (el.kind === 'photo' && el.photoId != null) { + photos.push({ box, focal: [el.frame.x + el.focalX * el.frame.w, el.frame.y + el.focalY * el.frame.h] }); + } + } + return { clear, photos }; +} + +/** Whether a sticker box sits well: in its room, clear of words and other stickers, off every photo's detail. */ +function fits(box: BookFrame, room: BookFrame, ob: Obstacles): boolean { + if (!inside(room, box)) return false; + if (ob.clear.some((b) => sharedArea(b, box) > 0)) return false; + return ob.photos.every((p) => { + if (holds(grown(box, 3), p.focal[0], p.focal[1])) return false; + return sharedArea(p.box, box) <= p.box.w * p.box.h * MAX_PHOTO_COVER; + }); +} + +interface StickerRequest { + /** How many to place at most. */ + count: number; + /** The longest side as a share of the page's shorter side, least and most. */ + size: readonly [number, number]; + /** The pages to place them on. */ + sides: readonly Side[]; + /** The decision's key, for the seed (design-random.ts). */ + key: string; + /** Boxes kept clear besides the words: a title zone, a badge. */ + avoid?: readonly BookFrame[]; + /** Ids the book used already: taken last, so a book repeats a drawing only when it runs out. */ + used?: Set; + /** The drawings to choose from; the design's motif stickers when left out. */ + from?: readonly BookDesignSticker[]; +} + +/** The order the drawings are tried in: shuffled, the ones the book has not used yet first. */ +function drawingsFor(pool: readonly BookDesignSticker[], used: Set | undefined, rng: DesignRng) { + const shuffled = rng.shuffle(pool); + return used ? [...shuffled.filter((s) => !used.has(s.id)), ...shuffled.filter((s) => used.has(s.id))] : shuffled; +} + +/** The size and turn of a sticker drawn at `share` of the page, its frame centred on a point. */ +function frameAt(box: readonly [number, number], longest: number, at: [number, number]): BookFrame { + const k = longest / Math.max(box[0], box[1], 1); + const w = box[0] * k; + const h = box[1] * k; + return frameOf(at[0] - w / 2, at[1] - h / 2, w, h); +} + +/** + * Up to `count` stickers for a page holding `elements`, to be painted on top + * of them, each on a spot that fits. Fewer when the page has no room, the + * library none for the book's motifs, or the page is at its element cap. + */ +export function placeStickers( + c: ComposeCtx, + elements: readonly BookElement[], + request: StickerRequest, +): BookStickerElement[] { + const art = c.art ?? CALM_ART; + const pool = request.from ?? art.stickers; + const room = MAX_SPREAD_ELEMENTS - elements.length; + const count = Math.min(request.count, room, pool.length); + if (count <= 0 || art.bare) return []; + const rng = designRng(art.seed, request.key); + // Turned as the book turns its prints: a calm book's stickers lie nearly straight. + const [tiltLo, tiltHi] = art.tilt; + const short = Math.min(c.grid.pageWidth, c.grid.pageHeight); + const out: BookStickerElement[] = []; + const drawings = drawingsFor(pool, request.used, rng); + for (let i = 0; i < count; i++) { + const side = request.sides[i % request.sides.length] as Side; + const pageRoom = roomOn(c, side); + const ob = obstaclesOf([...elements, ...out], request.avoid ?? []); + const drawing = drawings[i % drawings.length] as BookDesignSticker; + const turn = r2(rng.sign() * rng.range(tiltLo, tiltHi)); + const longest = rng.range(request.size[0], request.size[1]) * short; + const at = spots(c, elements, pageRoom, rng).find((p) => + fits(turnedBox(frameAt(drawing.box, longest, p), turn), pageRoom, ob), + ); + if (!at) continue; + const sticker = bookStickerElement(drawing, frameAt(drawing.box, longest, at), c.newId('k')); + out.push({ ...sticker, rotation: turn, flipX: rng.chance(0.3) }); + request.used?.add(drawing.id); + } + return out; +} + +/** How many stickers a spread takes at the design's decoration level, from its seed. */ +export function stickersFor(c: ComposeCtx, key: string): number { + const art = c.art ?? CALM_ART; + const [lo, hi] = STICKERS_PER_SPREAD[art.decoration]; + return hi === 0 ? 0 : designRng(art.seed, key).int(lo, hi); +} + +/** + * An inner spread decorated at the design's level: its stickers on both + * pages, painted over what is there. The spread as it was when the design is + * not decorated or finds no room. + */ +export function decorateSpread(c: ComposeCtx, spread: BookSpread, key: string): BookSpread { + const count = stickersFor(c, key); + if (!count) return spread; + const sides: Side[] = spread.role === 'inner' ? ['left', 'right'] : ['single']; + const made = placeStickers(c, spread.elements, { count, size: [0.14, 0.22], sides, key, used: c.art?.used }); + return made.length ? { ...spread, elements: [...spread.elements, ...made] } : spread; +} diff --git a/shared/src/book/stickers/sticker-catalogue.ts b/shared/src/book/stickers/sticker-catalogue.ts index aa770d00f..0c8f98713 100644 --- a/shared/src/book/stickers/sticker-catalogue.ts +++ b/shared/src/book/stickers/sticker-catalogue.ts @@ -1,3 +1,4 @@ +import { rankAssets, type AssetKey } from '../book-asset-search'; import type { BookStickerLookup } from '../book-sticker'; import { ASIA_STICKERS } from './data/asia.data'; import { DOODLES_STICKERS } from './data/doodles.data'; @@ -62,30 +63,10 @@ export function bookStickersOf(theme: BookStickerTheme): BookStickerDef[] { return BOOK_STICKERS.filter((s) => s.theme === theme); } -const norm = (s: string) => s.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, ''); - -/** The words a sticker is found by: its id split at the capitals, its tags, its theme and its description. */ -function haystack(s: BookStickerDef): string { +/** The words a sticker is found by: its id split at the capitals, its theme, its tags and its description. */ +function keyOf(s: BookStickerDef): AssetKey { const idWords = s.id.replace(/([a-z])([A-Z])/g, '$1 $2'); - return norm([s.id, idWords, s.theme, ...s.tags, s.descr].join(' ')); -} - -/** - * How well a sticker answers a query: every word of the query has to be found, - * and a word that is a tag or the id itself counts for more than one buried in - * the description, so "lemon" lists the lemon before the vase of lemons. - */ -function score(s: BookStickerDef, words: readonly string[]): number { - const hay = haystack(s); - const tags = s.tags.map(norm); - let total = 0; - for (const w of words) { - if (!hay.includes(w)) return 0; - if (norm(s.id) === w || tags.includes(w)) total += 3; - else if (tags.some((t) => t.split(' ').includes(w)) || norm(s.id).startsWith(w)) total += 2; - else total += 1; - } - return total; + return { id: s.id, words: [s.id, idWords, s.theme, ...s.tags, s.descr], tags: s.tags }; } export interface BookStickerSearch { @@ -95,22 +76,15 @@ export interface BookStickerSearch { limit?: number; } -/** Stickers matching a search, best first, library order between equals. */ +/** + * Stickers matching a search, best first, library order between equals: + * every word of the query has to be found, and a word that is a tag or the id + * itself counts for more than one buried in the description, so "lemon" lists + * the lemon before the vase of lemons (book-asset-search.ts). + */ export function searchBookStickers(search: BookStickerSearch = {}): BookStickerDef[] { - const words = norm(search.query ?? '') - .split(/\s+/) - .filter(Boolean); - const tags = (search.tags ?? []).map(norm); - const pool = BOOK_STICKERS.filter( - (s) => (!search.theme || s.theme === search.theme) && tags.every((t) => s.tags.map(norm).includes(t)), - ); - const ranked = words.length - ? pool - .map((s, i) => ({ s, i, n: score(s, words) })) - .filter((r) => r.n > 0) - .sort((a, b) => b.n - a.n || a.i - b.i) - .map((r) => r.s) - : pool; + const pool = BOOK_STICKERS.filter((s) => !search.theme || s.theme === search.theme); + const ranked = rankAssets(pool, keyOf, search.query, search.tags); return ranked.slice(0, search.limit ?? ranked.length); } diff --git a/shared/src/i18n/ar/assistantBookArt.ts b/shared/src/i18n/ar/assistantBookArt.ts new file mode 100644 index 000000000..e6a7c908a --- /dev/null +++ b/shared/src/i18n/ar/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'غلاف بلا ما يلفت النظر', + 'assistant.card.book.finding.weak_hierarchy': 'لا عنصر يقود الصفحة', + 'assistant.card.book.finding.off_theme': 'خارج سمة الكتاب', + 'assistant.card.book.finding.energy_mismatch': 'أكثر حيوية أو هدوءًا من الموجز', + 'assistant.step.bookAssets': 'يتصفح مكتبة الاستوديو', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/ar/index.ts b/shared/src/i18n/ar/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/ar/index.ts +++ b/shared/src/i18n/ar/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/ar/studioStickers.ts b/shared/src/i18n/ar/studioStickers.ts index ada635023..390dbb211 100644 --- a/shared/src/i18n/ar/studioStickers.ts +++ b/shared/src/i18n/ar/studioStickers.ts @@ -15,7 +15,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'عكس أفقي', 'journey.studio.stickerTurnLeft': 'تدوير لليسار', 'journey.studio.stickerTurnRight': 'تدوير لليمين', - 'assistant.step.bookStickers': 'يتصفح الملصقات', 'journey.studio.stickerTheme.ephemera': 'أغراض السفر', 'journey.studio.stickerTheme.landmarks': 'معالم شهيرة', 'journey.studio.stickerTheme.tropical': 'استوائي', diff --git a/shared/src/i18n/az/assistantBookArt.ts b/shared/src/i18n/az/assistantBookArt.ts new file mode 100644 index 000000000..00573fc34 --- /dev/null +++ b/shared/src/i18n/az/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Diqqət çəkməyən üz qabığı', + 'assistant.card.book.finding.weak_hierarchy': 'Səhifədə aydın iyerarxiya yoxdur', + 'assistant.card.book.finding.off_theme': 'Kitabın mövzusundan kənar', + 'assistant.card.book.finding.energy_mismatch': 'Tapşırıqdan daha canlı və ya sakit', + 'assistant.step.bookAssets': 'Studio kitabxanasına baxır', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/az/index.ts b/shared/src/i18n/az/index.ts index 4738c6aa1..b51a73e0c 100644 --- a/shared/src/i18n/az/index.ts +++ b/shared/src/i18n/az/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/az/studioStickers.ts b/shared/src/i18n/az/studioStickers.ts index d91f23272..1edef4079 100644 --- a/shared/src/i18n/az/studioStickers.ts +++ b/shared/src/i18n/az/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Güzgü kimi çevir', 'journey.studio.stickerTurnLeft': 'Sola döndər', 'journey.studio.stickerTurnRight': 'Sağa döndər', - 'assistant.step.bookStickers': 'Stikerlərə baxır', 'journey.studio.stickerTheme.ephemera': 'Səyahət əşyaları', 'journey.studio.stickerTheme.landmarks': 'Görməli yerlər', 'journey.studio.stickerTheme.tropical': 'Tropiklər', diff --git a/shared/src/i18n/br/assistantBookArt.ts b/shared/src/i18n/br/assistantBookArt.ts new file mode 100644 index 000000000..b4eb158f4 --- /dev/null +++ b/shared/src/i18n/br/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Uma capa sem destaque', + 'assistant.card.book.finding.weak_hierarchy': 'Página sem hierarquia clara', + 'assistant.card.book.finding.off_theme': 'Fora do tema do livro', + 'assistant.card.book.finding.energy_mismatch': 'Mais animado ou mais calmo que o briefing', + 'assistant.step.bookAssets': 'Olhando a biblioteca do Studio', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/br/index.ts b/shared/src/i18n/br/index.ts index f3bffe477..b9e782fe5 100644 --- a/shared/src/i18n/br/index.ts +++ b/shared/src/i18n/br/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/br/studioStickers.ts b/shared/src/i18n/br/studioStickers.ts index 3ddb745c5..c5cff1a41 100644 --- a/shared/src/i18n/br/studioStickers.ts +++ b/shared/src/i18n/br/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Espelhar', 'journey.studio.stickerTurnLeft': 'Girar para a esquerda', 'journey.studio.stickerTurnRight': 'Girar para a direita', - 'assistant.step.bookStickers': 'Olhando os adesivos', 'journey.studio.stickerTheme.ephemera': 'Coisas de viagem', 'journey.studio.stickerTheme.landmarks': 'Monumentos', 'journey.studio.stickerTheme.tropical': 'Trópicos', diff --git a/shared/src/i18n/ca/assistantBookArt.ts b/shared/src/i18n/ca/assistantBookArt.ts new file mode 100644 index 000000000..829024c08 --- /dev/null +++ b/shared/src/i18n/ca/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Una portada sense força', + 'assistant.card.book.finding.weak_hierarchy': 'Pàgina sense jerarquia clara', + 'assistant.card.book.finding.off_theme': 'Fora del tema del llibre', + 'assistant.card.book.finding.energy_mismatch': 'Més viu o més tranquil que l’encàrrec', + 'assistant.step.bookAssets': 'Repassant la biblioteca de Studio', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/ca/index.ts b/shared/src/i18n/ca/index.ts index 2836893fb..aa84e9580 100644 --- a/shared/src/i18n/ca/index.ts +++ b/shared/src/i18n/ca/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/ca/studioStickers.ts b/shared/src/i18n/ca/studioStickers.ts index edd860cfc..7971da476 100644 --- a/shared/src/i18n/ca/studioStickers.ts +++ b/shared/src/i18n/ca/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Emmiralla', 'journey.studio.stickerTurnLeft': 'Gira a l’esquerra', 'journey.studio.stickerTurnRight': 'Gira a la dreta', - 'assistant.step.bookStickers': 'Repassant els adhesius', 'journey.studio.stickerTheme.ephemera': 'Coses de viatge', 'journey.studio.stickerTheme.landmarks': 'Monuments', 'journey.studio.stickerTheme.tropical': 'Tròpic', diff --git a/shared/src/i18n/cs/assistantBookArt.ts b/shared/src/i18n/cs/assistantBookArt.ts new file mode 100644 index 000000000..e11e01d37 --- /dev/null +++ b/shared/src/i18n/cs/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Nevýrazná obálka', + 'assistant.card.book.finding.weak_hierarchy': 'Stránka bez jasné hierarchie', + 'assistant.card.book.finding.off_theme': 'Mimo motiv knihy', + 'assistant.card.book.finding.energy_mismatch': 'Živější nebo klidnější, než zadání chce', + 'assistant.step.bookAssets': 'Prochází knihovnu Studia', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/cs/index.ts b/shared/src/i18n/cs/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/cs/index.ts +++ b/shared/src/i18n/cs/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/cs/studioStickers.ts b/shared/src/i18n/cs/studioStickers.ts index b92aaf778..99b045f77 100644 --- a/shared/src/i18n/cs/studioStickers.ts +++ b/shared/src/i18n/cs/studioStickers.ts @@ -15,7 +15,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Zrcadlit', 'journey.studio.stickerTurnLeft': 'Otočit doleva', 'journey.studio.stickerTurnRight': 'Otočit doprava', - 'assistant.step.bookStickers': 'Prochází samolepky', 'journey.studio.stickerTheme.ephemera': 'Cestovní věci', 'journey.studio.stickerTheme.landmarks': 'Památky', 'journey.studio.stickerTheme.tropical': 'Tropy', diff --git a/shared/src/i18n/de/assistantBookArt.ts b/shared/src/i18n/de/assistantBookArt.ts new file mode 100644 index 000000000..3add3ae08 --- /dev/null +++ b/shared/src/i18n/de/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Ein Cover ohne Blickfang', + 'assistant.card.book.finding.weak_hierarchy': 'Keine klare Hierarchie auf der Seite', + 'assistant.card.book.finding.off_theme': 'Außerhalb des Buchthemas', + 'assistant.card.book.finding.energy_mismatch': 'Lebhafter oder ruhiger als vorgegeben', + 'assistant.step.bookAssets': 'Sieht die Studio-Bibliothek durch', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/de/index.ts b/shared/src/i18n/de/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/de/index.ts +++ b/shared/src/i18n/de/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/de/studioStickers.ts b/shared/src/i18n/de/studioStickers.ts index 55490d00c..dd983d391 100644 --- a/shared/src/i18n/de/studioStickers.ts +++ b/shared/src/i18n/de/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Spiegeln', 'journey.studio.stickerTurnLeft': 'Nach links drehen', 'journey.studio.stickerTurnRight': 'Nach rechts drehen', - 'assistant.step.bookStickers': 'Sieht die Sticker durch', 'journey.studio.stickerTheme.ephemera': 'Reisekram', 'journey.studio.stickerTheme.landmarks': 'Wahrzeichen', 'journey.studio.stickerTheme.tropical': 'Tropen', diff --git a/shared/src/i18n/en/assistantBookArt.ts b/shared/src/i18n/en/assistantBookArt.ts new file mode 100644 index 000000000..80cc8160d --- /dev/null +++ b/shared/src/i18n/en/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'A flat cover', + 'assistant.card.book.finding.weak_hierarchy': 'Nothing leads the page', + 'assistant.card.book.finding.off_theme': "Outside the book's theme", + 'assistant.card.book.finding.energy_mismatch': 'Livelier or calmer than the brief', + 'assistant.step.bookAssets': "Looking through the Studio's library", +}; +export default assistantBookArt; diff --git a/shared/src/i18n/en/index.ts b/shared/src/i18n/en/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/en/index.ts +++ b/shared/src/i18n/en/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/en/studioStickers.ts b/shared/src/i18n/en/studioStickers.ts index a9c2919fb..521c3588a 100644 --- a/shared/src/i18n/en/studioStickers.ts +++ b/shared/src/i18n/en/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Mirror', 'journey.studio.stickerTurnLeft': 'Turn left', 'journey.studio.stickerTurnRight': 'Turn right', - 'assistant.step.bookStickers': 'Looking through the stickers', 'journey.studio.stickerTheme.ephemera': 'Travel things', 'journey.studio.stickerTheme.landmarks': 'Landmarks', 'journey.studio.stickerTheme.tropical': 'Tropical', diff --git a/shared/src/i18n/es/assistantBookArt.ts b/shared/src/i18n/es/assistantBookArt.ts new file mode 100644 index 000000000..db00ff5b7 --- /dev/null +++ b/shared/src/i18n/es/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Una portada sin fuerza', + 'assistant.card.book.finding.weak_hierarchy': 'Página sin jerarquía clara', + 'assistant.card.book.finding.off_theme': 'Fuera del tema del libro', + 'assistant.card.book.finding.energy_mismatch': 'Más viva o más tranquila que el encargo', + 'assistant.step.bookAssets': 'Revisando la biblioteca de Studio', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/es/index.ts b/shared/src/i18n/es/index.ts index edb0a66d2..b1bf3240e 100644 --- a/shared/src/i18n/es/index.ts +++ b/shared/src/i18n/es/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/es/studioStickers.ts b/shared/src/i18n/es/studioStickers.ts index be80c633f..c17e6add0 100644 --- a/shared/src/i18n/es/studioStickers.ts +++ b/shared/src/i18n/es/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Reflejar', 'journey.studio.stickerTurnLeft': 'Girar a la izquierda', 'journey.studio.stickerTurnRight': 'Girar a la derecha', - 'assistant.step.bookStickers': 'Revisando las pegatinas', 'journey.studio.stickerTheme.ephemera': 'Cosas de viaje', 'journey.studio.stickerTheme.landmarks': 'Monumentos', 'journey.studio.stickerTheme.tropical': 'Trópico', diff --git a/shared/src/i18n/et/assistantBookArt.ts b/shared/src/i18n/et/assistantBookArt.ts new file mode 100644 index 000000000..285fff50f --- /dev/null +++ b/shared/src/i18n/et/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Ilmetu kaas', + 'assistant.card.book.finding.weak_hierarchy': 'Lehel pole selget hierarhiat', + 'assistant.card.book.finding.off_theme': 'Raamatu teemast väljas', + 'assistant.card.book.finding.energy_mismatch': 'Elavam või rahulikum kui lähteülesanne', + 'assistant.step.bookAssets': 'Vaatab Studio kogu', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/et/index.ts b/shared/src/i18n/et/index.ts index 4738c6aa1..b51a73e0c 100644 --- a/shared/src/i18n/et/index.ts +++ b/shared/src/i18n/et/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/et/studioStickers.ts b/shared/src/i18n/et/studioStickers.ts index 4bde64c57..16a8b62a4 100644 --- a/shared/src/i18n/et/studioStickers.ts +++ b/shared/src/i18n/et/studioStickers.ts @@ -15,7 +15,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Peegelda', 'journey.studio.stickerTurnLeft': 'Pööra vasakule', 'journey.studio.stickerTurnRight': 'Pööra paremale', - 'assistant.step.bookStickers': 'Vaatab kleebiseid', 'journey.studio.stickerTheme.ephemera': 'Reisiasjad', 'journey.studio.stickerTheme.landmarks': 'Vaatamisväärsused', 'journey.studio.stickerTheme.tropical': 'Troopika', diff --git a/shared/src/i18n/fr/assistantBookArt.ts b/shared/src/i18n/fr/assistantBookArt.ts new file mode 100644 index 000000000..5adbb46be --- /dev/null +++ b/shared/src/i18n/fr/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Une couverture sans relief', + 'assistant.card.book.finding.weak_hierarchy': 'Aucune hiérarchie claire sur la page', + 'assistant.card.book.finding.off_theme': 'Hors du thème du livre', + 'assistant.card.book.finding.energy_mismatch': 'Plus vif ou plus calme que la consigne', + 'assistant.step.bookAssets': 'Parcourt la bibliothèque du Studio', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/fr/index.ts b/shared/src/i18n/fr/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/fr/index.ts +++ b/shared/src/i18n/fr/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/fr/studioStickers.ts b/shared/src/i18n/fr/studioStickers.ts index 95e4c825f..a13b9891a 100644 --- a/shared/src/i18n/fr/studioStickers.ts +++ b/shared/src/i18n/fr/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Miroir', 'journey.studio.stickerTurnLeft': 'Tourner à gauche', 'journey.studio.stickerTurnRight': 'Tourner à droite', - 'assistant.step.bookStickers': 'Parcourt les autocollants', 'journey.studio.stickerTheme.ephemera': 'Objets de voyage', 'journey.studio.stickerTheme.landmarks': 'Monuments', 'journey.studio.stickerTheme.tropical': 'Tropiques', diff --git a/shared/src/i18n/gr/assistantBookArt.ts b/shared/src/i18n/gr/assistantBookArt.ts new file mode 100644 index 000000000..8d7fc433f --- /dev/null +++ b/shared/src/i18n/gr/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Ένα εξώφυλλο χωρίς ένταση', + 'assistant.card.book.finding.weak_hierarchy': 'Καμία σαφής ιεραρχία στη σελίδα', + 'assistant.card.book.finding.off_theme': 'Εκτός του θέματος του βιβλίου', + 'assistant.card.book.finding.energy_mismatch': 'Πιο ζωηρό ή πιο ήρεμο από το ζητούμενο', + 'assistant.step.bookAssets': 'Κοιτάζει τη βιβλιοθήκη του Studio', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/gr/index.ts b/shared/src/i18n/gr/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/gr/index.ts +++ b/shared/src/i18n/gr/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/gr/studioStickers.ts b/shared/src/i18n/gr/studioStickers.ts index 4dc9458eb..6dcdf119d 100644 --- a/shared/src/i18n/gr/studioStickers.ts +++ b/shared/src/i18n/gr/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Κατοπτρισμός', 'journey.studio.stickerTurnLeft': 'Στροφή αριστερά', 'journey.studio.stickerTurnRight': 'Στροφή δεξιά', - 'assistant.step.bookStickers': 'Κοιτάζει τα αυτοκόλλητα', 'journey.studio.stickerTheme.ephemera': 'Ταξιδιωτικά', 'journey.studio.stickerTheme.landmarks': 'Αξιοθέατα', 'journey.studio.stickerTheme.tropical': 'Τροπικά', diff --git a/shared/src/i18n/hu/assistantBookArt.ts b/shared/src/i18n/hu/assistantBookArt.ts new file mode 100644 index 000000000..54bd7f1c7 --- /dev/null +++ b/shared/src/i18n/hu/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Jellegtelen borító', + 'assistant.card.book.finding.weak_hierarchy': 'Nincs világos hierarchia az oldalon', + 'assistant.card.book.finding.off_theme': 'A könyv témáján kívül', + 'assistant.card.book.finding.energy_mismatch': 'Élénkebb vagy nyugodtabb a kértnél', + 'assistant.step.bookAssets': 'Átnézi a Studio könyvtárát', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/hu/index.ts b/shared/src/i18n/hu/index.ts index f3bffe477..b9e782fe5 100644 --- a/shared/src/i18n/hu/index.ts +++ b/shared/src/i18n/hu/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/hu/studioStickers.ts b/shared/src/i18n/hu/studioStickers.ts index 0d2c8dbd1..5316f8cb1 100644 --- a/shared/src/i18n/hu/studioStickers.ts +++ b/shared/src/i18n/hu/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Tükrözés', 'journey.studio.stickerTurnLeft': 'Forgatás balra', 'journey.studio.stickerTurnRight': 'Forgatás jobbra', - 'assistant.step.bookStickers': 'Átnézi a matricákat', 'journey.studio.stickerTheme.ephemera': 'Úti holmik', 'journey.studio.stickerTheme.landmarks': 'Nevezetességek', 'journey.studio.stickerTheme.tropical': 'Trópusok', diff --git a/shared/src/i18n/id/assistantBookArt.ts b/shared/src/i18n/id/assistantBookArt.ts new file mode 100644 index 000000000..beb29624e --- /dev/null +++ b/shared/src/i18n/id/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Sampul yang datar', + 'assistant.card.book.finding.weak_hierarchy': 'Tidak ada hierarki yang jelas di halaman', + 'assistant.card.book.finding.off_theme': 'Di luar tema buku', + 'assistant.card.book.finding.energy_mismatch': 'Lebih ramai atau lebih tenang dari arahan', + 'assistant.step.bookAssets': 'Melihat-lihat pustaka Studio', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/id/index.ts b/shared/src/i18n/id/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/id/index.ts +++ b/shared/src/i18n/id/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/id/studioStickers.ts b/shared/src/i18n/id/studioStickers.ts index 606dab75c..0fafc46a0 100644 --- a/shared/src/i18n/id/studioStickers.ts +++ b/shared/src/i18n/id/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Cerminkan', 'journey.studio.stickerTurnLeft': 'Putar ke kiri', 'journey.studio.stickerTurnRight': 'Putar ke kanan', - 'assistant.step.bookStickers': 'Melihat-lihat stiker', 'journey.studio.stickerTheme.ephemera': 'Barang perjalanan', 'journey.studio.stickerTheme.landmarks': 'Landmark terkenal', 'journey.studio.stickerTheme.tropical': 'Tropis', diff --git a/shared/src/i18n/it/assistantBookArt.ts b/shared/src/i18n/it/assistantBookArt.ts new file mode 100644 index 000000000..5890a5685 --- /dev/null +++ b/shared/src/i18n/it/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Una copertina senza mordente', + 'assistant.card.book.finding.weak_hierarchy': 'Nessuna gerarchia chiara nella pagina', + 'assistant.card.book.finding.off_theme': 'Fuori dal tema del libro', + 'assistant.card.book.finding.energy_mismatch': 'Più vivace o più calmo della richiesta', + 'assistant.step.bookAssets': 'Sta guardando la libreria dello Studio', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/it/index.ts b/shared/src/i18n/it/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/it/index.ts +++ b/shared/src/i18n/it/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/it/studioStickers.ts b/shared/src/i18n/it/studioStickers.ts index e5c01dad1..495b3fbb2 100644 --- a/shared/src/i18n/it/studioStickers.ts +++ b/shared/src/i18n/it/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Specchia', 'journey.studio.stickerTurnLeft': 'Ruota a sinistra', 'journey.studio.stickerTurnRight': 'Ruota a destra', - 'assistant.step.bookStickers': 'Sta guardando gli adesivi', 'journey.studio.stickerTheme.ephemera': 'Oggetti di viaggio', 'journey.studio.stickerTheme.landmarks': 'Monumenti', 'journey.studio.stickerTheme.tropical': 'Tropici', diff --git a/shared/src/i18n/ja/assistantBookArt.ts b/shared/src/i18n/ja/assistantBookArt.ts new file mode 100644 index 000000000..9b6db6086 --- /dev/null +++ b/shared/src/i18n/ja/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': '印象の弱い表紙', + 'assistant.card.book.finding.weak_hierarchy': 'ページに明確な主役がありません', + 'assistant.card.book.finding.off_theme': '本のテーマから外れています', + 'assistant.card.book.finding.energy_mismatch': 'ブリーフより賑やか、または落ち着いています', + 'assistant.step.bookAssets': 'Studioのライブラリを探しています', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/ja/index.ts b/shared/src/i18n/ja/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/ja/index.ts +++ b/shared/src/i18n/ja/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/ja/studioStickers.ts b/shared/src/i18n/ja/studioStickers.ts index 33f0b44d7..36b7efaf6 100644 --- a/shared/src/i18n/ja/studioStickers.ts +++ b/shared/src/i18n/ja/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': '左右反転', 'journey.studio.stickerTurnLeft': '左に回転', 'journey.studio.stickerTurnRight': '右に回転', - 'assistant.step.bookStickers': 'ステッカーを探しています', 'journey.studio.stickerTheme.ephemera': '旅の小物', 'journey.studio.stickerTheme.landmarks': '名所', 'journey.studio.stickerTheme.tropical': '南国', diff --git a/shared/src/i18n/ko/assistantBookArt.ts b/shared/src/i18n/ko/assistantBookArt.ts new file mode 100644 index 000000000..00c01724b --- /dev/null +++ b/shared/src/i18n/ko/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': '밋밋한 표지', + 'assistant.card.book.finding.weak_hierarchy': '페이지에 뚜렷한 위계가 없습니다', + 'assistant.card.book.finding.off_theme': '책 테마에서 벗어남', + 'assistant.card.book.finding.energy_mismatch': '브리프보다 더 활기차거나 차분함', + 'assistant.step.bookAssets': 'Studio 라이브러리를 살펴보는 중', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/ko/index.ts b/shared/src/i18n/ko/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/ko/index.ts +++ b/shared/src/i18n/ko/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/ko/studioStickers.ts b/shared/src/i18n/ko/studioStickers.ts index 69e0ed711..759861fa6 100644 --- a/shared/src/i18n/ko/studioStickers.ts +++ b/shared/src/i18n/ko/studioStickers.ts @@ -15,7 +15,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': '좌우 반전', 'journey.studio.stickerTurnLeft': '왼쪽으로 회전', 'journey.studio.stickerTurnRight': '오른쪽으로 회전', - 'assistant.step.bookStickers': '스티커를 살펴보는 중', 'journey.studio.stickerTheme.ephemera': '여행 소품', 'journey.studio.stickerTheme.landmarks': '명소', 'journey.studio.stickerTheme.tropical': '열대', diff --git a/shared/src/i18n/nl/assistantBookArt.ts b/shared/src/i18n/nl/assistantBookArt.ts new file mode 100644 index 000000000..110d10005 --- /dev/null +++ b/shared/src/i18n/nl/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Een vlakke omslag', + 'assistant.card.book.finding.weak_hierarchy': 'Geen duidelijke hiërarchie op de pagina', + 'assistant.card.book.finding.off_theme': 'Buiten het thema van het boek', + 'assistant.card.book.finding.energy_mismatch': 'Levendiger of rustiger dan de briefing', + 'assistant.step.bookAssets': 'Bekijkt de Studio-bibliotheek', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/nl/index.ts b/shared/src/i18n/nl/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/nl/index.ts +++ b/shared/src/i18n/nl/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/nl/studioStickers.ts b/shared/src/i18n/nl/studioStickers.ts index 74811e55c..afedc76d0 100644 --- a/shared/src/i18n/nl/studioStickers.ts +++ b/shared/src/i18n/nl/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Spiegelen', 'journey.studio.stickerTurnLeft': 'Linksom draaien', 'journey.studio.stickerTurnRight': 'Rechtsom draaien', - 'assistant.step.bookStickers': 'Bekijkt de stickers', 'journey.studio.stickerTheme.ephemera': 'Reisspullen', 'journey.studio.stickerTheme.landmarks': 'Bezienswaardigheden', 'journey.studio.stickerTheme.tropical': 'Tropen', diff --git a/shared/src/i18n/pl/assistantBookArt.ts b/shared/src/i18n/pl/assistantBookArt.ts new file mode 100644 index 000000000..a182741aa --- /dev/null +++ b/shared/src/i18n/pl/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Płaska okładka', + 'assistant.card.book.finding.weak_hierarchy': 'Brak wyraźnej hierarchii na stronie', + 'assistant.card.book.finding.off_theme': 'Poza motywem książki', + 'assistant.card.book.finding.energy_mismatch': 'Żywsze lub spokojniejsze niż w założeniach', + 'assistant.step.bookAssets': 'Przegląda bibliotekę Studia', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/pl/index.ts b/shared/src/i18n/pl/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/pl/index.ts +++ b/shared/src/i18n/pl/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/pl/studioStickers.ts b/shared/src/i18n/pl/studioStickers.ts index e0edf76ca..f8805d110 100644 --- a/shared/src/i18n/pl/studioStickers.ts +++ b/shared/src/i18n/pl/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Odbij lustrzanie', 'journey.studio.stickerTurnLeft': 'Obróć w lewo', 'journey.studio.stickerTurnRight': 'Obróć w prawo', - 'assistant.step.bookStickers': 'Przegląda naklejki', 'journey.studio.stickerTheme.ephemera': 'Rzeczy z podróży', 'journey.studio.stickerTheme.landmarks': 'Zabytki', 'journey.studio.stickerTheme.tropical': 'Tropiki', diff --git a/shared/src/i18n/ru/assistantBookArt.ts b/shared/src/i18n/ru/assistantBookArt.ts new file mode 100644 index 000000000..b323b4a39 --- /dev/null +++ b/shared/src/i18n/ru/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Невыразительная обложка', + 'assistant.card.book.finding.weak_hierarchy': 'На странице нет ясной иерархии', + 'assistant.card.book.finding.off_theme': 'Вне темы книги', + 'assistant.card.book.finding.energy_mismatch': 'Живее или спокойнее, чем задумано', + 'assistant.step.bookAssets': 'Просматривает библиотеку Studio', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/ru/index.ts b/shared/src/i18n/ru/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/ru/index.ts +++ b/shared/src/i18n/ru/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/ru/studioStickers.ts b/shared/src/i18n/ru/studioStickers.ts index 78182eb65..cba69c891 100644 --- a/shared/src/i18n/ru/studioStickers.ts +++ b/shared/src/i18n/ru/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Отразить', 'journey.studio.stickerTurnLeft': 'Повернуть влево', 'journey.studio.stickerTurnRight': 'Повернуть вправо', - 'assistant.step.bookStickers': 'Просматривает стикеры', 'journey.studio.stickerTheme.ephemera': 'Дорожные вещи', 'journey.studio.stickerTheme.landmarks': 'Достопримечательности', 'journey.studio.stickerTheme.tropical': 'Тропики', diff --git a/shared/src/i18n/sk/assistantBookArt.ts b/shared/src/i18n/sk/assistantBookArt.ts new file mode 100644 index 000000000..39477e248 --- /dev/null +++ b/shared/src/i18n/sk/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Nevýrazná obálka', + 'assistant.card.book.finding.weak_hierarchy': 'Strana bez jasnej hierarchie', + 'assistant.card.book.finding.off_theme': 'Mimo motívu knihy', + 'assistant.card.book.finding.energy_mismatch': 'Živšie alebo pokojnejšie, ako zadanie chce', + 'assistant.step.bookAssets': 'Prezerá knižnicu Studia', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/sk/index.ts b/shared/src/i18n/sk/index.ts index 4738c6aa1..b51a73e0c 100644 --- a/shared/src/i18n/sk/index.ts +++ b/shared/src/i18n/sk/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/sk/studioStickers.ts b/shared/src/i18n/sk/studioStickers.ts index dc2ee1596..6564a0e0c 100644 --- a/shared/src/i18n/sk/studioStickers.ts +++ b/shared/src/i18n/sk/studioStickers.ts @@ -15,7 +15,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Zrkadliť', 'journey.studio.stickerTurnLeft': 'Otočiť doľava', 'journey.studio.stickerTurnRight': 'Otočiť doprava', - 'assistant.step.bookStickers': 'Prezerá nálepky', 'journey.studio.stickerTheme.ephemera': 'Cestovné veci', 'journey.studio.stickerTheme.landmarks': 'Pamiatky', 'journey.studio.stickerTheme.tropical': 'Trópy', diff --git a/shared/src/i18n/sv/assistantBookArt.ts b/shared/src/i18n/sv/assistantBookArt.ts new file mode 100644 index 000000000..0ca07e54a --- /dev/null +++ b/shared/src/i18n/sv/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Ett platt omslag', + 'assistant.card.book.finding.weak_hierarchy': 'Ingen tydlig hierarki på sidan', + 'assistant.card.book.finding.off_theme': 'Utanför bokens tema', + 'assistant.card.book.finding.energy_mismatch': 'Livligare eller lugnare än briefen', + 'assistant.step.bookAssets': 'Tittar igenom Studios bibliotek', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/sv/index.ts b/shared/src/i18n/sv/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/sv/index.ts +++ b/shared/src/i18n/sv/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/sv/studioStickers.ts b/shared/src/i18n/sv/studioStickers.ts index 7d179c874..e006105b9 100644 --- a/shared/src/i18n/sv/studioStickers.ts +++ b/shared/src/i18n/sv/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Spegelvänd', 'journey.studio.stickerTurnLeft': 'Vrid åt vänster', 'journey.studio.stickerTurnRight': 'Vrid åt höger', - 'assistant.step.bookStickers': 'Tittar igenom klistermärkena', 'journey.studio.stickerTheme.ephemera': 'Resesaker', 'journey.studio.stickerTheme.landmarks': 'Sevärdheter', 'journey.studio.stickerTheme.tropical': 'Tropikerna', diff --git a/shared/src/i18n/th/assistantBookArt.ts b/shared/src/i18n/th/assistantBookArt.ts new file mode 100644 index 000000000..7fbbdbc7e --- /dev/null +++ b/shared/src/i18n/th/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'ปกที่ไม่โดดเด่น', + 'assistant.card.book.finding.weak_hierarchy': 'หน้านี้ไม่มีลำดับความสำคัญที่ชัดเจน', + 'assistant.card.book.finding.off_theme': 'ไม่ตรงกับธีมของหนังสือ', + 'assistant.card.book.finding.energy_mismatch': 'คึกคักหรือสงบกว่าที่บรีฟกำหนด', + 'assistant.step.bookAssets': 'กำลังดูคลังของ Studio', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/th/index.ts b/shared/src/i18n/th/index.ts index 4738c6aa1..b51a73e0c 100644 --- a/shared/src/i18n/th/index.ts +++ b/shared/src/i18n/th/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/th/studioStickers.ts b/shared/src/i18n/th/studioStickers.ts index 06db80da6..2caaadf13 100644 --- a/shared/src/i18n/th/studioStickers.ts +++ b/shared/src/i18n/th/studioStickers.ts @@ -15,7 +15,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'กลับด้านซ้ายขวา', 'journey.studio.stickerTurnLeft': 'หมุนไปทางซ้าย', 'journey.studio.stickerTurnRight': 'หมุนไปทางขวา', - 'assistant.step.bookStickers': 'กำลังดูสติกเกอร์', 'journey.studio.stickerTheme.ephemera': 'ของใช้เดินทาง', 'journey.studio.stickerTheme.landmarks': 'สถานที่สำคัญ', 'journey.studio.stickerTheme.tropical': 'เขตร้อน', diff --git a/shared/src/i18n/tr/assistantBookArt.ts b/shared/src/i18n/tr/assistantBookArt.ts new file mode 100644 index 000000000..0501cba27 --- /dev/null +++ b/shared/src/i18n/tr/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Sönük bir kapak', + 'assistant.card.book.finding.weak_hierarchy': 'Sayfada belirgin bir hiyerarşi yok', + 'assistant.card.book.finding.off_theme': 'Kitabın temasının dışında', + 'assistant.card.book.finding.energy_mismatch': 'İstenenden daha canlı ya da daha sakin', + 'assistant.step.bookAssets': 'Studio kütüphanesine bakıyor', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/tr/index.ts b/shared/src/i18n/tr/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/tr/index.ts +++ b/shared/src/i18n/tr/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/tr/studioStickers.ts b/shared/src/i18n/tr/studioStickers.ts index 3f56f3e75..a7f313fdd 100644 --- a/shared/src/i18n/tr/studioStickers.ts +++ b/shared/src/i18n/tr/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Aynala', 'journey.studio.stickerTurnLeft': 'Sola döndür', 'journey.studio.stickerTurnRight': 'Sağa döndür', - 'assistant.step.bookStickers': 'Çıkartmalara bakıyor', 'journey.studio.stickerTheme.ephemera': 'Seyahat eşyaları', 'journey.studio.stickerTheme.landmarks': 'Simge yapılar', 'journey.studio.stickerTheme.tropical': 'Tropikal', diff --git a/shared/src/i18n/uk/assistantBookArt.ts b/shared/src/i18n/uk/assistantBookArt.ts new file mode 100644 index 000000000..86b06123f --- /dev/null +++ b/shared/src/i18n/uk/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Невиразна обкладинка', + 'assistant.card.book.finding.weak_hierarchy': 'На сторінці немає чіткої ієрархії', + 'assistant.card.book.finding.off_theme': 'Поза темою книги', + 'assistant.card.book.finding.energy_mismatch': 'Жвавіше або спокійніше, ніж задумано', + 'assistant.step.bookAssets': 'Переглядає бібліотеку Studio', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/uk/index.ts b/shared/src/i18n/uk/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/uk/index.ts +++ b/shared/src/i18n/uk/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/uk/studioStickers.ts b/shared/src/i18n/uk/studioStickers.ts index 0a216e24b..18d6fdc9b 100644 --- a/shared/src/i18n/uk/studioStickers.ts +++ b/shared/src/i18n/uk/studioStickers.ts @@ -16,7 +16,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Віддзеркалити', 'journey.studio.stickerTurnLeft': 'Повернути ліворуч', 'journey.studio.stickerTurnRight': 'Повернути праворуч', - 'assistant.step.bookStickers': 'Переглядає наліпки', 'journey.studio.stickerTheme.ephemera': 'Дорожні речі', 'journey.studio.stickerTheme.landmarks': 'Пам’ятки', 'journey.studio.stickerTheme.tropical': 'Тропіки', diff --git a/shared/src/i18n/vi/assistantBookArt.ts b/shared/src/i18n/vi/assistantBookArt.ts new file mode 100644 index 000000000..50b688950 --- /dev/null +++ b/shared/src/i18n/vi/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': 'Bìa thiếu điểm nhấn', + 'assistant.card.book.finding.weak_hierarchy': 'Trang không có thứ bậc rõ ràng', + 'assistant.card.book.finding.off_theme': 'Lệch khỏi chủ đề của sách', + 'assistant.card.book.finding.energy_mismatch': 'Sôi động hoặc trầm hơn so với yêu cầu', + 'assistant.step.bookAssets': 'Đang xem thư viện của Studio', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/vi/index.ts b/shared/src/i18n/vi/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/vi/index.ts +++ b/shared/src/i18n/vi/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/vi/studioStickers.ts b/shared/src/i18n/vi/studioStickers.ts index abacb0efa..ee3f883e1 100644 --- a/shared/src/i18n/vi/studioStickers.ts +++ b/shared/src/i18n/vi/studioStickers.ts @@ -15,7 +15,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': 'Lật ngang', 'journey.studio.stickerTurnLeft': 'Xoay trái', 'journey.studio.stickerTurnRight': 'Xoay phải', - 'assistant.step.bookStickers': 'Đang xem các nhãn dán', 'journey.studio.stickerTheme.ephemera': 'Đồ du lịch', 'journey.studio.stickerTheme.landmarks': 'Danh thắng', 'journey.studio.stickerTheme.tropical': 'Nhiệt đới', diff --git a/shared/src/i18n/zh-TW/assistantBookArt.ts b/shared/src/i18n/zh-TW/assistantBookArt.ts new file mode 100644 index 000000000..81681ac6f --- /dev/null +++ b/shared/src/i18n/zh-TW/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': '平淡的封面', + 'assistant.card.book.finding.weak_hierarchy': '頁面缺少明確的層次', + 'assistant.card.book.finding.off_theme': '偏離書本主題', + 'assistant.card.book.finding.energy_mismatch': '比設計方向更熱鬧或更沉靜', + 'assistant.step.bookAssets': '正在瀏覽 Studio 素材庫', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/zh-TW/index.ts b/shared/src/i18n/zh-TW/index.ts index bd2bf8087..3499250e6 100644 --- a/shared/src/i18n/zh-TW/index.ts +++ b/shared/src/i18n/zh-TW/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/zh-TW/studioStickers.ts b/shared/src/i18n/zh-TW/studioStickers.ts index 845f81db8..caa983bde 100644 --- a/shared/src/i18n/zh-TW/studioStickers.ts +++ b/shared/src/i18n/zh-TW/studioStickers.ts @@ -15,7 +15,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': '水平翻轉', 'journey.studio.stickerTurnLeft': '向左旋轉', 'journey.studio.stickerTurnRight': '向右旋轉', - 'assistant.step.bookStickers': '正在瀏覽貼紙', 'journey.studio.stickerTheme.ephemera': '旅行小物', 'journey.studio.stickerTheme.landmarks': '地標', 'journey.studio.stickerTheme.tropical': '熱帶', diff --git a/shared/src/i18n/zh/assistantBookArt.ts b/shared/src/i18n/zh/assistantBookArt.ts new file mode 100644 index 000000000..e8e7338b8 --- /dev/null +++ b/shared/src/i18n/zh/assistantBookArt.ts @@ -0,0 +1,11 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the art checks the assistant runs on a page, and its search of the Studio's catalogues. */ +const assistantBookArt: TranslationStrings = { + 'assistant.card.book.finding.flat_cover': '平淡的封面', + 'assistant.card.book.finding.weak_hierarchy': '页面缺少明确的层次', + 'assistant.card.book.finding.off_theme': '偏离书的主题', + 'assistant.card.book.finding.energy_mismatch': '比设计方向更热闹或更沉静', + 'assistant.step.bookAssets': '正在浏览 Studio 素材库', +}; +export default assistantBookArt; diff --git a/shared/src/i18n/zh/index.ts b/shared/src/i18n/zh/index.ts index 34d388358..c17dd3492 100644 --- a/shared/src/i18n/zh/index.ts +++ b/shared/src/i18n/zh/index.ts @@ -3,6 +3,7 @@ import airport from './airport'; import assistant from './assistant'; import assistantAtlas from './assistantAtlas'; import assistantBook from './assistantBook'; +import assistantBookArt from './assistantBookArt'; import assistantBookDesign from './assistantBookDesign'; import assistantBooking from './assistantBooking'; import assistantBudget from './assistantBudget'; @@ -153,6 +154,7 @@ const locale = { ...assistantBudget, ...assistantBook, ...assistantBookDesign, + ...assistantBookArt, ...studioThemes, ...assistantBooking, ...assistantCollab, diff --git a/shared/src/i18n/zh/studioStickers.ts b/shared/src/i18n/zh/studioStickers.ts index ba9caa7c6..7cccbfe90 100644 --- a/shared/src/i18n/zh/studioStickers.ts +++ b/shared/src/i18n/zh/studioStickers.ts @@ -15,7 +15,6 @@ const studioStickers: TranslationStrings = { 'journey.studio.stickerFlip': '水平翻转', 'journey.studio.stickerTurnLeft': '向左旋转', 'journey.studio.stickerTurnRight': '向右旋转', - 'assistant.step.bookStickers': '正在浏览贴纸', 'journey.studio.stickerTheme.ephemera': '旅行小物', 'journey.studio.stickerTheme.landmarks': '地标', 'journey.studio.stickerTheme.tropical': '热带', diff --git a/shared/src/index.ts b/shared/src/index.ts index 4256a6801..fb0c713a2 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -164,6 +164,9 @@ export * from './book/book-text'; export * from './book/book-path'; export * from './book/book-icons'; export * from './book/book-sticker'; +export * from './book/book-assets'; +export * from './book/book-findings-art'; +export * from './book/book-shape-catalogue'; export * from './book/stickers/sticker-types'; export * from './book/book-map-sources'; export * from './book/book-colour'; @@ -189,6 +192,7 @@ export * from './book/auto/auto-types'; export * from './book/auto/auto-book'; export * from './book/auto/auto-templates.data'; export * from './book/auto/auto-template-apply'; +export * from './book/design/design-brief.schema'; export * from './book/design/design-directives.schema'; export * from './book/design/design-book'; export * from './book/design/design-merge'; diff --git a/sonar-project.properties b/sonar-project.properties index 46660260c..29c2a8c5d 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -81,7 +81,10 @@ sonar.coverage.exclusions=\ # full document elements in fractions, as the Studio draws and the extract # script writes them, so every element repeats the fields of its kind. That # repetition is the format of the data, not copied logic. -sonar.cpd.exclusions=shared/src/i18n/**,server/src/db/kysely/**,shared/src/book/book-icon-names-*.data.ts,shared/src/book/stickers/data/**,shared/src/book/templates/data/** +# shared/src/book/data/ holds the one-line descriptions list_book_assets answers +# with for each shape and pattern: a table of English sentences keyed by id, its +# repeated "id: 'line'," rows a data layout, not copied logic. +sonar.cpd.exclusions=shared/src/i18n/**,server/src/db/kysely/**,shared/src/book/book-icon-names-*.data.ts,shared/src/book/stickers/data/**,shared/src/book/templates/data/**,shared/src/book/data/** # The full analysis of a push to dev outgrows the JS/TS analyser's default heap # of about 4 GB since the ORM migration and stops with "Analysis of JS/TS files diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index 8021a9ed2..6da08e2e1 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -217,9 +217,9 @@ Requires `journey:read` or `journey:write` scope. | `list_journey_recorded_days` | The days of recorded route a journey keeps: zone, distance, pieces, gaps, modes, whether the day was still running, and whose recording it is, without the geometry. Readable without a Dawarich connection. | | `delete_journey_recorded_day` | Drop the recorded route a journey keeps for one day. Entries and texts stay, and Dawarich keeps its own recording. Needs edit access. | | `get_book` | The journey's photo book from the Studio, without its document: version, title, page format and every page with its id, role, photos and first words. Page and element ids for the tools below come from here. Any member may read it. | -| `describe_book_page` | One page of the book in words: every element with its id, frame in millimetres and photo or text, then the findings (overlaps, content in the fold or past the trim, empty frames, low print resolution, words past their frame). | +| `describe_book_page` | One page of the book in words: every element with its id, frame in millimetres and photo or text, then the findings (overlaps, content in the fold or past the trim, empty frames, low print resolution, words past their frame) and whether the page reads as designed: a flat cover, a page whose largest words do not lead, colours or typefaces outside the book's theme. `edit_book_page` answers with the same art checks for the pages it changed. | | `list_book_layouts` | The layouts a page can take: templates for double spreads and single pages, the decorated templates (whole designed pages, each with its style, tags, photo frames and the roles its words go into), photo grids, and the book's own saved layouts, with what each does to what is on the page. | -| `list_book_assets` | Search the Studio's sticker library: illustrations of landmarks, travel things, plants, food, paper and tape, doodles and ornaments, each with its id, a one-line description, its tags, its proportions and its colour slots. `edit_book_page` places one with `add` and recolours it with `style`. | +| `list_book_assets` | Search the Studio's catalogues by `kind`, each row with its id, a one-line description and its tags: `sticker` (illustrations of landmarks, travel things, plants, food, paper and tape, doodles and ornaments, with their proportions and colour slots; `edit_book_page` places one with `add` and recolours it with `style`), `shape` (the outlines a shape draws and a photo is cut to, marked when one holds a photo or takes a cut edge), `font` (the typefaces by role: display, heading, body, script, mono, with their weights), `lockup` (the set titles, badges, postmarks and quotes `add_lockup` places, with the word roles each takes), `theme` (the twelve built-in themes with their palette, typefaces and motifs) and `pattern` (the repeats a paint takes). Layouts come from `list_book_layouts`. | | `create_book` | Lay down a blank book (cover, first page, one double spread, last page, back) for a journey that has none, in a page format of your choice. Needs edit access. | | `add_book_pages` | Add up to 10 empty double spreads, always between the first and the last page, optionally on a template or tied to an entry. Needs edit access. | | `move_book_page` | Move one double spread to another place between the first and the last page; the answer says where it stood. A move to where the page already is changes nothing. Needs edit access. | @@ -234,9 +234,9 @@ Requires `journey:read` or `journey:write` scope. | `save_book_layout` | Keep the arrangement of one page as one of the book's own layouts (at most 24). Needs edit access. | | `remove_book_layout` | Take one of the book's own layouts away; pages laid out on it stay as they are. Needs edit access. | | `restore_book_layout` | Put back a layout `remove_book_layout` took away, only while the book is still at the version that removal answered. Meant for undo. Needs edit access. | -| `design_book` | Design the whole book as a draft: chapters, pacing, pages, photos, type and colours, from what the person asks for (style, density, page count, scope, what to keep). Nothing changes until `apply_book_design`. It sees no faces and judges no beauty. Needs edit access. | +| `design_book` | Design the whole book as a draft: chapters, pacing, pages, photos, type and colours, from what the person asks for (style, density, page count, scope, what to keep). The art direction shapes it further: a `brief` (energy calm, lively or loud, decoration none, light or rich, collages, one of the twelve themes), a cover `archetype` (spine band, poster pop, cutout, postcard, grid and badge, minimal serif or stamp collage) composed from the journey's photos and words, a cover `lockup` for the title, and `pages.overrides` giving single pages of the plan another beat (a collage, a script overlay, a badge grid, a stamp row, a two-tone split, a hero, a duo, a trio or a grid) or another layout. A lively or loud book turns and frames its prints, makes some pages lively and places stickers from its theme's motifs; the answer says where a page falls outside the brief. Nothing changes until `apply_book_design`. It sees no faces and judges no beauty. Needs edit access. | | `get_book_design` | A design draft again, with the pages chosen for the apply now. | -| `revise_book_design` | Change one page of a draft with the operations `edit_book_page` takes; nothing in the book changes. | +| `revise_book_design` | Change one page of a draft with the operations `edit_book_page` takes; nothing in the book changes. The page is checked as `describe_book_page` checks it, and against the draft's brief when it has one. | | `apply_book_design` | Write a draft into the book in one change: the chosen pages, each only if nobody changed it since the design. The book as it was is kept so the change can be taken back in one step. Needs edit access. | | `restore_book_revision` | Put the book back as an applied design found it, only while it is still at the version the apply answered. Meant for undo. Needs edit access. |