Compare commits

...

29 Commits

Author SHA1 Message Date
Maurice 09f286fbfe docs(readme): the feature list as 5.0 has it
Every count checked against the code (410 MCP tools, 35 scopes in 17 groups,
18 booking types, 17 trip permissions, 66 plugin permissions), and the parts
5.0 adds get their own place: Tours, TREK Studio, the AI assistant, the help
center, Road trip, Dawarich and the linked journal. Most lines are shorter.
2026-10-24 22:30:55 +02:00
Maurice 4cc62f4c4e test(journey): the design_book request in the MCP test takes the journey beside the parsed directives 2026-10-24 20:58:49 +02:00
Maurice cb34c46226 fix(studio): CI on the templated auto layout
The rhythm test holds the picker to what it does now: it gives the rhythm up
only where no template of the book's styles frames every photo and keeps it.
A design applied over a stored book keeps that book's theme, as the merge has
always done, so the end to end test checks the pages instead. The MCP test
builds its request through the shared schema, a travel mark's frame is read
with its sides spelled out for the client's looser types, and the client's
react-hooks/refs baseline drops to the 555 it holds now.
2026-10-24 20:54:16 +02:00
Maurice e777118272 feat(studio): auto layout lists bare stops on a words-only template of the book's style
Stops nobody wrote about or photographed were set as a small list on a plain
spread, the old composed page. They now take a words-only template of the
book's styles: the list's label as its title, the stops as its story grouped
by day, as many as its story frame holds, and the day in the template's date
where all of them share one. The words go to their roles by name, since a
short list that opens on a date would otherwise read as a date line. The
composed list stays as the fallback where no template takes it.
2026-10-24 20:28:37 +02:00
Maurice 3f2da7a867 test(studio): repin the templated parity digests on top of the design figure and riso cover fixes 2026-10-24 20:14:06 +02:00
Maurice 9c3649f011 fix(studio): auto layout keeps every page in the book's own styles and every photo in a frame
The picker scored all 108 templates at once, so a page whose photos the own
pool could not frame borrowed a template of another style (a night film page
in a poster pop book). It now goes in tiers: the theme's archetype and its kin
with a frame for every photo, then the same with the rhythm given up for that
page, then another archetype, and only then a photo left in the tray. The
photos of a chapter are cut into the counts the own pool has, and only where
those leave a photo over into counts another archetype has, at a price. Riso
joins poster pop's kin for its one photo story page.

Across the 17 themes and the nine fixture journeys no photo waits in the tray,
no page is laid in another style and no page has a severe finding.
2026-10-24 20:13:44 +02:00
Maurice 73fdbdc0c3 feat(studio): Auto layout lays the book on the designed pages
"The whole book" now lays the book on the designed templates through
autoDesignedBook, in the book's own page format and theme (a recoloured
one of its own included), its title kept and every page holding a locked
element kept; each press takes a new seed, so a second press gives another
book, one undo away as before. "This spread" lays the page on another
design than the one on screen and its neighbours, its id kept, and now
works on the cover, the summary and the countries too, so the menu offers
it wherever the auto layout knows the page. Loose gallery photos reach the
layout with the day they were taken, which it shares them out by. The
menu's two hints say what the button now does, in every language. Nothing
here places the classic summary, countries or marks any more.
2026-10-24 20:13:44 +02:00
Maurice 0879b5aecc feat(shared): the Studio's auto layout entry points
auto/auto-designed.ts holds what the Studio's Auto layout menu calls.
autoDesignedBook lays the whole book on the designed templates in the
book's own theme object (a recoloured one of its own included, the calm
fabric-classic theme for a book without one), over the stored book, its
page format kept and every page holding a locked element kept; another
seed lays another book. autoRelayoutSpread lays one page again around its
locks, its id kept: an entry's page on the best template other than the
one on screen and its neighbours', avoiding one that would cut its story
where it can; the cover on the next cover template of its archetype and
then of its kin; the summary and the countries in their next look, an old
classic summary included. Pages the engine did not make are left alone,
as before. autoRelayoutKind says which pages those are.

designBook's templated path is split so the relayout reads the same
setting (templatedSetting) as the whole book, and the page layer is
shared (layEntryPage); the parity digests are unchanged by it. buildBook
and relayoutSpread stay exported and unchanged.
2026-10-24 20:13:44 +02:00
Maurice 9729b0b575 feat(server): design_book offers the designed templates
The design_book tool says in one sentence that layouts: 'templates' lays
every page on the Studio's designed templates in the archetype of the
book's theme, as the Studio's Auto layout does, and that each plan row then
names its template; style classic alone stays the old built-in Auto
layout. The field comes from the directives contract in @trek/shared, so
the tool's schema takes it without a zod line of its own, and the answer's
plan row gains an optional template. The REST draft routes take no
directives, so nothing changes there. Without the field the answer is the
same as before, no row naming a template.
2026-10-24 20:13:43 +02:00
Maurice 35acdb8ba1 feat(shared): design_book can lay the book on the designed templates
A new optional directive, layouts: 'templates', lays every entry on the
decorated spreads of the book theme's archetype and its kin
(design-templated.ts). An entry's photos are cut into page-sized parts that
leave no frame empty, at most twelve of them, the first part carrying its
story; each part takes the template that suits it best, poured from a page
of the entry's photos and story so the title, place, date and story stay
bound to the journal. A story longer than its frame is cut at a sentence
and goes on over a page of words. Date marks are set as stamps, never in
the classic styles. Loose gallery photos go to the entry they are linked
to, then to the entry of their day, then to the entry with the fewest
(shareGalleryByDay, beside distributeGallery, which stays as it is); what
no entry has room for closes the book on up to three pages of more
moments, and anything past that is said to be left out, so no photo is
dropped without a word.

The rest is the composer's: scope, the photo picker, the page budget, the
stored pages kept, repair and findings. A book can bring its own theme
object (BookDesignInput.theme), recoloured or not, and it is kept whole.
Plan rows name the template a page was laid on. Without the directive
every design is composed exactly as before, and classic is still buildBook.

AUTODSN-001..015 cover the rules, and AUTODSN-020 pins a digest of every
page on the auto layout's parity journeys and a fourth one in three themes.
design-templated.parity.json was computed with the spec's own canonical form
and digest over these inputs, run through tsx; CI checks it from here on.
2026-10-24 20:13:43 +02:00
Maurice a19fdf6161 feat(shared): framing pages from the new travel looks
design-templated-framing.ts builds the pages around the chapters of a book
laid on the designed templates. The cover is laid on one of the theme
archetype's cover templates, poured from a page whose title and subtitle
are bound to the journey, so the binding survives; an archetype with no
cover for the photos there are has its cover composed as before. The
journey at a glance is its map with a photo at every stop beside the
figures as a poster (tiles for the collage and grid books, a sign for the
postcard ones), a ticket stub of how far and how long, and the furthest
point on an odometer. The countries are one country's map cut to its shape
beside its stamp, a card each for up to twelve, one panel of stamps past
that. The back cover adds a band of days, places and photos and a row of
country stamps. A page of words, where no template takes a story, sets it
beside the stop's map, its day as a stamp and its country as a tag. None of
these sets a summary, a country or a mark in the classic look.
2026-10-24 20:13:43 +02:00
Maurice 813dd7a7eb feat(shared): pick a decorated spread for a page's photos and words
design-template-pick.ts says which decorated template a page of the auto
layout is laid on. Every built-in theme belongs to an archetype
(THEME_ARCHETYPE, the five new themes to their own five), and a book picks
from that archetype's spreads and from those of the archetypes that sit
well beside it (ARCHETYPE_KIN). A template that would leave a photo frame
empty, lose the story, draw a map with nothing to draw, repeat the page
before or follow a busy page loudly is never a candidate; of the rest the
best fit wins: the photos all used, the theme's own archetype, the story
fitting its frame (bodyCapacity measures each frame once per format and
face), the first frame the shape of the first photo, not used a few pages
back, and calm where the book should breathe. Equal ones are chosen by the
design's seed. chunkPhotos cuts an entry's photos into page-sized parts
that leave no frame empty, and pickCoverTemplate does the same for the
cover.
2026-10-24 20:13:43 +02:00
Maurice d51b9de2b0 feat(shared): a laid page knows which decorated template it is
matchDecoratedTemplate reads a page's elements by kind and frame, rounded
to the millimetre, and compares them with every template laid on the same
page format: a template is the page's when nearly all of its design is
there and it explains most of the page, and of those the one explaining the
most wins. A page rearranged by hand reads as none. The auto layout's
"this spread" uses it to offer a template other than the one on screen.
2026-10-24 20:13:43 +02:00
Maurice 6254510e0f refactor(shared): the travel looks are built in shared
The factories behind the Travel panel's cards (a summary as a poster, a
ticket or a sign, a figure on a medal or an odometer, a map of the journey
or of one country, a mark as a stamp or a tag, a country as a stamp, a visa
or a postcard, each dressed in the book's theme) move to
book/travel/book-travel-looks.ts, so the auto layout can build its framing
pages from the same looks. Where an element lands stays the caller's: the
panel still centres it on the spread on screen, through the same wrapper and
the same API, and a designed page names its frame. A mark can now also take
an entry's value rather than the journey's.
2026-10-24 20:13:43 +02:00
Maurice c525b2f84c fix(studio): the riso cover's year band deepens a mid-tone ink so the year reads
In a theme whose second ink is a mid tone (riviera's olive), neither white nor
near black reached 4.5:1 on the year's band. The band is taken towards the dark
until the year reads, as the other covers do for small words.
2026-10-24 20:13:42 +02:00
Maurice b3d30a834e test(studio): pin which texts count as a template's figures 2026-10-24 20:13:42 +02:00
Maurice 589d3354eb fix(studio): a template's figures stay its design, never the next template's date line
Figures a template draws as design, a film's timecode or a frame number, came
back as the page's words when another template was laid on the page, and took
its date line. Texts with no letter that are not a date and are bound to
nothing are design now, and the new templates spell their plate numbers and
REC marks so they read as such.
2026-10-24 20:13:41 +02:00
Maurice 8ce3b24a01 feat(studio): 71 more designed pages and five new styles, each with a theme of its own
Every style now has four covers and five double spreads, 108 in all, and the
spreads of each style take different photo counts, from a words-only page to
eight photos, so a book can be laid on them without an empty frame. The five
new styles come with their own themes: Riso (overprinted spot inks), Magazine
(a travel magazine), Retro travel (vintage posters), Midnight (a dark film
look) and Field notes (a traveller's notebook). Each new style has a cover
composer for the design engine and a recipe for the assistant, and its chip,
theme and template names are translated into every language.
2026-10-24 20:13:41 +02:00
Maurice 06a1dcd730 fix(journey): the share link's copy button and its switches show their words again
The filled buttons set their label in text-accent-on, the accent used as
text on a plain surface, which on the accent's own ground is the same
colour: a black button with black words. They take text-accent-text, the
colour made for words on the accent, and a test pins it.
2026-10-24 18:28:47 +02:00
Maurice 4b633d8cd2 feat(admin): release notes show a #### heading as the feature it names
The release notes in Admin read ## as the version and ### as a section,
and printed a #### line as plain text. A release that heads each feature
inside a section with #### (Tours, TREK Studio under Added) now shows it
as a feature heading between the section and its text, on desktop and on
the phone.
2026-10-24 18:28:44 +02:00
Maurice 728a275fef chore(assistant): drop Connect with Claude from the integration settings
The card that opened claude.ai's custom connector dialog for this instance
goes, with its phone twin, the line in the assistant setup that jumped to it,
its endpoint and its contract. Anyone who wants Claude uses TREK's MCP
server under Integrations, or an Anthropic key in the assistant. It came with
the assistant and never shipped in a release.
2026-10-24 18:28:41 +02:00
Maurice f6befaa6b2 fix(vacay): the opened year's broadcast skips the socket the change came from, like every other
The app only sets days in the years it shows, so the tab that made the change
never needs the new year; the assistant, MCP and plugins send no socket and
reach everyone.
2026-10-24 18:28:38 +02:00
Maurice fd6d3fddd5 fix(vacay): a year a day opens is announced after the day is committed
Opening the year sent vacay:settings from inside the day's transaction, so
a client could reload the years before they were there. The rows go in
with the day, and the broadcast follows the commit. The helpers file is
formatted the way the server's Prettier wants it.
2026-10-24 18:07:02 +02:00
Maurice 280e55bbc9 test(vacay): a day in a year the plan has not opened also announces the new year
The broadcast parity cases toggle a day in 2027, which the seeded plan does
not hold, so both MCP and REST now send vacay:settings for the opened year
before vacay:update.
2026-10-24 17:31:47 +02:00
Maurice e84060cdf7 refactor(vacay): the pure constants live in the helpers, so the service keeps its size budget
The holiday feeds' limits and patterns and the colour palette move to
vacay.helpers.ts, which holds the vacay domain's pure half already. That
keeps the service under its line budget after the missing-year fix.
2026-10-24 17:31:47 +02:00
Maurice fbc85b05a1 fix(vacay): a day set for a year the plan has not opened adds that year
The calendar offers only the plan's years, but a vacation day or company
holiday could be written for any date. A day set months ahead, as the
assistant did for 6 January 2027, was stored and counted yet never shown,
because 2027 was not a year of the plan. Writing a day now adds its year,
with every member's allowance, the way opening it by hand does; the year
is the writer's leave period that holds the date. This holds for the app,
MCP, plugins and the assistant alike, since they all write through here.
2026-10-24 17:31:47 +02:00
Maurice 01333c6ff3 chore(client): hold the complexity count at the notifications redesign's lower one 2026-10-24 17:31:46 +02:00
Maurice aecef03090 feat(notifications): a tile per kind, the list cut into days, and clear actions
Every notification gets a tile in the colour of what it is about (a trip, a
message, a booking, packing, a to-do, Vacay, a collection, shared photos, a
tour, an update or an alert) with the sender's face on its corner, the page
and the bell's panel group the list under Today, Yesterday, the weekday or
the date, and answering or following a notification is a proper button.
The server's UTC timestamps are read as UTC for the grouping and the time.
2026-10-24 17:31:46 +02:00
Maurice 8dacd0a50c fix(book): the auto layout's drawn templates no longer carry photos of the book they were drawn in
Three photo frames in the templates kept the photo ids of the journey the
templates were read out of (68 and 69). Only empty frames take an entry's
photos, so every book laid out on those templates pointed at those ids: a
broken picture on most installs, and on one where the id exists, someone
else's photo or an unrelated one of the user's own. The frames are empty
now and take the entry's photos like the others. The parity digests of the
five spreads that use them are written anew, as the spec asks for a
deliberate layout change.
2026-10-24 16:59:53 +02:00
215 changed files with 28659 additions and 1310 deletions
+97 -67
View File
@@ -62,7 +62,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<br />
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Tours, Vacay and Atlas ship on; Journey, Collections, MCP, the AI assistant, AI Parsing, Road trip, Dawarich and AirTrail ship off and are marked below.
<table>
<tr>
@@ -70,76 +70,110 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
#### 🧭 Planning
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
- **Public transport**: door-to-door itineraries over Transitous
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
- **Day plans**: drag places, notes and bookings between days with undo, or drop a map marker straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token) with clustering, photo markers, route lines and a whole-trip route overview
- **Places**: search the TREK Places index and OpenStreetMap with no key, or Google Places and Amap with one. Descriptions, hours and photos from Wikipedia, Wikidata and Wikimedia Commons, plus a rating and a custom image per place
- **Explore**: nearby places by category for the map view, from the TREK Places index with Overpass as fallback
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotels stay put), a travel mode per leg over OSRM or Valhalla, then open the day in Google Maps or CoMaps and a place in seven navigation apps
- **Public transport**: door-to-door itineraries over Transitous, or Google where Transitous has no data
- **Import and export**: Google Maps and Naver Maps lists, Google Maps directions links, GPX, KML and KMZ in; GPX and an ICS feed per trip or across all trips out
- **Weather**: 16-day forecast from Open-Meteo, no key, and the archive for dates beyond it
- **Trip dates**: move a trip and its days re-date themselves, with the bookings along or re-anchored. Trips also copy and archive
- **Road trip** (off by default): drives with stops, day windows and arrival times that recompute, route alternatives, hazards and EV charging with range
</td>
<td width="50%" valign="top">
#### 🧳 Bookings and money
#### 🥾 Tours
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
- **Seven tour types**: hike, city walk, run, bike (touring, road, gravel, MTB), paddle (kayak, canoe, SUP), horse ride, and ski and winter (alpine, cross-country, touring, snowshoe, winter hike)
- **Routing**: legs routed over Valhalla with shaping points, hand-drawn legs, route variants, round hikes of a wished length and trip places as stops, all with undo
- **Hikes**: routed within a maximum SAC grade (T1 to T6), the trail grades along the way, and via ferratas on the map
- **Terrain**: elevation profile, ascent and descent, and a 3D terrain map with grade colouring and a flyover, no token needed. Topo, hiking, cycling and satellite bases with sea marks and ski pistes as overlays
- **Multi-day tours**: one route cut into stages and spread across the trip's days
- **Along the way**: viewpoints, huts, water, food and more within a corridor of the route, inserted as a stop with the detour it adds
- **Ski, paddle and city walks**: a piste router by difficulty and lift type; a water router with portages, locks and a wind forecast; city walks with stays, opening hours and transit between stops
- **Plan against reality**: link a recorded GPX to a planned day and compare distance, ascent and time. Import trails from a wanderer instance, save tours to a collection and take them into another trip
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🧳 Bookings and money
- **Reservations**: 18 booking types with status, confirmation code, travellers and attached files
- **Flights and trains**: several legs with stopovers and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
- **Accommodation**: a stay spans a range of nights with a check-in window
- **Booking import**: EML, PDF, PKPass, HTML, TXT and photos of confirmations, through [KItinerary](https://invent.kde.org/pim/kitinerary) or an AI model, with a review step before anything is saved. Prices become linked costs
- **AirTrail** (off by default): import flights from a self-hosted AirTrail instance and keep them in sync
- **Costs**: split in integer cents with equal or custom shares, several payers, settle-up suggestions, a per-person summary, a settlement log and CSV export. A receipt photo can fill in an expense
- **Currencies**: one per expense with the rate frozen at entry, from Frankfurter with no key
- **Packing lists and to-dos**: categories, templates, assignees and visibility tiers; bags with weights as an admin switch; to-dos with due dates, priorities and reminders
- **Files**: attach to a trip, place, day or booking, 50 MB each and 500 MB for video, with trash and restore
- **PDF export**: cover, place photos, day notes, bookings and costs, with an optional page break per day
</td>
<td width="50%" valign="top">
#### 👥 Collaboration
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
- **Public share**: a read-only trip page anyone can open without an account
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
- **Real-time sync** (WebSocket): edits land live for everyone who has the trip open
- **Members**: add by email or username, hand over ownership, or add guests who have no login at all
- **Permissions**: an admin maps each of 17 trip actions to admin, trip owner, trip member or everybody
- **Invites**: one reusable link per trip with an optional expiry, and admin signup invites that drop a new account straight into a trip
- **Public share**: a read-only trip page without an account, with switches for map, bookings, packing, costs and chat
- **Collab**: group chat with replies, reactions and link previews, shared notes, links, polls and a What's Next list, each one its own switch
- **Notifications**: an inbox grouped by day with answers right from the notification, and a per-user matrix of events against in-app, email, webhook, ntfy, Web Push and plugin channels
#### 📔 Journal, Atlas and Vacay
</td>
</tr>
<tr>
<td width="50%" valign="top">
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
#### 📔 Journey and TREK Studio
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads, [Immich](https://immich.app) or Synology Photos, map views, co-authors, drafts and a public share link
- **Linked journal**: a trip can show one journal as its own, with the members picked on the trip as viewers or editors
- **Recorded days** (with Dawarich): keep a day's recorded route and turn its stays into entries
- **TREK Studio**: turn a journal into a printable photo book. 34 print-licensed typefaces, 17 themes, 108 designed pages in twelve styles, 160 recolourable stickers, text effects, layers and a Travel tab with route maps, country stamps and trip figures
- **Auto layout**: lays the whole book on the designed pages in the style of its theme, every photo in a frame, or rebuilds one spread
- **Print and share**: PDF export as pages or spreads with crop marks, several people editing at once, and design files to move a spread into another book
</td>
<td width="50%" valign="top">
#### 🗺️ Atlas, Vacay and Collections
- **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
- **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite
- **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🤖 AI assistant and MCP
- **AI assistant** (off by default): a chat that plans with you in a trip, across all your trips, in a journal and in Studio, and works with tours, costs, bookings, packing, Vacay and the Atlas
- **Your own model**: an API key for Anthropic, OpenAI, Gemini or OpenRouter, a ChatGPT plan, or any OpenAI-compatible endpoint, set for the instance or per user
- **You stay in charge**: reads run at once, every change waits as a card to apply or discard and can be undone for 15 minutes. Nothing leaves before you agree to the provider, and a file's text only with your consent
- **Attachments**: drop a booking confirmation, a GPX file or a photo into the chat, and the assistant offers what to do with it
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration, 410 tools, 30 resources and 4 prompts, 35 scopes in 17 groups ticked on the consent screen, and every call in the audit log
- **Addon-aware**: twelve addon gates decide which tools a session sees, and flipping one re-registers live sessions
</td>
<td width="50%" valign="top">
#### 🧩 Plugins
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
#### 🤖 AI and MCP
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
- **Third-party plugins**: install from the TREK registry or sideload a zip, with their own pages under `/plugins/<id>`
- **Sandboxed**: one child process per plugin, 66 grantable permissions, an admin-edited outbound host allowlist, memory and call caps
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key, a sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
- **Extension points**: map layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, search and photo providers, route profiles, notification channels and MCP tools
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) with a CLI to create, validate, run against real data, sign and publish
</td>
</tr>
@@ -148,30 +182,26 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
#### 📱 Mobile and offline
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
- **Installable**: iOS and Android straight from the browser, no App Store, standalone with a themed status bar
- **Phone layouts**: their own screens for every part of the app, with a bottom bar and dashboard widgets the user orders
- **Offline reads**: the app shell is precached and trips, places, tours, files and nearby places live in a per-user IndexedDB store, so a trip opens with no network
- **Offline writes**: changes queue and replay with an `X-Idempotency-Key`, and a stale edit waits for a keep-mine or keep-theirs choice
- **Offline maps**: raster and vector tiles of a trip downloaded ahead, and a switch that takes the whole app offline on purpose
</td>
<td width="50%" valign="top">
#### ⚙️ Admin, accounts and security
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook, ntfy and Web Push (on every device you turn it on for, even with TREK closed), plus any channel a plugin registers
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
- **Sign-in**: password, OIDC and passkeys each switch on and off on their own, `OIDC_ONLY` leaves SSO as the only way in, and an OIDC claim can grant admin
- **SSO and 2FA**: one OIDC provider by discovery with PKCE (Authentik, Keycloak, Google and the like), TOTP with backup codes that an admin can require, and passkeys that count as the second factor
- **Sessions**: every sign-in is a session you can see and end, one by one or all others at once
- **Hardening**: rate limits on sign-in, reset and 2FA, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
- **Admin panel**: users and the permission matrix, defaults for new users, categories and templates, addons, plugins, storage, notifications, MCP tokens, backups, audit log and releases
- **Backups and storage**: scheduled backups with retention that restore their own secrets, and local or S3-compatible storage per content category with replicas
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, density, reduced motion and text size
- **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
- **Help center**: a ? in the top bar with 381 step-by-step guides with screenshots, searched together with the wiki that ships in the image
</td>
</tr>
+2 -2
View File
@@ -3,12 +3,12 @@
"@typescript-eslint/no-non-null-assertion": 384,
"@typescript-eslint/no-misused-promises": 849,
"no-console": 88,
"complexity": 222,
"complexity": 221,
"@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 130,
"max-params": 32,
"trek/store-without-selector": 67,
"react-hooks/refs": 556,
"react-hooks/refs": 555,
"react-hooks/set-state-in-effect": 194,
"@typescript-eslint/no-floating-promises": 165,
"react-hooks/exhaustive-deps": 116,
-2
View File
@@ -72,7 +72,6 @@
"components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1,
"components/Notifications/InAppNotificationItem.tsx": 1,
"components/PDF/TripPDF.tsx": 53,
"components/Packing/PackingListPanelBagModal.tsx": 2,
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
@@ -295,7 +294,6 @@
"pages/FilesPage.tsx": 11,
"pages/ForgotPasswordPage.tsx": 18,
"pages/HelpPage.tsx": 21,
"pages/InAppNotificationsPage.tsx": 2,
"pages/JoinTripPage.tsx": 4,
"pages/JourneyDetailPage.tsx": 53,
"pages/JourneyPage.tsx": 64,
-5
View File
@@ -5,7 +5,6 @@ import {
type AiApiKeyConnectRequest,
type AiAuthStartResponse,
type AiChatgptFinishRequest,
type AiClaudeConnectorResponse,
type AiConnectionScope,
type AiConnectionView,
type AiInstanceConnectionRequest,
@@ -233,7 +232,3 @@ export const aiAdminApi = {
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
};
export const claudeConnectorApi = {
get: (): Promise<AiClaudeConnectorResponse> => apiClient.get('/integrations/claude-connector').then((r) => r.data),
};
@@ -102,6 +102,13 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
</ul>
);
}
if (block.kind === 'h5') {
return (
<h5 key={idx} className="mb-1 mt-3 font-semibold text-content first:mt-0" style={fs(12.5, 'body')}>
{block.text}
</h5>
);
}
if (block.kind === 'h4') {
return (
<h4 key={idx} className="mb-1 mt-3 font-geist font-bold uppercase tracking-[.08em] text-content-faint first:mt-0" style={fs(10)}>
@@ -37,6 +37,11 @@ const STYLE_LOOK: Readonly<Record<BookDesignStyle, { labelKey: string }>> = {
nordic: { labelKey: 'studio.theme.name.nordic' },
alpine: { labelKey: 'studio.theme.name.alpine' },
'india-stamp': { labelKey: 'studio.theme.name.indiaStamp' },
riso: { labelKey: 'studio.theme.name.riso' },
magazine: { labelKey: 'studio.theme.name.magazine' },
'retro-travel': { labelKey: 'studio.theme.name.retroTravel' },
midnight: { labelKey: 'studio.theme.name.midnight' },
'field-notes': { labelKey: 'studio.theme.name.fieldNotes' },
};
type Translate = (key: string, params?: Record<string, string | number>) => string;
@@ -1,4 +1,4 @@
// FE-JRN-SHARE-001 to FE-JRN-SHARE-014
// FE-JRN-SHARE-001 to FE-JRN-SHARE-015
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
@@ -245,4 +245,15 @@ describe('JourneyShareSection', () => {
expect(plan).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('button', { name: /Recorded routes/ })).toBeDisabled()
})
it('FE-JRN-SHARE-015: a filled accent button sets its words in the colour made for it, not in the accent itself', async () => {
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink() })))
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
const filled = [await screen.findByRole('button', { name: 'Timeline' }), screen.getByRole('button', { name: 'Copy' })]
for (const button of filled) {
expect(button.className).toContain('text-accent-text')
expect(button.className).not.toContain('text-accent-on')
}
})
})
@@ -69,7 +69,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
<span className="flex-1 text-caption text-content-muted truncate">{shareUrl}</span>
<button type="button"
onClick={copyLink}
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-accent text-accent-on text-caption font-semibold hover:bg-accent-hover transition-colors"
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-accent text-accent-text text-caption font-semibold hover:bg-accent-hover transition-colors"
>
{copied ? t('journey.share.copied') : t('journey.share.copy')}
</button>
@@ -94,7 +94,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
onClick={() => togglePerm(key)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-xl border text-body font-medium transition-all ${
link[key]
? 'border-accent bg-accent text-accent-on'
? 'border-accent bg-accent text-accent-text'
: 'border-edge text-content-muted hover:border-content-faint'
}`}
>
@@ -5,7 +5,7 @@ import { Bell, Trash2, CheckCheck } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
import { useAuthStore } from '../../store/authStore'
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
import NotificationDayList from '../Notifications/NotificationDayList'
import ConfirmDialog from '../shared/ConfirmDialog'
export default function InAppNotificationBell(): React.ReactElement {
@@ -73,9 +73,9 @@ export default function InAppNotificationBell(): React.ReactElement {
position: 'fixed',
top: 'var(--nav-h)',
insetInlineEnd: 8,
width: 380,
width: 400,
maxWidth: 'calc(100vw - 16px)',
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
maxHeight: 'min(560px, calc(100vh - var(--nav-h) - 16px))',
zIndex: 9999,
display: 'flex',
flexDirection: 'column',
@@ -119,7 +119,7 @@ export default function InAppNotificationBell(): React.ReactElement {
</div>
{/* Notification list */}
<div className="overflow-y-auto flex-1 py-1 border-t border-edge-secondary">
<div className="overflow-y-auto flex-1 pb-1 border-t border-edge-secondary">
{isLoading && notifications.length === 0 ? (
<div className="flex items-center justify-center py-10">
<div className="w-5 h-5 border-2 rounded-full animate-spin border-edge border-t-content" />
@@ -133,9 +133,7 @@ export default function InAppNotificationBell(): React.ReactElement {
<p className="text-caption text-content-muted">{t('notifications.emptyDescription')}</p>
</div>
) : (
notifications.slice(0, 10).map(n => (
<InAppNotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />
))
<NotificationDayList notifications={notifications.slice(0, 10)} onClose={() => setOpen(false)} compact />
)}
</div>
@@ -1,29 +1,53 @@
import React from 'react'
import { User, Check, X, ArrowRight, Trash2, CheckCheck, Mail } from 'lucide-react'
import { Check, X, ArrowRight, Trash2, CheckCheck, Mail } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { InAppNotification } from '../../store/inAppNotificationStore'
import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
function relativeTime(dateStr: string, locale: string): string {
return compactTime(dateStr, locale === 'ar' ? 'الآن' : 'just now')
}
import { KIND_LOOK, createdAt, kindOf } from './notificationModel'
interface NotificationItemProps {
notification: InAppNotification
onClose?: () => void
}
/** The round tool beside a notification, quiet until it is pointed at. */
const TOOL = 'grid place-items-center w-7 h-7 rounded-lg transition-colors text-content-faint hover:text-content hover:bg-surface-card focus-visible:bg-surface-card focus-visible:text-content'
/** The quiet tool beside a notification, forward only when it is pointed at. */
const TOOL = 'grid place-items-center w-7 h-7 rounded-lg transition-colors text-content-faint hover:text-content hover:bg-surface-secondary focus-visible:bg-surface-secondary focus-visible:text-content'
/** An answer or a way on, as a pill under the words. */
const PILL = 'inline-flex items-center gap-1.5 h-7 px-3 rounded-full text-caption font-medium transition-colors'
/** An answer or a way on, as a button under the words. */
const ACTION = 'inline-flex items-center gap-1.5 h-8 px-3 rounded-lg text-caption font-semibold transition-colors'
/** Under an hour it reads "5m"; after that the clock time, since the list is headed by its day. */
function timeOf(value: string, locale: string, justNow: string): string {
const date = createdAt(value)
if (Date.now() - date.getTime() < 3_600_000) return compactTime(date.toISOString(), justNow)
return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)
}
/** The tile on the start side: the kind's symbol on its colour, and who sent it as a small face on its corner. */
function KindTile({ notification }: { notification: InAppNotification }) {
const { icon: Icon, tone } = KIND_LOOK[kindOf(notification.title_key)]
const initial = notification.sender_username?.charAt(0).toUpperCase()
return (
<span className="relative block self-start flex-shrink-0">
<span className={`grid place-items-center w-10 h-10 rounded-xl ${tone}`}>
<Icon className="w-[18px] h-[18px]" aria-hidden />
</span>
{notification.sender_avatar ? (
<img src={notification.sender_avatar} alt="" className="absolute -bottom-1 -end-1 w-5 h-5 rounded-full object-cover ring-2 ring-surface-card" />
) : initial ? (
<span aria-hidden className="absolute -bottom-1 -end-1 grid place-items-center w-5 h-5 rounded-full text-caption leading-none font-bold bg-accent text-accent-text ring-2 ring-surface-card">
{initial}
</span>
) : null}
</span>
)
}
/**
* One notification, as a card in the list: who sent it, what it says, and what
* can be done with it. Unread ones carry a dot in the accent and a faint
* ground; the tools to mark and delete come forward when the card is pointed
* at or focused, so a list reads as messages rather than as rows of buttons.
* One notification: a tile that says at a glance what it is about and who sent
* it, the message itself, and what can be done with it. Unread ones are set in
* bold with a dot in the accent; marking and deleting come forward when the row
* is pointed at or focused, so a list reads as messages rather than as buttons.
*/
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
const { t, locale } = useTranslation()
@@ -32,112 +56,96 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
const titleText = t(notification.title_key, notification.title_params)
const bodyText = t(notification.text_key, notification.text_params)
const hasUnknownTitle = titleText === notification.title_key
const hasUnknownBody = bodyText === notification.text_key
const unread = !notification.is_read
const answered = notification.response !== null
const { positive_text_key: yesKey, negative_text_key: noKey, navigate_text_key: goKey } = notification
const asks = notification.type === 'boolean' && yesKey && noKey
const leads = notification.type === 'navigate' && goKey && notification.navigate_target
return (
<div
className={`group relative mx-2 my-1 rounded-xl px-3 py-3 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover ${unread ? 'bg-surface-secondary' : ''}`}
>
{unread && <span aria-hidden className="absolute start-1 top-5 w-1.5 h-1.5 rounded-full bg-accent" />}
<div className="group relative flex gap-3.5 px-4 py-3.5 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover">
<KindTile notification={notification} />
<div className="flex gap-3 items-start">
{/* Sender avatar */}
<div className="flex-shrink-0">
{notification.sender_avatar ? (
<img src={notification.sender_avatar} alt="" className="w-9 h-9 rounded-xl object-cover" />
) : (
<div className="w-9 h-9 rounded-xl flex items-center justify-center text-body font-semibold text-accent bg-accent-subtle">
{notification.sender_username
? notification.sender_username.charAt(0).toUpperCase()
: <User className="w-4 h-4" />}
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>
{hasUnknownTitle ? notification.title_key : titleText}
</p>
<div className="relative flex-shrink-0 h-7 flex items-center">
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
{relativeTime(notification.created_at, locale)}
</span>
<div className="absolute end-0 top-0 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
{notification.is_read ? (
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
<Mail className="w-3.5 h-3.5" />
</button>
) : (
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={() => deleteNotification(notification.id)}
title={t('notifications.delete')}
aria-label={t('notifications.delete')}
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
>
<Trash2 className="w-3.5 h-3.5" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-3">
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>{titleText}</p>
<div className="relative flex-shrink-0 h-6 flex items-center gap-2">
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
{timeOf(notification.created_at, locale, t('common.justNow'))}
</span>
{unread && (
<span aria-hidden className="w-2 h-2 rounded-full bg-accent transition-opacity group-hover:opacity-0 group-focus-within:opacity-0" />
)}
<div className="absolute end-0 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
{notification.is_read ? (
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
<Mail className="w-3.5 h-3.5" />
</button>
</div>
) : (
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={() => deleteNotification(notification.id)}
title={t('notifications.delete')}
aria-label={t('notifications.delete')}
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
<p className="text-caption mt-0.5 leading-relaxed text-content-muted">
{hasUnknownBody ? notification.text_key : bodyText}
</p>
<p className="text-body mt-0.5 leading-relaxed text-content-muted">{bodyText}</p>
{/* Boolean actions */}
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
<div className="flex gap-2 mt-2.5">
<button
type="button"
onClick={() => handleRespond('positive')}
disabled={responding || answered}
className={`${PILL} ${
notification.response === 'positive'
? 'bg-accent text-accent-text'
: `bg-surface-card shadow-sm ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
>
<Check className="w-3.5 h-3.5" />
{t(notification.positive_text_key)}
</button>
<button
type="button"
onClick={() => handleRespond('negative')}
disabled={responding || answered}
className={`${PILL} ${
notification.response === 'negative'
? 'bg-danger text-white'
: `bg-surface-card shadow-sm ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
>
<X className="w-3.5 h-3.5" />
{t(notification.negative_text_key)}
</button>
</div>
)}
{/* Navigate action */}
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
{asks && (
<div className="flex flex-wrap gap-2 mt-3">
<button
type="button"
onClick={handleNavigate}
className={`${PILL} mt-2.5 bg-surface-card shadow-sm text-content hover:bg-accent hover:text-accent-text focus-visible:bg-accent focus-visible:text-accent-text`}
onClick={() => handleRespond('positive')}
disabled={responding || answered}
className={`${ACTION} ${
notification.response === 'positive'
? 'bg-success-soft text-success'
: answered
? 'border border-edge text-content-faint'
: 'bg-accent text-accent-text hover:bg-accent-hover'
} disabled:cursor-default`}
>
{t(notification.navigate_text_key)}
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
<Check className="w-3.5 h-3.5" />
{t(yesKey)}
</button>
)}
</div>
<button
type="button"
onClick={() => handleRespond('negative')}
disabled={responding || answered}
className={`${ACTION} ${
notification.response === 'negative'
? 'bg-danger-soft text-danger'
: answered
? 'border border-edge text-content-faint'
: 'border border-edge text-content hover:bg-surface-secondary'
} disabled:cursor-default`}
>
<X className="w-3.5 h-3.5" />
{t(noKey)}
</button>
</div>
)}
{leads && (
<button
type="button"
onClick={handleNavigate}
className={`${ACTION} mt-3 border border-edge text-content hover:bg-accent hover:text-accent-text hover:border-transparent focus-visible:bg-accent focus-visible:text-accent-text`}
>
{t(goKey)}
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
</button>
)}
</div>
</div>
)
@@ -0,0 +1,56 @@
import React, { useMemo } from 'react';
import { useTranslation } from '../../i18n';
import type { InAppNotification } from '../../store/inAppNotificationStore';
import InAppNotificationItem from './InAppNotificationItem';
import { groupByDay } from './notificationModel';
interface NotificationDayListProps {
notifications: InAppNotification[];
/** Closes the bell's panel after a notification led somewhere. */
onClose?: () => void;
/** The bell's panel: headings stick to its top and the days need no cards of their own. */
compact?: boolean;
}
/**
* The notifications cut into days, each under its heading (Today, Yesterday,
* a weekday, then the date). On the page every day is a card of its own; in
* the bell's panel the headings stay at the top while the day scrolls by.
*/
export default function NotificationDayList({
notifications,
onClose,
compact = false,
}: NotificationDayListProps): React.ReactElement {
const { locale } = useTranslation();
const groups = useMemo(() => groupByDay(notifications, locale), [notifications, locale]);
return (
<div className={compact ? '' : 'flex flex-col gap-6'}>
{groups.map((group) => (
<section key={group.key} aria-label={group.label}>
<h2
className={
compact
? 'sticky top-0 z-10 bg-surface-card px-4 pb-1.5 pt-3 text-caption font-semibold text-content-muted'
: 'mb-2 px-1 text-caption font-semibold text-content-muted'
}
>
{group.label}
</h2>
<div
className={
compact
? 'divide-y divide-edge-secondary'
: 'divide-y divide-edge-secondary overflow-hidden rounded-2xl border border-edge bg-surface-card'
}
>
{group.items.map((n) => (
<InAppNotificationItem key={n.id} notification={n} onClose={onClose} />
))}
</div>
</section>
))}
</div>
);
}
@@ -0,0 +1,91 @@
/**
* FE-NOTIF-MODEL-001 to FE-NOTIF-MODEL-007
*
* What a notification's tile shows, how the server's time is read, and how the
* list is cut into days under headings in the reader's language.
*/
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import type { InAppNotification } from '../../store/inAppNotificationStore';
import NotificationDayList from './NotificationDayList';
import { KIND_LOOK, createdAt, dayLabel, groupByDay, kindOf } from './notificationModel';
const note = (id: number, created_at: string, title_key = 'notif.trip_invite.title'): InAppNotification => ({
id,
type: 'simple',
scope: 'trip',
target: 1,
sender_id: null,
sender_username: null,
sender_avatar: null,
recipient_id: 1,
title_key,
title_params: {},
text_key: 'notifications.empty',
text_params: {},
positive_text_key: null,
negative_text_key: null,
response: null,
navigate_text_key: null,
navigate_target: null,
is_read: false,
created_at,
});
const NOW = new Date(2026, 9, 24, 15, 0);
const at = (days: number, hour = 10) => new Date(2026, 9, 24 - days, hour, 0);
describe('notificationModel', () => {
it('FE-NOTIF-MODEL-001: every known title has its kind, anything else is the plain bell', () => {
expect(kindOf('notif.collab_message.title')).toBe('message');
expect(kindOf('notif.replica_failure.title')).toBe('alert');
expect(kindOf('plugin.something.title')).toBe('other');
for (const look of Object.values(KIND_LOOK)) expect(look.tone).toMatch(/^bg-/);
});
it("FE-NOTIF-MODEL-002: the server's UTC text is read as UTC, an ISO string as it is", () => {
expect(createdAt('2026-10-24 08:30:00').toISOString()).toBe('2026-10-24T08:30:00.000Z');
expect(createdAt('2026-10-24T08:30:00.000Z').toISOString()).toBe('2026-10-24T08:30:00.000Z');
});
it('FE-NOTIF-MODEL-003: today and yesterday by name, the week by weekday, older by date', () => {
expect(dayLabel(at(0), 'en', NOW)).toBe('Today');
expect(dayLabel(at(1), 'en', NOW)).toBe('Yesterday');
expect(dayLabel(at(3), 'en', NOW)).toBe('Wednesday');
expect(dayLabel(at(20), 'en', NOW)).toBe('October 4');
expect(dayLabel(new Date(2025, 11, 31), 'en', NOW)).toBe('December 31, 2025');
});
it('FE-NOTIF-MODEL-004: the headings follow the language', () => {
expect(dayLabel(at(0), 'de', NOW)).toBe('Heute');
expect(dayLabel(at(1), 'de', NOW)).toBe('Gestern');
});
it('FE-NOTIF-MODEL-005: the list is cut into days in the order it came', () => {
const groups = groupByDay(
[note(1, at(0, 12).toISOString()), note(2, at(0, 9).toISOString()), note(3, at(2).toISOString())],
'en',
NOW
);
expect(groups.map((g) => [g.label, g.items.map((n) => n.id)])).toEqual([
['Today', [1, 2]],
['Thursday', [3]],
]);
});
it('FE-NOTIF-MODEL-006: an empty list has no days', () => {
expect(groupByDay([], 'en', NOW)).toEqual([]);
});
});
describe('NotificationDayList', () => {
it('FE-NOTIF-MODEL-007: each day is a section under its heading', () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 12);
render(<NotificationDayList notifications={[note(1, now.toISOString()), note(2, yesterday.toISOString())]} />);
expect(screen.getByRole('heading', { name: 'Today' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Yesterday' })).toBeInTheDocument();
expect(screen.getAllByRole('region')).toHaveLength(2);
});
});
@@ -0,0 +1,119 @@
import type { LucideIcon } from 'lucide-react';
import {
Backpack,
Bell,
BookmarkPlus,
CalendarDays,
Images,
ListChecks,
MessageCircle,
Mountain,
Plane,
Sparkles,
Ticket,
TriangleAlert,
} from 'lucide-react';
import type { InAppNotification } from '../../store/inAppNotificationStore';
/** What a notification is about, read from its title key: it picks the symbol and colour of its tile. */
export type NotificationKind =
| 'trip'
| 'message'
| 'booking'
| 'packing'
| 'todo'
| 'vacay'
| 'collection'
| 'photos'
| 'tour'
| 'update'
| 'alert'
| 'other';
const KIND_BY_TITLE: Record<string, NotificationKind> = {
'notif.trip_invite.title': 'trip',
'notif.trip_reminder.title': 'trip',
'notif.collab_message.title': 'message',
'notif.booking_change.title': 'booking',
'notif.packing_tagged.title': 'packing',
'notif.todo_due.title': 'todo',
'notif.vacay_invite.title': 'vacay',
'notif.vacay_share.title': 'vacay',
'notif.collection_invite.title': 'collection',
'notif.photos_shared.title': 'photos',
'notif.wanderer_source_hint.title': 'tour',
'notif.paddle_region_requested.title': 'tour',
'notif.version_available.title': 'update',
'notifications.versionAvailable.title': 'update',
'notif.replica_failure.title': 'alert',
'notifications.synologySessionCleared.title': 'alert',
};
export function kindOf(titleKey: string): NotificationKind {
return KIND_BY_TITLE[titleKey] ?? 'other';
}
/** The symbol of each kind and the tinted tile it sits on. */
export const KIND_LOOK: Record<NotificationKind, { icon: LucideIcon; tone: string }> = {
trip: { icon: Plane, tone: 'bg-info-soft text-info' },
message: { icon: MessageCircle, tone: 'bg-success-soft text-success' },
booking: { icon: Ticket, tone: 'bg-warning-soft text-warning' },
packing: { icon: Backpack, tone: 'bg-warning-soft text-warning' },
todo: { icon: ListChecks, tone: 'bg-success-soft text-success' },
vacay: { icon: CalendarDays, tone: 'bg-info-soft text-info' },
collection: { icon: BookmarkPlus, tone: 'bg-accent-subtle text-accent' },
photos: { icon: Images, tone: 'bg-warning-soft text-warning' },
tour: { icon: Mountain, tone: 'bg-success-soft text-success' },
update: { icon: Sparkles, tone: 'bg-accent-subtle text-accent' },
alert: { icon: TriangleAlert, tone: 'bg-danger-soft text-danger' },
other: { icon: Bell, tone: 'bg-surface-secondary text-content-secondary' },
};
/**
* When a notification was made. The server writes `YYYY-MM-DD HH:MM:SS` in UTC,
* which a bare `new Date()` would read as local time.
*/
export function createdAt(value: string): Date {
return new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(value) ? `${value.replace(' ', 'T')}Z` : value);
}
const dayStart = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
/**
* The heading of the day a notification belongs to, in the reader's language:
* today and yesterday by name, the rest of the week by weekday, anything older
* by its date (with the year when it is not this one).
*/
export function dayLabel(date: Date, locale: string, now: Date = new Date()): string {
const days = Math.round((dayStart(now) - dayStart(date)) / 86_400_000);
if (days <= 1) {
const word = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-Math.max(days, 0), 'day');
return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);
}
if (days < 7) return new Intl.DateTimeFormat(locale, { weekday: 'long' }).format(date);
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'long',
...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
}).format(date);
}
export interface NotificationGroup {
key: string;
label: string;
items: InAppNotification[];
}
/** The list cut into days, in the order it came (newest first). */
export function groupByDay(list: InAppNotification[], locale: string, now: Date = new Date()): NotificationGroup[] {
const groups: NotificationGroup[] = [];
for (const n of list) {
const date = createdAt(n.created_at);
const key = String(dayStart(date));
const current = groups[groups.length - 1];
if (current?.key === key) current.items.push(n);
else groups.push({ key, label: dayLabel(date, locale, now), items: [n] });
}
return groups;
}
@@ -33,12 +33,6 @@ function mcpEndpointRow() {
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
const LAN_CONNECTOR = {
mcpUrl: 'http://192.168.1.20:3000/mcp',
deepLink: 'https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=TREK&connectorUrl=x',
qrSvg: '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
};
function enableAddons(ids: string[]) {
seedStore(useAddonStore, {
addons: ids.map(id => ({ id, name: id, type: 'integration', icon: '', enabled: true })),
@@ -71,7 +65,6 @@ beforeEach(() => {
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
http.get('/api/oauth/clients', () => HttpResponse.json({ clients: [] })),
http.get('/api/oauth/sessions', () => HttpResponse.json({ sessions: [] })),
http.get('/api/integrations/claude-connector', () => HttpResponse.json(LAN_CONNECTOR)),
);
});
@@ -1026,25 +1019,4 @@ describe('IntegrationsTab – failure toasts', () => {
render(<IntegrationsTab />);
expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument();
});
it('FE-COMP-INTEGRATIONS-049: the Claude card explains why a LAN install cannot be added', async () => {
enableMcp();
render(<IntegrationsTab />);
expect(await screen.findByText('Claude can only reach TREK when it is available over https on the internet.')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /Open in Claude/ })).not.toBeInTheDocument();
});
it('FE-COMP-INTEGRATIONS-050: the Claude card links a public install into Claude', async () => {
server.use(
http.get('/api/integrations/claude-connector', () =>
HttpResponse.json({ ...LAN_CONNECTOR, mcpUrl: 'https://trip.example.org/mcp' }),
),
);
enableMcp();
render(<IntegrationsTab />);
const link = await screen.findByRole('link', { name: /Open in Claude/ });
expect(link).toHaveAttribute('href', LAN_CONNECTOR.deepLink);
expect(link).toHaveAttribute('target', '_blank');
expect(screen.getByText('https://trip.example.org/mcp')).toBeInTheDocument();
});
});
@@ -8,7 +8,6 @@ import AirTrailConnectionSection from './AirTrailConnectionSection'
import DawarichConnectionSection from './DawarichConnectionSection'
import LlmConnectionSection from './LlmConnectionSection'
import AiConnectionSection from './ai/AiConnectionSection'
import ClaudeConnectorCard from './ai/ClaudeConnectorCard'
import ApiKeysSection from './ApiKeysSection'
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
import ConfirmDialog from '../shared/ConfirmDialog'
@@ -45,7 +44,6 @@ export default function IntegrationsTab(): React.ReactElement {
one should not have to read past a section about AI assistants. */}
<ApiKeysSection />
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
{S.mcpEnabled && <ClaudeConnectorCard />}
{S.mcpEnabled && <IntegrationsMcpSection S={S} />}
<McpTokenModals S={S} />
<OAuthClientModals S={S} />
@@ -3,8 +3,7 @@ import userEvent from '@testing-library/user-event';
import type { AiStatusResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useAddonStore } from '../../../store/addonStore';
import { resetAllStores } from '../../../../tests/helpers/store';
import { useAssistantStore } from '../../../store/assistantStore';
const repo = vi.hoisted(() => ({
@@ -173,17 +172,11 @@ describe('AiConnectPanel', () => {
expect(screen.getByRole('button', { name: /Connect OpenRouter/ })).toBeInTheDocument();
});
it('FE-COMP-AI-CONNECT-PANEL-008: "Using Claude?" shows with the MCP addon and the sharing details fold out', async () => {
it('FE-COMP-AI-CONNECT-PANEL-008: the sharing details fold out', async () => {
const user = userEvent.setup();
const { unmount } = render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
expect(screen.queryByText('Using Claude? Connect in Claude')).not.toBeInTheDocument();
render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /What gets shared\?/ }));
expect(screen.getByText('Not your password, your files or your other trips.')).toBeInTheDocument();
unmount();
seedStore(useAddonStore, { addons: [{ id: 'mcp', name: 'MCP', type: 'integration', icon: '', enabled: true }] });
render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
expect(screen.getByRole('button', { name: 'Using Claude? Connect in Claude' })).toBeInTheDocument();
});
it('FE-COMP-AI-CONNECT-PANEL-009: the admin signing in with ChatGPT is told the plan is shared, and the sign-in is for everyone', async () => {
@@ -2,14 +2,13 @@ import type { AiApiKeyKind, AiConnectionScope, AiParsingPrefill } from '@trek/sh
import { ChevronRight, KeyRound, Loader2, Server, ShieldCheck } from 'lucide-react';
import React, { useId, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore';
import { useAssistantStore } from '../../../store/assistantStore';
import { AI_PROVIDERS, API_KEY_KINDS, providerLabel } from '../../Assistant/providerCatalog';
import { fs } from '../../shared/DialogShell';
import { EditorField, GRID_2, INPUT } from '../../shared/dialogParts';
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint, StatusPill } from '../settingsKit';
import ChatGptPasteStep, { ConnectErrorNote } from './ChatGptPasteStep';
import { CLAUDE_CONNECTOR_ANCHOR, type AiConnectMode } from './aiConnectModel';
import { type AiConnectMode } from './aiConnectModel';
import { useChatgptConnect, useCustomConnect, useKeyConnect, useOpenrouterStart } from './useAiConnect';
interface AiConnectPanelProps {
@@ -399,7 +398,6 @@ export default function AiConnectPanel({ scope, onConnected, initialMode, prefil
const chatgpt = useChatgptConnect({ active: mode === 'chatgpt', onConnected, scope });
const keyConnect = useKeyConnect({ scope, prefill, onConnected });
const custom = useCustomConnect({ prefill, onConnected });
const mcpEnabled = useAddonStore((s) => s.isEnabled('mcp'));
// The privacy line names whoever would receive the messages, once that is known.
const receivers: Record<AiConnectMode, string> = {
@@ -450,18 +448,6 @@ export default function AiConnectPanel({ scope, onConnected, initialMode, prefil
{mode === 'custom' && <CustomForm custom={custom} />}
<PrivacyNote provider={mode ? receivers[mode] : ''} />
{scope === 'user' && mcpEnabled && (
<button
type="button"
onClick={() =>
document.getElementById(CLAUDE_CONNECTOR_ANCHOR)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
className="self-start font-medium text-content-muted underline underline-offset-2 hover:text-content"
style={fs(12, 'body')}
>
{t('assistant.connect.claudeLink')}
</button>
)}
</div>
);
}
@@ -1,63 +0,0 @@
// FE-COMP-CLAUDE-CONNECTOR-001 to FE-COMP-CLAUDE-CONNECTOR-003: adding TREK to Claude as a connector.
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '../../../../tests/helpers/render';
const repo = vi.hoisted(() => ({ claudeConnector: vi.fn() }));
const clipboard = vi.hoisted(() => ({ copyText: vi.fn() }));
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
vi.mock('../../../utils/clipboard', () => clipboard);
const { default: ClaudeConnectorCard } = await import('./ClaudeConnectorCard');
const CONNECTOR = {
mcpUrl: 'https://trip.example.org/mcp',
deepLink:
'https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=TREK&connectorUrl=https%3A%2F%2Ftrip.example.org%2Fmcp',
qrSvg: '<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>',
};
beforeEach(() => {
vi.clearAllMocks();
clipboard.copyText.mockResolvedValue(true);
});
describe('ClaudeConnectorCard', () => {
it('FE-COMP-CLAUDE-CONNECTOR-001: links a public install into Claude, with a QR code that is an image, not markup', async () => {
repo.claudeConnector.mockResolvedValue(CONNECTOR);
const { container } = render(<ClaudeConnectorCard />);
const link = await screen.findByRole('link', { name: /Open in Claude/ });
expect(link).toHaveAttribute('href', CONNECTOR.deepLink);
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
const qr = screen.getByAltText('QR code to add TREK in Claude');
expect(qr.getAttribute('src')).toMatch(/^data:image\/svg\+xml;charset=utf-8,/);
// The QR code's own markup never lands in the page; icons may draw rects of their own.
expect(container.querySelector('rect[width="1"][height="1"]')).toBeNull();
expect(document.getElementById('claude-connector')).not.toBeNull();
});
it('FE-COMP-CLAUDE-CONNECTOR-002: copies the address and shows the tick for a moment', async () => {
const user = userEvent.setup();
repo.claudeConnector.mockResolvedValue(CONNECTOR);
render(<ClaudeConnectorCard />);
await user.click(await screen.findByRole('button', { name: 'Copy address' }));
expect(clipboard.copyText).toHaveBeenCalledWith(CONNECTOR.mcpUrl);
await waitFor(() => expect(document.querySelector('.text-success')).not.toBeNull());
});
it('FE-COMP-CLAUDE-CONNECTOR-003: a plain-http or LAN install gets the reason instead of a link, and a failed read only the description', async () => {
repo.claudeConnector.mockResolvedValueOnce({ ...CONNECTOR, mcpUrl: 'http://192.168.1.20:3000/mcp' });
const { unmount } = render(<ClaudeConnectorCard />);
expect(
await screen.findByText('Claude can only reach TREK when it is available over https on the internet.')
).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
unmount();
repo.claudeConnector.mockRejectedValueOnce(new Error('404'));
render(<ClaudeConnectorCard />);
expect(screen.getByText('Use TREK right inside Claude: add it as a connector, sign in, done.')).toBeInTheDocument();
await waitFor(() => expect(repo.claudeConnector).toHaveBeenCalledTimes(2));
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
});
@@ -1,98 +0,0 @@
import { Check, Copy, ExternalLink, MessagesSquare } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingsCard, SettingsHint } from '../settingsKit';
import { CLAUDE_CONNECTOR_ANCHOR } from './aiConnectModel';
import { useClaudeConnector } from './useClaudeConnector';
type ClaudeConnector = ReturnType<typeof useClaudeConnector>;
/** The way in: the button that adds TREK on claude.ai, the address to copy, and the QR code for a phone. */
function ConnectorLinks({
connector,
qrSrc,
copied,
copyUrl,
}: Omit<ClaudeConnector, 'connector' | 'reachable'> & { connector: NonNullable<ClaudeConnector['connector']> }) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center gap-5">
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-3">
<div className="flex">
<a
href={connector.deepLink}
target="_blank"
rel="noopener noreferrer"
className={SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
<ExternalLink size={14} strokeWidth={2.2} /> {t('assistant.claude.open')}
</a>
</div>
<div className="flex items-center gap-2">
<code
dir="ltr"
className="block min-w-0 flex-1 truncate rounded-[10px] border border-edge-faint bg-surface-tertiary px-3 py-2 font-mono text-content"
style={fs(12, 'body')}
>
{connector.mcpUrl}
</code>
<Tooltip label={copied ? t('common.copied') : t('assistant.claude.copyUrl')}>
<button
type="button"
onClick={() => void copyUrl()}
aria-label={t('assistant.claude.copyUrl')}
className={SETTINGS_ICON_BUTTON}
>
{copied ? (
<Check size={15} strokeWidth={2.2} className="text-success" />
) : (
<Copy size={15} strokeWidth={2} />
)}
</button>
</Tooltip>
</div>
</div>
<figure className="m-0 flex flex-none flex-col items-center gap-1.5 max-sm:hidden">
<img
src={qrSrc}
alt={t('assistant.claude.qrAlt')}
width={112}
height={112}
className="rounded-[10px] shadow-sm ring-1 ring-edge-faint"
/>
<figcaption className="text-content-faint" style={fs(11)}>
{t('assistant.claude.scan')}
</figcaption>
</figure>
</div>
);
}
/**
* "Connect with Claude": TREK as a connector inside Claude, over the MCP
* server and its OAuth. One button adds it on claude.ai; the QR code does the
* same from a phone, so it only shows where a phone is not already in hand.
*/
export default function ClaudeConnectorCard(): React.ReactElement {
const { t } = useTranslation();
const { connector, reachable, ...links } = useClaudeConnector();
// Null keeps the card to its band until the address is known.
let body: React.ReactNode = null;
if (connector && reachable) body = <ConnectorLinks connector={connector} {...links} />;
else if (connector) body = <SettingsHint>{t('assistant.claude.notPublic')}</SettingsHint>;
return (
<SettingsCard
id={CLAUDE_CONNECTOR_ANCHOR}
icon={MessagesSquare}
title={t('assistant.claude.title')}
hint={t('assistant.claude.description')}
>
{body}
</SettingsCard>
);
}
@@ -9,9 +9,6 @@ import { OnlineOnlyError } from '../../../repo/onlineOnly';
/** How a connection is being set up: a ChatGPT sign-in, a pasted key, or (admin only) an own server. */
export type AiConnectMode = 'chatgpt' | 'key' | 'custom';
/** Where the Claude connector card sits, for the "Using Claude?" line to jump to. */
export const CLAUDE_CONNECTOR_ANCHOR = 'claude-connector';
/** The `code` of a refused AI request (`{ error, code }`), or null when the failure carries none. */
export function refusalCode(err: unknown): string | null {
if (err instanceof OnlineOnlyError) return 'offline';
@@ -1,56 +0,0 @@
import type { AiClaudeConnectorResponse } from '@trek/shared';
import { useEffect, useRef, useState } from 'react';
import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo';
import { copyText } from '../../../utils/clipboard';
import { isPublicHttpsUrl, svgDataUrl } from './aiConnectModel';
/**
* Adding TREK to Claude as a connector (no AI connection of TREK's own
* involved): the MCP address, the claude.ai link that adds it, and a QR code
* for doing it from a phone. Claude reaches TREK from the internet, so on a
* LAN or plain-http install the card explains that instead of offering a link
* that would fail after the user did everything right.
*/
export function useClaudeConnector() {
const [connector, setConnector] = useState<AiClaudeConnectorResponse | null>(null);
const [copied, setCopied] = useState(false);
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let cancelled = false;
aiConnectionsRepo.claudeConnector().then(
(next) => {
if (!cancelled) setConnector(next);
},
() => {
// Without an answer the card keeps to its description; nothing to link to.
if (!cancelled) setConnector(null);
}
);
return () => {
cancelled = true;
};
}, []);
useEffect(
() => () => {
if (copiedTimer.current) clearTimeout(copiedTimer.current);
},
[]
);
const copyUrl = async () => {
if (!connector || !(await copyText(connector.mcpUrl))) return;
setCopied(true);
if (copiedTimer.current) clearTimeout(copiedTimer.current);
copiedTimer.current = setTimeout(() => setCopied(false), 2000);
};
return {
connector,
reachable: connector ? isPublicHttpsUrl(connector.mcpUrl) : false,
qrSrc: connector ? svgDataUrl(connector.qrSvg) : '',
copied,
copyUrl,
};
}
@@ -90,7 +90,7 @@ describe('the Templates panel', () => {
it('narrows the set to one archetype from the style chips', () => {
panel();
const chips = screen.getByRole('radiogroup', { name: 'studio.templates.style' });
expect(chips.querySelectorAll('[role="radio"]')).toHaveLength(8);
expect(chips.querySelectorAll('[role="radio"]')).toHaveLength(13);
fireEvent.click(screen.getByRole('radio', { name: 'studio.templates.archetype.postcard' }));
expect(
screen.getByRole('radio', { name: 'studio.templates.archetype.postcard' }).getAttribute('aria-checked')
@@ -22,7 +22,7 @@ import { ThemeSheet } from './ThemeSheet';
import { useStudioTheme } from './useStudioTheme';
/**
* The Theme section of the rail: twelve built-in looks, the palette, the
* The Theme section of the rail: seventeen built-in looks, the palette, the
* typefaces and the motifs of the one being put together, and applying it to
* the book or to the page that is open.
*
@@ -1,100 +1,56 @@
import type {
BookBadgeStyle,
BookBadgeVariant,
BookColorToken,
BookCountryLook,
BookElement,
BookMapElement,
BookMapScope,
BookMetric,
BookPageSetup,
BookStatsLook,
BookTheme,
BookTravelMapCard,
BookTravelMarkVariant,
JourneyStats,
} from '@trek/shared';
import {
bookBadgeValue,
bookCountryName,
bookTravelBadge,
bookTravelCountries,
bookTravelMap,
bookTravelSize,
bookTravelStats,
themedElement,
BOOK_TRAVEL_COUNTRY_LOOKS,
BOOK_TRAVEL_FIGURE_SHELVES,
BOOK_TRAVEL_MARK_STYLES,
BOOK_TRAVEL_MARK_VARIANTS,
BOOK_TRAVEL_SUMMARY_LOOKS,
bookTravelLooks,
bookElementId as uid,
} from '@trek/shared';
import { studioTravelContext } from '../studioTravelContext';
/**
* What the Travel panel offers, built: every card is the element its click
* places, made by the book engine's own factories (book-travel.ts) so a card
* and what the assistant places are one thing, then set in one of the newer
* looks and dressed in the book's theme.
* places, made by the book's own travel looks (book-travel-looks.ts in
* shared), the same factory the auto layout builds its framing pages with,
* so a card and a designed page are one thing.
*
* Pure, so the panel stays a list of shelves: it asks for an element and
* renders it through the page's own renderer, and the preview and the result
* are the same object.
* are the same object. What is the panel's own here is where a new element
* lands: centred on the spread on screen.
*/
/** The figures a summary offers: how far, how long, where, and how much was kept. */
const SUMMARY_METRICS: BookMetric[] = ['distance', 'days', 'countries', 'photos'];
/** The looks of a summary, in the order the panel offers them. */
export const SUMMARY_LOOKS: BookStatsLook[] = [
'poster',
'ticket',
'tiles',
'sign',
'odometer',
'note',
'sketch',
'band',
];
export const SUMMARY_LOOKS: readonly BookStatsLook[] = BOOK_TRAVEL_SUMMARY_LOOKS;
/** The marks the journey fills, in the order a page is read: when, which day, how far, where. */
export const MARK_VARIANTS = ['date', 'day', 'distance', 'coords', 'flag'] as const;
export type MarkVariant = (typeof MARK_VARIANTS)[number];
export const MARK_VARIANTS = BOOK_TRAVEL_MARK_VARIANTS;
export type MarkVariant = BookTravelMarkVariant;
/** The newer styles a mark comes in, and which of them each mark is offered in. */
export const MARK_STYLES: { style: BookBadgeStyle; variants: readonly MarkVariant[] }[] = [
{ style: 'stamp', variants: ['date', 'day', 'flag'] },
{ style: 'tag', variants: ['day', 'distance', 'coords', 'flag'] },
{ style: 'pill', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
{ style: 'ribbon', variants: ['date', 'day', 'distance', 'flag'] },
{ style: 'sticker', variants: ['date', 'day', 'distance', 'flag'] },
{ style: 'patch', variants: ['day', 'distance', 'flag'] },
{ style: 'sketch', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
];
export const MARK_STYLES: readonly { style: BookBadgeStyle; variants: readonly MarkVariant[] }[] =
BOOK_TRAVEL_MARK_STYLES;
/** The single figures, one shelf per look, with the figures each look suits. */
export const FIGURE_SHELVES: { look: BookStatsLook; metrics: readonly BookMetric[] }[] = [
{ look: 'medal', metrics: ['distance', 'days', 'steps', 'photos', 'countries', 'places', 'furthest'] },
{ look: 'odometer', metrics: ['distance', 'furthest', 'days', 'photos'] },
{ look: 'sketch', metrics: ['distance', 'days', 'countries', 'photos'] },
];
export const FIGURE_SHELVES: readonly { look: BookStatsLook; metrics: readonly BookMetric[] }[] =
BOOK_TRAVEL_FIGURE_SHELVES;
/** The looks a country is offered in. */
export const COUNTRY_LOOKS: BookCountryLook[] = ['stamp', 'visa', 'postcard', 'flag', 'pin', 'label', 'sketch'];
/** The looks written by hand take the theme's script face rather than its heading. */
const HANDWRITTEN = new Set<string>(['note', 'sketch']);
/** Which of the theme's accents each country look takes, so a shelf of them is not one colour. */
const COUNTRY_ACCENT: Partial<Record<BookCountryLook, BookColorToken>> = {
stamp: 'accent2',
visa: 'accent3',
pin: 'accent',
label: 'accent2',
};
/** Which of the theme's accents each mark takes, so a page of them is not one colour. */
const MARK_ACCENT: Record<MarkVariant, BookColorToken> = {
date: 'accent',
day: 'accent2',
distance: 'accent3',
coords: 'accent',
flag: 'accent2',
};
export const COUNTRY_LOOKS: readonly BookCountryLook[] = BOOK_TRAVEL_COUNTRY_LOOKS;
/** The map cards: the photographed route, its stops numbered, its two ends marked, and the dark ground. */
export const MAP_CARDS: {
@@ -130,130 +86,19 @@ export function travelBuilders({ page, stats, path, locale, t, theme, single }:
return { x: (W - size.w) / 2, y: (page.pageHeight - size.h) / 2, w: size.w, h: size.h };
};
/**
* An element dressed in the book's theme: its words in the theme's ink and
* heading face, its colour one of the theme's accents, all three linked so
* it follows when the theme changes. A book without a theme keeps the
* engine's own colours.
*/
const dress = (el: BookElement, accent: BookColorToken = 'accent', hand = false): BookElement =>
theme
? themedElement(
{ ...el, token: 'ink', accentToken: accent, fontRole: hand ? 'script' : 'heading' } as BookElement,
theme
)
: el;
const looks = bookTravelLooks({ page, stats, travel, theme, newId: uid, place: at });
/** A summary in one of the newer looks; a band is a strip, so it is set a third as tall. */
const summaryEl = (look: BookStatsLook): BookElement => {
const w = page.pageWidth * (look === 'note' || look === 'sign' ? 0.56 : 0.72);
const h =
look === 'band' ? page.pageHeight * 0.13 : look === 'ticket' ? w * 0.42 : look === 'note' ? w * 0.72 : w * 0.5;
const el = { ...bookTravelStats(stats, { id: uid('st'), frame: at({ w, h }), metrics: SUMMARY_METRICS }), look };
return dress(el, look === 'sign' ? 'accent3' : 'accent', HANDWRITTEN.has(look));
return {
summaryEl: (look: BookStatsLook) => looks.summaryEl(look),
figureEl: (metric: BookMetric, look: BookStatsLook = 'medal') => looks.figureEl(metric, look),
mapEl: (
card: BookTravelMapCard,
scope: BookMapScope = 'journey',
source: 'vector' | 'relief' | 'satellite' = 'vector'
) => looks.mapEl(card, scope, source),
countryMapEl: (code: string, kind: 'shape' | 'satellite' | 'box') => looks.countryMapEl(code, kind),
markEl: (variant: MarkVariant, style: BookBadgeStyle) => looks.markEl(variant, style),
countryEl: (code: string, look: BookCountryLook) => looks.countryEl(code, look),
countryName: looks.countryName,
};
/**
* One figure on its own, the mark to drop beside a photograph: as a medal, on
* the drums of an odometer, or ringed by hand.
*/
const figureEl = (metric: BookMetric, look: BookStatsLook = 'medal'): BookElement => {
const side = Math.min(page.pageWidth, page.pageHeight) * 0.3;
const size = look === 'odometer' ? { w: side * 1.6, h: side * 0.62 } : { w: side, h: side };
const el = { ...bookTravelStats(stats, { id: uid('st'), frame: at(size), metrics: [metric] }), look };
return dress(el, 'accent', HANDWRITTEN.has(look));
};
/**
* A map as the engine places it, on the card's ground and with the card's
* stops. The scope is the journey or one trip; the ground is the outlines
* or one of the two imagery sources.
*/
const mapEl = (
card: { style: BookMapElement['style']; pins?: BookMapElement['pinStyle'] },
scope: BookMapScope = 'journey',
source: 'vector' | 'relief' | 'satellite' = 'vector'
): BookElement => {
const el = bookTravelMap(travel, stats, { id: uid('mp'), frame: at(bookTravelSize('map', page)), scope, source });
const shaped = { ...el, style: card.style, ...(card.pins ? { pinStyle: card.pins } : {}) };
// Over imagery the line is white on purpose (book-travel.ts); only the outline maps take the theme.
return source === 'vector' ? dress(shaped) : shaped;
};
/**
* A map of one country: its stops alone, without the way there. A journey's
* recorded way often starts with the flight from home, and fitted to all of
* it the country is a speck at the edge of a continent; the page a travel
* book wants is the country, cut to its own shape on the paper or over the
* satellite picture, or the country in a box.
*/
const countryMapEl = (code: string, kind: 'shape' | 'satellite' | 'box'): BookElement => {
const own = {
...stats,
points: stats.points.filter((p) => p.country === code),
countries: stats.countries.filter((c) => c.code === code),
};
const ctx = studioTravelContext(own, [], locale, t);
const source = kind === 'satellite' ? 'satellite' : 'vector';
const el = bookTravelMap(ctx, own, {
id: uid('mp'),
frame: at(bookTravelSize('map', page)),
scope: 'journey',
source,
});
// Cut to its shape on the paper, the land is dark, so the silhouette reads and the route in the accent stands on it.
const style = kind === 'shape' ? 'dark' : 'minimal';
const shaped = {
...el,
style,
clip: kind === 'box' ? 'rect' : 'country',
pinStyle: 'numbered',
fitToCountries: true,
} as const;
return source === 'vector' ? dress(shaped) : shaped;
};
/** A mark in one of the newer styles, at the size its variant suits; a stamp is set taller for its two rules. */
const markEl = (variant: MarkVariant, style: BookBadgeStyle): BookElement | null => {
const value = bookBadgeValue(travel, variant, null);
if (!value) return null;
const frame = at(markSize(style, value.text, value.sub));
const el = { ...bookTravelBadge({ id: uid('bd'), frame, variant: variant as BookBadgeVariant, value }), style };
return dress(el, MARK_ACCENT[variant], style === 'sketch');
};
/**
* How large a mark is set: as wide as its words need. A stamp stacks them in
* a box with room for its two rules; a tag or a pill sets them in a line
* after its eyelet or its disc, so its length follows the words.
*/
const markSize = (style: BookBadgeStyle, text: string, sub: string) => {
const twoLines = !!text && !!sub && text.length > 14;
const h = page.pageHeight * (style === 'stamp' ? 0.12 : twoLines ? 0.1 : 0.066);
const main = text || sub;
if (style === 'stamp') return { w: Math.max(h * 1.3, h * 0.2 * Math.max(main.length * 1.4, sub.length)), h };
// Two lines are set smaller than one, so they need less width a letter.
if (twoLines)
return { w: Math.min(page.pageWidth * 0.6, h * (1.5 + 0.13 * Math.max(text.length, sub.length * 0.7))), h };
const chars = main.length * (text ? 1 : 0.85) + (text ? sub.length * 0.5 : 0);
return { w: Math.min(page.pageWidth * 0.6, h * (1.6 + 0.3 * chars)), h };
};
/** One country as a square stamp, or as a card in the proportions of a postcard. */
const countryEl = (code: string, look: BookCountryLook): BookElement => {
const side = Math.min(page.pageWidth, page.pageHeight) * 0.36;
const ratio = look === 'stamp' || look === 'pin' || look === 'sketch' ? 1 : look === 'postcard' ? 1.36 : 1.4;
const frame = at({ w: side * ratio, h: side });
const el = {
...bookTravelCountries(travel, { id: uid('co'), frame, codes: [code] }),
look,
showFlag: look === 'pin' || look === 'postcard',
};
return dress(el, COUNTRY_ACCENT[look] ?? 'accent', look === 'sketch');
};
/** A country's name in the reader's language, from the same CLDR data Atlas uses. */
const countryName = (code: string): string => bookCountryName(code, locale, stats);
return { summaryEl, figureEl, mapEl, countryMapEl, markEl, countryEl, countryName };
}
@@ -83,6 +83,13 @@ export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelea
</ul>
)
}
if (block.kind === 'h5') {
return (
<h5 key={idx} className="text-caption font-semibold mt-3 mb-1 text-m-ink">
{block.text}
</h5>
);
}
if (block.kind === 'h4') {
return (
<h4 key={idx} className="text-xs font-semibold mt-3 mb-1 text-m-ink">
@@ -1,15 +1,10 @@
import { Check, Copy, ExternalLink, MessagesSquare } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import MPhotoProvidersSection from './MPhotoProvidersSection'
import MAirTrailConnectionSection from './MAirTrailConnectionSection'
import MDawarichConnectionSection from './MDawarichConnectionSection'
import MLlmConnectionSection from './MLlmConnectionSection'
import MAiConnectionSection from './MAiConnectionSection'
import MSettingsMcp from './MSettingsMcp'
import { MSetButton, MSetCard, MSetHint } from './MSettingsUi'
import { useIntegrationGates } from '../../../components/Settings/useIntegrationGates'
import { useClaudeConnector } from '../../../components/Settings/ai/useClaudeConnector'
import { CLAUDE_CONNECTOR_ANCHOR } from '../../../components/Settings/ai/aiConnectModel'
/**
* "Integrations" section. The photo-provider / AirTrail / LLM connection forms
@@ -33,35 +28,7 @@ export default function MSettingsIntegrations() {
a managed install. The per-user fallback exists for people who supply their
own key, and there nobody does. */}
{llmEnabled && ownParsing && !managed && <MLlmConnectionSection />}
{mcpEnabled && <MClaudeConnectorCard />}
{mcpEnabled && <MSettingsMcp />}
</>
)
}
/**
* "Connect with Claude" on the phone: the button and the address only. The QR
* code is for reaching a phone from a desktop, and this is the phone.
*/
function MClaudeConnectorCard() {
const { t } = useTranslation()
const { connector, reachable, copied, copyUrl } = useClaudeConnector()
return (
<div id={CLAUDE_CONNECTOR_ANCHOR}>
<MSetCard title={t('assistant.claude.title')} icon={MessagesSquare} className="mt-3">
<MSetHint className="mb-3">{t('assistant.claude.description')}</MSetHint>
{connector && !reachable && <MSetHint>{t('assistant.claude.notPublic')}</MSetHint>}
{connector && reachable && (
<div className="flex flex-wrap gap-2">
<MSetButton onClick={() => window.open(connector.deepLink, '_blank', 'noopener,noreferrer')}>
<ExternalLink size={14} /> {t('assistant.claude.open')}
</MSetButton>
<MSetButton variant="ghost" onClick={() => void copyUrl()}>
{copied ? <Check size={14} /> : <Copy size={14} />} {copied ? t('common.copied') : t('assistant.claude.copyUrl')}
</MSetButton>
</div>
)}
</MSetCard>
</div>
)
}
+18 -20
View File
@@ -6,7 +6,7 @@ import HelpAnchor from '../components/Help/HelpAnchor'
import EmptyState from '../components/shared/EmptyState'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import { Spinner } from '../components/shared/Spinner'
import InAppNotificationItem from '../components/Notifications/InAppNotificationItem.tsx'
import NotificationDayList from '../components/Notifications/NotificationDayList'
import { useInAppNotifications } from './inAppNotifications/useInAppNotifications'
export default function InAppNotificationsPage(): React.ReactElement {
@@ -88,27 +88,25 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
</button>
</div>
{/* Notification list */}
<div className="rounded-2xl border overflow-hidden border-edge bg-surface-card py-1">
{isLoading && displayed.length === 0 ? (
<div className="flex items-center justify-center py-16">
<Spinner className="w-6 h-6 border-2 border-slate-200 border-t-current" />
</div>
) : displayed.length === 0 ? (
{/* The list, one card per day. */}
{isLoading && displayed.length === 0 ? (
<div className="flex items-center justify-center py-16">
<Spinner className="w-6 h-6 border-2 border-edge border-t-current" />
</div>
) : displayed.length === 0 ? (
<div className="rounded-2xl border border-edge bg-surface-card">
<EmptyState scene="notifications" title={t('notifications.empty')} />
) : (
displayed.map(n => (
<InAppNotificationItem key={n.id} notification={n} />
))
)}
</div>
) : (
<NotificationDayList notifications={displayed} />
)}
{/* Infinite scroll trigger */}
{hasMore && (
<div ref={loaderRef} className="flex items-center justify-center py-4">
{isLoading && <Spinner className="w-5 h-5 border-2 border-slate-200 border-t-current" />}
</div>
)}
</div>
{/* Infinite scroll trigger */}
{hasMore && (
<div ref={loaderRef} className="flex items-center justify-center py-4">
{isLoading && <Spinner className="w-5 h-5 border-2 border-edge border-t-current" />}
</div>
)}
</div>
<ConfirmDialog
isOpen={confirmingDeleteAll}
@@ -37,10 +37,13 @@ vi.mock('../../api/websocket', async importOriginal => ({
// The auto layout lives in @trek/shared now; the hook's call into it is watched through a partial mock.
const shared = await vi.importActual<typeof import('@trek/shared')>('@trek/shared')
const buildBook = vi.fn(shared.buildBook)
const autoDesignedBook = vi.fn(shared.autoDesignedBook)
vi.mock('@trek/shared', async importOriginal => {
const actual = await importOriginal<typeof import('@trek/shared')>()
return { ...actual, buildBook: (...args: Parameters<typeof actual.buildBook>) => buildBook(...args) }
return {
...actual,
autoDesignedBook: (...args: Parameters<typeof actual.autoDesignedBook>) => autoDesignedBook(...args),
}
})
import { useJourneyStudio } from './useJourneyStudio'
@@ -67,7 +70,7 @@ function seedJourney() {
beforeEach(() => {
seedJourney()
buildBook.mockClear()
autoDesignedBook.mockClear()
useStudioStore.setState({ doc: null, selection: [], activeSpread: 0, past: [], future: [], readOnly: false })
vi.spyOn(journeyApi, 'getBook').mockResolvedValue({ book: null } as never)
vi.spyOn(journeyApi, 'saveBook').mockResolvedValue({} as never)
@@ -140,8 +143,8 @@ describe('the auto layout input', () => {
act(() => { result.current.relayoutBook() })
expect(buildBook).toHaveBeenCalled()
const input = buildBook.mock.calls[buildBook.mock.calls.length - 1][0]
expect(autoDesignedBook).toHaveBeenCalled()
const input = autoDesignedBook.mock.calls[autoDesignedBook.mock.calls.length - 1][1]
expect(input.path).toEqual([TRACK])
})
@@ -176,9 +179,51 @@ describe('the auto layout input', () => {
act(() => { result.current.relayoutBook() })
const input = buildBook.mock.calls[buildBook.mock.calls.length - 1][0]
const input = autoDesignedBook.mock.calls[autoDesignedBook.mock.calls.length - 1][1]
expect(input.path).toEqual([])
})
it("lays the whole book on the designed pages in the book's own page format, theme and title", async () => {
const { result } = await mountStudio()
const theme = shared.bookThemeOf('midnight')
const page = { ...shared.bookPageSetupSchema.parse({}), pageWidth: 200 }
act(() => {
useStudioStore.getState().commit(d => ({ ...d, title: 'My own title', page, theme }))
})
act(() => { result.current.relayoutBook() })
const [doc, input, opts] = autoDesignedBook.mock.calls[autoDesignedBook.mock.calls.length - 1]
expect(input.page).toEqual(page)
expect(opts?.theme).toEqual(theme)
expect(doc?.title).toBe('My own title')
const laid = useStudioStore.getState().doc
expect(laid?.title).toBe('My own title')
expect(laid?.theme).toEqual(theme)
expect(laid?.page.pageWidth).toBe(200)
expect(laid?.spreads.some(s => s.role === 'inner' && s.entryId === 1)).toBe(true)
})
it('lays this spread on another design, its id kept, and knows the cover but not the back', async () => {
const { result } = await mountStudio()
act(() => { result.current.relayoutBook() })
const before = useStudioStore.getState().doc
if (!before) throw new Error('a laid book')
const at = before.spreads.findIndex(s => s.role === 'inner' && s.entryId === 1)
act(() => { useStudioStore.getState().setActiveSpread(at) })
expect(result.current.canRelayoutSpread).toBe(true)
const was = shared.matchDecoratedTemplate(before.spreads[at], before.page)?.id
act(() => { result.current.relayoutCurrentSpread() })
const after = useStudioStore.getState().doc?.spreads[at]
expect(after?.id).toBe(before.spreads[at].id)
expect(shared.matchDecoratedTemplate(after as never, before.page)?.id).not.toBe(was)
act(() => { useStudioStore.getState().setActiveSpread(0) })
expect(result.current.canRelayoutSpread).toBe(true)
act(() => { useStudioStore.getState().setActiveSpread(before.spreads.length - 1) })
expect(result.current.canRelayoutSpread).toBe(false)
})
})
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router'
import type { AutoEntry, AutoInput, AutoPhoto, BookElement, BookPageNumbers, JourneyStats } from '@trek/shared'
import type { AutoDesignedInput, AutoEntry, AutoPhoto, BookElement, BookPageNumbers, JourneyStats } from '@trek/shared'
import {
autoEntriesWithStops, bookElementId as elementId, bookPageSetupSchema, bookPath, buildBook, distributeGallery,
emptyBook, layoutAroundLocks, relayoutSpread,
autoDesignedBook, autoEntriesWithStops, autoRelayoutKind, autoRelayoutSpread, bookElementId as elementId,
bookPageSetupSchema, bookPath, emptyBook,
} from '@trek/shared'
import { journeyApi } from '../../api/client'
import { useJourneyStore, type GalleryPhoto, type JourneyEntry, type JourneyPhoto } from '../../store/journeyStore'
@@ -153,7 +153,7 @@ export function useJourneyStudio() {
* possible; rebuilding it from the journey on every click would be the same
* work plus a chance of the two disagreeing.
*/
const autoInput = useRef<AutoInput | null>(null)
const autoInput = useRef<AutoDesignedInput | null>(null)
const journey = current && current.id === journeyId ? current : null
@@ -274,15 +274,15 @@ export function useJourneyStudio() {
})),
}))
// When each loose photo was taken: the auto layout gives it to the entry of its day first.
const gallery: AutoPhoto[] = (journey.gallery || []).map((p: GalleryPhoto) => ({
photoId: p.photo_id,
width: p.width ?? null,
height: p.height ?? null,
caption: p.caption ?? null,
takenAt: p.taken_at ?? null,
}))
const withPhotos = distributeGallery(entries, gallery)
/*
* Give every entry what the journey's figures know about its stop: the
* coordinates and country its place resolved to and which day it is, so a
@@ -290,7 +290,7 @@ export function useJourneyStudio() {
* is shared (autoEntriesWithStops) so the server's design tool reads the
* same stops.
*/
const placed = autoEntriesWithStops(withPhotos, stats)
const placed = autoEntriesWithStops(entries, stats)
const preset = PAGE_PRESETS['square-210']
@@ -300,6 +300,8 @@ export function useJourneyStudio() {
subtitle: journey.subtitle ?? null,
coverPhotoId: gallery[0]?.photoId ?? null,
entries: placed,
// The loose photos stay loose here: the layout shares them out itself, by day, and loses none.
gallery,
page: {
preset: preset.id,
pageWidth: preset.pageWidthMm,
@@ -375,7 +377,7 @@ export function useJourneyStudio() {
*
* The build effect runs as soon as the book and the figures are in, which is
* usually before this answers, and what it froze is what the Auto layout
* buttons hand to buildBook afterwards. Without the patch a relayout drew
* buttons hand to the layout afterwards. Without the patch a relayout drew
* the straight line between stops over a route somebody had walked.
*/
const applyPath = (p: [number, number][][]) => {
@@ -471,8 +473,10 @@ export function useJourneyStudio() {
const relayoutBook = useCallback(() => {
const input = autoInput.current
if (!input) return
// A new seed every press: "the whole book" twice gives two books, each one undo away.
const seed = Date.now() % 2 ** 31
commit(d => ({
...buildBook({ ...input, page: d.page }),
...autoDesignedBook(d, { ...input, page: d.page }, { theme: d.theme, seed }),
title: d.title,
}))
setActiveSpread(0)
@@ -481,22 +485,20 @@ export function useJourneyStudio() {
const relayoutCurrentSpread = useCallback(() => {
const input = autoInput.current
if (!input) return
const seed = Date.now() % 2 ** 31
commit(d => {
const sp = d.spreads[activeSpread]
if (!sp) return d
// Laid out around what is locked, as the assistant's auto layout leaves it.
const next = layoutAroundLocks(sp, free => relayoutSpread(free, { ...input, page: d.page }))
// Laid out around what is locked, its id kept, on another template than the one on screen.
const next = autoRelayoutSpread(d, activeSpread, { ...input, page: d.page }, { seed })
if (!next) return d
return { ...d, spreads: d.spreads.map((x, i) => (i === activeSpread ? next : x)) }
})
select([])
}, [commit, activeSpread, select])
/** Whether the spread on screen came from an entry, and so can be redone. */
/** Whether the auto layout knows the spread on screen (an entry's page, the cover, the summary, the countries). */
const canRelayoutSpread = !!(
autoInput.current
&& doc?.spreads[activeSpread]?.entryId != null
&& doc.spreads[activeSpread].role === 'inner'
&& autoRelayoutKind(doc?.spreads[activeSpread]) !== null
)
/** Page numbers and anything else that belongs to the book rather than a page. */
@@ -126,6 +126,5 @@ describe('aiConnectionsRepo', () => {
await expect(aiConnectionsRepo.disconnect()).rejects.toBeInstanceOf(OnlineOnlyError);
await expect(aiConnectionsRepo.chatgptStart()).rejects.toBeInstanceOf(OnlineOnlyError);
await expect(aiConnectionsRepo.adminSettings()).rejects.toBeInstanceOf(OnlineOnlyError);
await expect(aiConnectionsRepo.claudeConnector()).rejects.toBeInstanceOf(OnlineOnlyError);
});
});
+1 -9
View File
@@ -2,7 +2,6 @@ import {
aiAdminConnectionResponseSchema,
aiAdminSettingsSchema,
aiAuthStartResponseSchema,
aiClaudeConnectorResponseSchema,
aiConnectionViewSchema,
aiModelListResponseSchema,
aiStatusResponseSchema,
@@ -14,7 +13,7 @@ import {
type AiOpenrouterFinishRequest,
type AiPrivacyAckRequest,
} from '@trek/shared';
import { aiAdminApi, aiApi, claudeConnectorApi } from '../api/assistant';
import { aiAdminApi, aiApi } from '../api/assistant';
import { requireOnline } from './onlineOnly';
/**
@@ -114,11 +113,4 @@ export const aiConnectionsRepo = {
requireOnline();
return aiModelListResponseSchema.parse(await aiAdminApi.models()).models;
},
// ── Claude connector ───────────────────────────────────────────────────────
async claudeConnector() {
requireOnline();
return aiClaudeConnectorResponseSchema.parse(await claudeConnectorApi.get());
},
};
+2 -2
View File
@@ -22,11 +22,11 @@ describe('parseReleaseNotes', () => {
]);
});
it('FE-UTIL-RELNOTES-003: lines are trimmed and markers need their space', () => {
it('FE-UTIL-RELNOTES-003: lines are trimmed, markers need their space and #### heads a feature', () => {
expect(parseReleaseNotes(' ## Spaced \n-no space\n#### deep\n\n\n')).toEqual([
{ kind: 'h3', text: 'Spaced' },
{ kind: 'p', text: '-no space' },
{ kind: 'p', text: '#### deep' },
{ kind: 'h5', text: 'deep' },
]);
});
+7 -4
View File
@@ -1,5 +1,5 @@
/** One block of a release body: a heading, a paragraph, or a run of list items. */
export type ReleaseNoteBlock = { kind: 'h3' | 'h4' | 'p'; text: string } | { kind: 'ul'; items: string[] };
export type ReleaseNoteBlock = { kind: 'h3' | 'h4' | 'h5' | 'p'; text: string } | { kind: 'ul'; items: string[] };
/** Escapes the characters that would let release text break out of the HTML it is placed in. */
export function escapeReleaseHtml(str: string): string {
@@ -7,8 +7,8 @@ export function escapeReleaseHtml(str: string): string {
}
/**
* Splits a GitHub release body into the blocks both admin shells render: `### ` and `## `
* headings, `- ` or `* ` list items gathered into one list until anything else breaks the
* Splits a GitHub release body into the blocks both admin shells render: `## `, `### ` and
* `#### ` headings (the version, a section such as Added, a feature inside it), `- ` or `* ` list items gathered into one list until anything else breaks the
* run, and every other non-empty line as a paragraph. Blank lines only end a list. The
* text stays raw; each shell formats it inline itself.
*/
@@ -30,7 +30,10 @@ export function parseReleaseNotes(body: string): ReleaseNoteBlock[] {
continue;
}
if (trimmed.startsWith('### ')) {
if (trimmed.startsWith('#### ')) {
flushList();
blocks.push({ kind: 'h5', text: trimmed.slice(5) });
} else if (trimmed.startsWith('### ')) {
flushList();
blocks.push({ kind: 'h4', text: trimmed.slice(4) });
} else if (trimmed.startsWith('## ')) {
@@ -179,12 +179,12 @@ describe('applying a theme', () => {
});
describe('the Theme panel', () => {
it('FE-STUDIO-THEME-007: shows twelve themes, applies the one picked and says so', () => {
it('FE-STUDIO-THEME-007: shows seventeen themes, applies the one picked and says so', () => {
render(<StudioThemePanel t={t} />);
const cards = screen
.getAllByRole('button', { pressed: false })
.filter((b) => b.classList.contains('st-theme-card'));
expect(cards).toHaveLength(11);
expect(cards).toHaveLength(16);
fireEvent.click(screen.getByTitle('studio.theme.descr.riviera'));
fireEvent.click(screen.getByText('studio.theme.applyBook'));
expect(doc().theme?.id).toBe('riviera');
+1 -1
View File
@@ -17,5 +17,5 @@
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
"src/nest/reservations/reservations.service.ts": 1237,
"src/nest/trips/trips.service.ts": 1006,
"src/nest/vacay/vacay.service.ts": 1416
"src/nest/vacay/vacay.service.ts": 1409
}
@@ -46,6 +46,11 @@ Recipes, for a cover built or tweaked by hand, as JSON: each object one element
- 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]}]
- riso: [{"kind":"shape","shape":"ellipse","token":"accent2","opacity":0.85,"frame":[8,46,52,52]},{"kind":"shape","shape":"ellipse","paint":{"type":"pattern","pattern":"dots","fg":"accent","bg":null,"scale":0.4,"angle":45},"frame":[11,44,52,52]},{"kind":"shape","shape":"rect","token":"accent","frame":[37,50,52,42],"rotation":2},{"kind":"photo","photoId":"cover","frame":[39,48,52,42],"rotation":2},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":90,"transform":"upper","leading":0.92,"align":"left","shadow":{"dx":1.6,"dy":1.6,"color":"accent","alpha":1},"frame":[8,8,84,30]},{"kind":"text","text":"2026","size":9,"highlight":{"color":"accent2"},"frame":[8,88,20,4]}], the block and the discs a few mm out of register.
- editorial: [{"kind":"photo","photoId":"cover","frame":[-2,37,104,65]},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":96,"weight":800,"transform":"upper","leading":0.9,"frame":[8,8,84,19]},{"kind":"shape","shape":"rect","token":"ink","frame":[8,27.5,84,0.2]},{"kind":"text","text":"May 2026","size":8,"align":"left","frame":[8,29,40,3]},{"kind":"shape","shape":"rect","token":"accent","frame":[8,74,44,18]},{"kind":"text","binding":{"source":"journey.subtitle"},"fontRole":"heading","token":"paper","size":16,"align":"left","frame":[12,78,36,10]}], the block off the photo's focal point.
- vintage-poster: [{"kind":"shape","shape":"rect","fill":null,"strokeToken":"ink","strokeWidth":0.6,"frame":[1.7,1.7,96.6,96.6]},{"kind":"shape","shape":"rect","token":"accent3","frame":[3.3,3.3,93.4,58]},{"kind":"shape","shape":"sunburst-24","token":"accent","opacity":0.5,"frame":[24,6,52,52]},{"kind":"shape","shape":"ellipse","token":"accent","frame":[34,16,32,32]},{"kind":"shape","shape":"mountain","token":"accent2","frame":[3.3,32,64,29.3]},{"kind":"shape","shape":"mountain","token":"ink","opacity":0.8,"frame":[36,40,60.7,21.3]},{"kind":"photo","photoId":"cover","frame":[38,14,24,44],"mask":"arch-tall","cutout":{"width":2,"color":"#ffffff"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":80,"transform":"upper","leading":0.92,"frame":[8,66,84,20]},{"kind":"text","text":"May 2026","size":9,"frame":[8,88,84,4]}]
- night-film: [{"background":"ink"},{"kind":"text","text":"May 2026","font":"mono","token":"accent","size":8,"tracking":0.2,"align":"left","frame":[8,8,40,3]},{"kind":"photo","photoId":"cover","frame":[-2,14,104,40]},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"paper","size":72,"transform":"upper","tracking":0.14,"align":"left","frame":[8,58,84,16]},{"kind":"shape","shape":"rect","token":"accent","frame":[8,76,16,0.4]},{"kind":"shape","shape":"rect","token":"paper","opacity":0.12,"frame":[-2,80,104,18]},{"kind":"line","stroke":"#f2efe8","strokeWidth":1.6,"dash":"dashed","frame":[-2,81.5,104,1]},{"kind":"line","stroke":"#f2efe8","strokeWidth":1.6,"dash":"dashed","frame":[-2,95.5,104,1]},{"kind":"photo","photoId":"2","frame":[8,84,26,10]},{"kind":"photo","photoId":"3","frame":[37,84,26,10]},{"kind":"photo","photoId":"4","frame":[66,84,26,10]}], paper and ink swapped in a dark theme.
- field-notes: [{"kind":"shape","shape":"rect","paint":{"type":"pattern","pattern":"grid","fg":"accent3","bg":"accent2","scale":0.6,"angle":0},"frame":[4,4,92,92],"rotation":-1.5},{"kind":"shape","shape":"leaf-long","token":"accent3","frame":[2,40,9,40],"rotation":-20},{"kind":"photo","photoId":"cover","frame":[10,10,48,40],"rotation":-3,"border":{"width":2.6,"color":"#ffffff"}},{"kind":"shape","shape":"rect","token":"accent2","opacity":0.72,"edge":"zigzag","edgeSize":1,"frame":[24,8,19,3.3],"rotation":4},{"kind":"photo","photoId":"2","frame":[62,17,28,27],"rotation":4,"border":{"width":2.6,"color":"#ffffff"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":60,"align":"left","frame":[8,62,46,18]},{"kind":"text","binding":{"source":"journey.subtitle"},"fontRole":"script","token":"accent","size":26,"align":"left","frame":[8,80,46,8]},{"kind":"text","text":"May 2026","font":"mono","size":8,"align":"left","frame":[8,89,46,3]}]
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.
@@ -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) 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.`,
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. With layouts: 'templates' every page is laid on the Studio's designed templates in the archetype of the book's theme, as the Studio's Auto layout does, and each plan row names its template; style classic alone is the old built-in Auto 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,
@@ -1,26 +0,0 @@
import { ADDON_IDS } from '../../addons';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ResponseContract } from '../common/response-contract';
import { ClaudeConnectorService } from './claude-connector.service';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { aiClaudeConnectorResponseSchema, type AiClaudeConnectorResponse } from '@trek/shared';
/**
* /api/integrations/claude-connector — what the settings card needs to add
* TREK to claude.ai as a custom connector. It follows the MCP addon (the
* connector is the MCP endpoint), not the in-app assistant's addon.
*/
@Controller('api/integrations/claude-connector')
@UseGuards(AddonGuard, JwtAuthGuard)
@RequireAddon(ADDON_IDS.MCP, 'MCP')
export class ClaudeConnectorController {
constructor(private readonly connector: ClaudeConnectorService) {}
@Get()
@ResponseContract(aiClaudeConnectorResponseSchema)
async get(): Promise<AiClaudeConnectorResponse> {
return await this.connector.describe();
}
}
@@ -1,31 +0,0 @@
import { OauthService } from './oauth.service';
import { Injectable } from '@nestjs/common';
import type { AiClaudeConnectorResponse } from '@trek/shared';
import QRCode from 'qrcode';
/** claude.ai's "add custom connector" dialog, prefilled through its query string. */
const CLAUDE_ADD_CONNECTOR_URL = 'https://claude.ai/customize/connectors';
/**
* "Connect with Claude": the link that opens claude.ai's custom-connector
* dialog with this instance's MCP endpoint filled in, and the same link as a QR
* code for the phone. claude.ai then runs the ordinary OAuth flow against
* /oauth/authorize; nothing here touches the user's data or calls Anthropic.
*/
@Injectable()
export class ClaudeConnectorService {
constructor(private readonly oauth: OauthService) {}
async describe(): Promise<AiClaudeConnectorResponse> {
const mcpUrl = `${this.oauth.mcpSafeUrl().replace(/(?<!\/)\/+$/, '')}/mcp`;
const params = new URLSearchParams({
modal: 'add-custom-connector',
connectorName: 'TREK',
connectorUrl: mcpUrl,
});
const deepLink = `${CLAUDE_ADD_CONNECTOR_URL}?${params.toString()}`;
const qrSvg = await QRCode.toString(deepLink, { type: 'svg', margin: 1 });
return { mcpUrl, deepLink, qrSvg };
}
}
+2 -6
View File
@@ -8,8 +8,6 @@ import { createMcpAddonGate } from '../addons/mcp-addon-gate';
import { AuditModule } from '../audit/audit.module';
import { RateLimitModule } from '../common/rate-limit.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { ClaudeConnectorController } from './claude-connector.controller';
import { ClaudeConnectorService } from './claude-connector.service';
import { OauthApiController } from './oauth-api.controller';
import { CimdClientResolver } from './oauth-cimd';
import { OauthPublicController } from './oauth-public.controller';
@@ -49,8 +47,7 @@ import type { MiddlewareConsumer, NestModule } from '@nestjs/common';
* swapped provider too.
*
* Client ID Metadata Documents (oauth-cimd.ts) resolve through the injected
* CimdClientResolver; ClaudeConnectorController hands the settings page the
* claude.ai link to this instance's MCP endpoint.
* CimdClientResolver.
*
* Exports OauthService for AdminController (admin OAuth-session panel) and the
* MCP transport's token verification.
@@ -73,11 +70,10 @@ import type { MiddlewareConsumer, NestModule } from '@nestjs/common';
SchedulingModule,
MikroOrmModule.forFeature([OauthClients, OauthTokens, OauthConsents, Users]),
],
controllers: [OauthPublicController, OauthApiController, ClaudeConnectorController],
controllers: [OauthPublicController, OauthApiController],
providers: [
OauthService,
CimdClientResolver,
ClaudeConnectorService,
TrekClientsStore,
TrekOAuthProvider,
OauthTokenRetentionJob,
+32
View File
@@ -69,3 +69,35 @@ export function shiftIsoDate(isoDate: string, days: number): string {
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
export const CACHE_TTL = 24 * 60 * 60 * 1000;
export const FETCH_TIMEOUT_MS = 10_000;
// A country's holidays for one year are a few kilobytes; two megabytes means the
// provider is misbehaving and we would rather report the usual error than buffer it.
export const MAX_HOLIDAY_BYTES = 2 * 1024 * 1024;
// Both providers key on a 4-digit year and an ISO 3166-1 alpha-2 code. Anything
// else has no business reaching the URL path.
export const YEAR_RE = /^\d{4}$/;
export const COUNTRY_RE = /^[A-Za-z]{2}$/;
// ---------------------------------------------------------------------------
// Color palette for auto-assign
// ---------------------------------------------------------------------------
export const COLORS = [
'#6366f1',
'#ec4899',
'#14b8a6',
'#8b5cf6',
'#ef4444',
'#3b82f6',
'#22c55e',
'#06b6d4',
'#f43f5e',
'#a855f7',
'#10b981',
'#0ea5e9',
'#64748b',
'#be185d',
'#0d9488',
];
+55 -63
View File
@@ -30,7 +30,7 @@ import { SchoolHolidayRegions } from '../../db/entities/SchoolHolidayRegions.ent
import type { SchoolHolidayRegionsRepository } from '../../db/repositories/SchoolHolidayRegions.repository';
import {
daysAlwaysInMonth, isBlockedWeekend, normalizeFraction, normalizeKind, normalizeYearType, pad2, parseAsUtcMillis,
shiftIsoDate, windowEndYear,
shiftIsoDate, windowEndYear, CACHE_TTL, COLORS, COUNTRY_RE, FETCH_TIMEOUT_MS, MAX_HOLIDAY_BYTES, YEAR_RE,
} from './vacay.helpers';
// ---------------------------------------------------------------------------
@@ -125,26 +125,6 @@ export interface VacayShare {
created_at?: string;
}
const CACHE_TTL = 24 * 60 * 60 * 1000;
const FETCH_TIMEOUT_MS = 10_000;
// A country's holidays for one year are a few kilobytes; two megabytes means the
// provider is misbehaving and we would rather report the usual error than buffer it.
const MAX_HOLIDAY_BYTES = 2 * 1024 * 1024;
// Both providers key on a 4-digit year and an ISO 3166-1 alpha-2 code. Anything
// else has no business reaching the URL path.
const YEAR_RE = /^\d{4}$/;
const COUNTRY_RE = /^[A-Za-z]{2}$/;
// ---------------------------------------------------------------------------
// Color palette for auto-assign
// ---------------------------------------------------------------------------
const COLORS = [
'#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444',
'#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7',
'#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488',
];
/**
* Vacay domain service — owns the vacay SQL, now through the ten vacay
* repositories (+ `SchoolHolidayRegionsRepository`'s cross-domain read,
@@ -1020,37 +1000,38 @@ export class VacayService {
}
async addYear(planId: number, year: number, socketId: string | undefined): Promise<number[]> {
// A duplicate year is a no-op (the legacy blanket try/catch was written for
// exactly this constraint hit); real errors now propagate instead of being
// swallowed. The insert + per-user seeding runs atomically.
const exists = await this.years.exists(planId, year);
if (!exists) {
await this.uow.transactional(async () => {
await this.years.insertYear(planId, year);
const plan = await this.plans.findById(planId);
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
const users = await this.getPlanUsers(planId);
for (const u of users) {
let carriedOver = 0;
if (carryOverEnabled) {
const prevConfig = await this.userYears.findForYear(u.id, planId, year - 1);
if (prevConfig) {
const used = await this.usedDays(u.id, planId, year - 1);
// L1 (task-7-review.md): `?? 0`, not `?? 30` — reproduces
// legacy's raw `prevConfig.vacation_days + prevConfig.carried_over`,
// where a NULL vacation_days coerced to 0 in the `+`, never to 30.
const total = (prevConfig.vacation_days ?? 0) + (prevConfig.carried_over ?? 0);
carriedOver = Math.max(0, total - used);
}
}
await this.userYears.insertIgnore(u.id, planId, year, 30, carriedOver);
}
});
}
// A duplicate year is a no-op (the legacy blanket try/catch was written for exactly this
// constraint hit); real errors propagate. The insert + per-user seeding runs atomically.
if (!(await this.years.exists(planId, year))) await this.openYear(planId, year);
await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
return await this.listYears(planId);
}
/** The year's row and every member's allowance for it, with no broadcast: the caller sends it after its commit. */
private async openYear(planId: number, year: number): Promise<void> {
await this.uow.transactional(async () => {
await this.years.insertYear(planId, year);
const plan = await this.plans.findById(planId);
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
const users = await this.getPlanUsers(planId);
for (const u of users) {
let carriedOver = 0;
if (carryOverEnabled) {
const prevConfig = await this.userYears.findForYear(u.id, planId, year - 1);
if (prevConfig) {
const used = await this.usedDays(u.id, planId, year - 1);
// L1 (task-7-review.md): `?? 0`, not `?? 30` — reproduces
// legacy's raw `prevConfig.vacation_days + prevConfig.carried_over`,
// where a NULL vacation_days coerced to 0 in the `+`, never to 30.
const total = (prevConfig.vacation_days ?? 0) + (prevConfig.carried_over ?? 0);
carriedOver = Math.max(0, total - used);
}
}
await this.userYears.insertIgnore(u.id, planId, year, 30, carriedOver);
}
});
}
async deleteYear(planId: number, year: number, socketId: string | undefined): Promise<number[]> {
// Year removal deletes across four tables and recomputes the next year's
// carry-over — atomic, so a failure can't leave entries without their year.
@@ -1139,25 +1120,32 @@ export class VacayService {
const knd = normalizeKind(kind);
const weekendBlocked = plan ? isBlockedWeekend(plan, date) : false;
const existing = await this.entries.findByUserDatePlan(userId, date, planId);
if (existing) {
// Clicking the exact same day again (same type AND same fraction) clears it;
// clicking a different type or fraction converts it in place (#552/#1074).
if (existing.fraction === frac && (existing.kind || 'vacation') === knd) {
await this.entries.deleteById(existing.id);
await this.notifyPlanUsers(planId, socketId);
return { action: 'removed' };
}
// Removing a stray entry on a blocked day stays possible; keeping one
// there (converted in place) does not.
if (weekendBlocked) return { error: 'weekend_blocked' };
await this.entries.updateFractionKind(existing.id, frac, knd);
// Clicking the exact same day again (same type AND same fraction) clears it;
// clicking a different type or fraction converts it in place (#552/#1074).
if (existing && existing.fraction === frac && (existing.kind || 'vacation') === knd) {
await this.entries.deleteById(existing.id);
await this.notifyPlanUsers(planId, socketId);
return { action: 'updated', fraction: frac, kind: knd };
return { action: 'removed' };
}
// Removing a stray entry on a blocked day stays possible; keeping one there does not.
if (weekendBlocked) return { error: 'weekend_blocked' };
await this.entries.insertEntry(planId, userId, date, '', frac, knd);
const opened = await this.uow.transactional(async () => {
if (existing) await this.entries.updateFractionKind(existing.id, frac, knd);
else await this.entries.insertEntry(planId, userId, date, '', frac, knd);
return this.ensureYearOf(planId, userId, date);
});
if (opened) await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
await this.notifyPlanUsers(planId, socketId);
return { action: 'added', fraction: frac, kind: knd };
return { action: existing ? 'updated' : 'added', fraction: frac, kind: knd };
}
/** A day written for a year the plan has not opened adds that year, or the calendar never shows it (#737 picks the period). */
private async ensureYearOf(planId: number, userId: number, date: string): Promise<boolean> {
const [y, m, d] = date.split('-').map(Number);
const year = await this.currentPeriodYear(userId, new Date(y, m - 1, d));
if (await this.years.exists(planId, year)) return false;
await this.openYear(planId, year);
return true;
}
/**
@@ -1167,6 +1155,7 @@ export class VacayService {
*/
async toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined, fraction?: unknown): Promise<{ action: string; fraction?: number }> {
const frac = normalizeFraction(fraction);
let opened = false;
const result = await this.uow.transactional(async () => {
const existing = await this.companyHolidays.findByPlanAndDate(planId, date); // VC118
if (existing && existing.fraction === frac) {
@@ -1179,8 +1168,11 @@ export class VacayService {
await this.companyHolidays.insertHoliday(planId, date, note || '', frac); // VC120
}
await this.makeRoomForCompanyHoliday(planId, date, frac);
const owner = await this.plans.findOwnerId(planId);
if (owner) opened = await this.ensureYearOf(planId, owner.owner_id, date);
return { action: existing ? 'updated' : 'added', fraction: frac };
});
if (opened) await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
await this.notifyPlanUsers(planId, socketId);
return result;
}
@@ -29,6 +29,7 @@ import {
bookDesignAnswerSchema,
bookDesignApplyResultSchema,
bookDesignDraftResponseSchema,
bookDocumentSchema,
bookWriteResultSchema,
} from '@trek/shared';
@@ -197,4 +198,21 @@ describe('the whole-book design over MCP and REST', () => {
expect(names.filter((name) => DESIGN_TOOLS.includes(name))).toEqual([]);
expect(names.length).toBeGreaterThan(0);
});
it('JBDSNE2E-004: a design on the designed templates applies as a valid book GET reads back', async () => {
const { editor, journey } = await journeyWithBook();
const directives = { style: 'field-notes', layouts: 'templates', seed: 2 };
const draft = bookDesignAnswerSchema.parse(
await callTool(editor.id, 'design_book', { journeyId: journey.id, directives }),
);
expect(draft.plan.filter((row) => row.template).length).toBeGreaterThan(0);
const applied = bookDesignApplyResultSchema.parse(
await callTool(editor.id, 'apply_book_design', { journeyId: journey.id, designId: draft.design_id }),
);
const after = await book(journey.id, editor.id);
expect(after.body.book.version).toBe(applied.version);
expect(bookDocumentSchema.safeParse(after.body.book.document).success).toBe(true);
// Applied over a stored book, the design brings its pages; the book keeps its own theme.
expect(after.body.book.document.spreads.length).toBeGreaterThan(1);
});
});
-30
View File
@@ -160,34 +160,4 @@ describe('OAuth e2e (real guards + temp SQLite)', () => {
expect(cookie.status).toBe(201);
expect(cookie.body).toEqual({ client_id: 'c1', client_secret: 's' });
});
describe('GET /api/integrations/claude-connector', () => {
const MCP_URL = 'https://mcp.test/mcp';
it('404 { error } while the MCP addon is off, even without a session', async () => {
isAddonEnabled.mockReturnValue(false);
const res = await request(server).get('/api/integrations/claude-connector');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'MCP addon is not enabled' });
});
it('401 without a session', async () => {
const res = await request(server).get('/api/integrations/claude-connector');
expect(res.status).toBe(401);
});
it('200 with the MCP endpoint, the claude.ai link that adds it and that link as a QR code', async () => {
const res = await request(server).get('/api/integrations/claude-connector').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body.mcpUrl).toBe(MCP_URL);
const link = new URL(res.body.deepLink);
expect(link.origin + link.pathname).toBe('https://claude.ai/customize/connectors');
expect(Object.fromEntries(link.searchParams)).toEqual({
modal: 'add-custom-connector',
connectorName: 'TREK',
connectorUrl: MCP_URL,
});
expect(res.body.qrSvg).toMatch(/^<svg[\s\S]*<\/svg>\s*$/);
});
});
});
@@ -602,14 +602,22 @@ const vacayCases: ParityCase[] = [
needs: ['vacay'],
args: () => ({ date: '2027-06-01' }),
rest: { method: 'post', path: () => `${VACAY}/entries/toggle`, body: () => ({ date: '2027-06-01' }) },
expect: [{ event: 'vacay:update', user: 'actor' }],
// 2027 is not a year of the plan yet, so the day opens it too.
expect: [
{ event: 'vacay:settings', user: 'actor' },
{ event: 'vacay:update', user: 'actor' },
],
},
{
tool: 'toggle_company_holiday',
needs: ['vacay'],
args: () => ({ date: '2027-06-02' }),
rest: { method: 'post', path: () => `${VACAY}/entries/company-holiday`, body: () => ({ date: '2027-06-02' }) },
expect: [{ event: 'vacay:update', user: 'actor' }],
// 2027 is not a year of the plan yet, so the day opens it too.
expect: [
{ event: 'vacay:settings', user: 'actor' },
{ event: 'vacay:update', user: 'actor' },
],
},
{
tool: 'update_vacay_stats',
@@ -130,7 +130,7 @@ describe('the art director in the Studio', () => {
const paint = i.paint as { pattern?: string } | undefined;
return paint?.pattern ? [paint.pattern] : [];
});
expect(patterns).toEqual(['checker']);
expect(patterns).toEqual(['checker', 'dots', 'grid']);
for (const id of patterns) expect(BOOK_PATTERNS as readonly string[]).toContain(id);
});
@@ -154,7 +154,7 @@ describe('the art director in the Studio', () => {
});
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.length).toBeLessThan(14500);
expect(BOOK_RECIPES_PROMPT.split('\n').length).toBeLessThanOrEqual(24);
expect(BOOK_RECIPES_PROMPT).not.toMatch(/[\u2013\u2014]/);
expect(BOOK_RECIPES_PROMPT).not.toContain(' - ');
@@ -1,67 +0,0 @@
import { ADDON_IDS } from '../../../src/addons';
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
import { RESPONSE_CONTRACT_KEY } from '../../../src/nest/common/response-contract';
import { ClaudeConnectorController } from '../../../src/nest/oauth/claude-connector.controller';
import { ClaudeConnectorService } from '../../../src/nest/oauth/claude-connector.service';
import { OauthService } from '../../../src/nest/oauth/oauth.service';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { Test } from '@nestjs/testing';
import { aiClaudeConnectorResponseSchema } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
async function service(mcpSafeUrl: string): Promise<ClaudeConnectorService> {
const moduleRef = await Test.createTestingModule({
providers: [ClaudeConnectorService, { provide: OauthService, useValue: { mcpSafeUrl: () => mcpSafeUrl } }],
}).compile();
return moduleRef.get(ClaudeConnectorService);
}
describe('ClaudeConnectorService', () => {
it('CLAUDE-001: links claude.ai to the MCP endpoint of this instance and encodes it once', async () => {
const res = await (await service('https://trek.example.test')).describe();
expect(res.mcpUrl).toBe('https://trek.example.test/mcp');
expect(res.deepLink).toBe(
'https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=TREK' +
'&connectorUrl=https%3A%2F%2Ftrek.example.test%2Fmcp',
);
expect(aiClaudeConnectorResponseSchema.parse(res)).toEqual(res);
});
it('CLAUDE-002: trailing slashes on the configured URL do not double the path', async () => {
expect((await (await service('https://trek.example.test/sub///')).describe()).mcpUrl).toBe(
'https://trek.example.test/sub/mcp',
);
});
it('CLAUDE-003: the QR code comes as an SVG document', async () => {
const { qrSvg } = await (await service('https://trek.example.test')).describe();
expect(qrSvg.startsWith('<svg')).toBe(true);
expect(qrSvg.trimEnd().endsWith('</svg>')).toBe(true);
});
});
describe('ClaudeConnectorController', () => {
it('CLAUDE-004: answers what the service describes', async () => {
const answer = { mcpUrl: 'https://t.example/mcp', deepLink: 'https://claude.ai/x', qrSvg: '<svg/>' };
const describeConnector = vi.fn().mockResolvedValue(answer);
const controller = new ClaudeConnectorController({
describe: describeConnector,
} as unknown as ClaudeConnectorService);
expect(await controller.get()).toBe(answer);
expect(describeConnector).toHaveBeenCalledOnce();
});
it('CLAUDE-005: follows the MCP addon, checks it before the session, and declares its contract', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, ClaudeConnectorController)).toEqual([AddonGuard, JwtAuthGuard]);
expect(Reflect.getMetadata(REQUIRE_ADDON, ClaudeConnectorController)).toEqual({
addonId: ADDON_IDS.MCP,
label: 'MCP',
});
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, ClaudeConnectorController.prototype.get)).toBe(
aiClaudeConnectorResponseSchema,
);
});
});
@@ -74,9 +74,9 @@ describe('decorated templates in list_book_layouts', () => {
const all = json(await mcp.listLayouts({ journeyId: journey.id }, ctx(viewer.id)));
const single = all.decorated.single as Row[];
const inner = all.decorated.inner as Row[];
expect(single).toHaveLength(21);
expect(inner).toHaveLength(16);
expect(new Set(single.map((r) => r.archetype)).size).toBe(7);
expect(single).toHaveLength(48);
expect(inner).toHaveLength(60);
expect(new Set(single.map((r) => r.archetype)).size).toBe(12);
expect(single.find((r) => r.id === 'grid-badge-circle')).toMatchObject({
archetype: 'grid-badge',
photoSlots: 4,
@@ -86,7 +86,7 @@ describe('decorated templates in list_book_layouts', () => {
expect(all.notes.decorated).toMatch(/whole designed page/);
const narrowed = json(await mcp.listLayouts({ journeyId: journey.id, role: 'single' }, ctx(owner.id)));
expect([narrowed.decorated.single.length, narrowed.decorated.inner]).toEqual([21, []]);
expect([narrowed.decorated.single.length, narrowed.decorated.inner]).toEqual([48, []]);
});
});
@@ -35,6 +35,7 @@ import {
bookDesignRevisionAnswerSchema,
bookPageDescriptionSchema,
bookWriteResultSchema,
designBookRequestSchema,
} from '@trek/shared';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -236,4 +237,14 @@ describe('the design tools', () => {
await expect(books.restoreBookLayout(putBack, ctx(viewer.id))).rejects.toThrow('Not allowed');
expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled();
});
it('JBDMCP-006: design_book takes layouts templates and answers the template of each page', async () => {
const { journey, editor } = await crew();
const request = designBookRequestSchema.parse({ directives: { style: 'postcard', layouts: 'templates', seed: 1 } });
const designed = await designs.designBook({ journeyId: journey.id, ...request }, ctx(editor.id));
const draft = bookDesignAnswerSchema.parse(answered(designed));
expect(draft.plan.some((row) => row.template !== undefined)).toBe(true);
expect(draft.style.style).toBe('postcard');
expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled();
});
});
@@ -418,3 +418,26 @@ describe('the art direction', () => {
expect(free.findings.map((f) => f.code)).not.toContain('energy_mismatch');
});
});
describe('the designed templates', () => {
it("JBDSN-014: layouts templates lays the draft on the designed templates and names each page's template", async () => {
const { journey, owner, created } = await crew();
const answer = await designs.design(journey.id, owner.id, { style: 'riso', layouts: 'templates', seed: 3 });
expect(bookDesignAnswerSchema.parse(answer)).toEqual(answer);
const named = answer.plan.filter((row) => row.template);
expect(named.length).toBeGreaterThan(1);
expect(answer.plan[0]?.beat).toBe('cover');
const { document } = await designs.draftView(journey.id, owner.id, answer.design_id);
expect(document.theme?.id).toBe('riso');
expect(answer.findings.filter((f) => f.severity === 'severe')).toEqual([]);
expect(await version(journey.id, owner.id)).toBe(created?.version);
expect(told()).toEqual([]);
});
it('JBDSN-015: without layouts the plan names no template, as every design answered before', async () => {
const { journey, owner } = await crew();
const answer = await designs.design(journey.id, owner.id, { style: 'riso', seed: 3 });
expect(answer.plan.every((row) => !('template' in row))).toBe(true);
});
});
+1 -4
View File
@@ -1614,21 +1614,18 @@ describe('OauthModule', () => {
const { OauthApiController } = await import('../../../src/nest/oauth/oauth-api.controller');
const { OauthService: Svc } = await import('../../../src/nest/oauth/oauth.service');
const { ClaudeConnectorController } = await import('../../../src/nest/oauth/claude-connector.controller');
const { ClaudeConnectorService } = await import('../../../src/nest/oauth/claude-connector.service');
const { CimdClientResolver } = await import('../../../src/nest/oauth/oauth-cimd');
const { TrekClientsStore, TrekOAuthProvider } = await import('../../../src/nest/oauth/oauth-sdk.provider');
const { OauthTokenRetentionJob } = await import('../../../src/nest/oauth/oauth-token-retention.job');
const controllers = Reflect.getMetadata('controllers', OauthModule);
const providers = Reflect.getMetadata('providers', OauthModule);
expect(controllers).toEqual([OauthPublicController, OauthApiController, ClaudeConnectorController]);
expect(controllers).toEqual([OauthPublicController, OauthApiController]);
// RateLimitService is deliberately absent: it comes from the global
// RateLimitModule so all consumers share one set of counters.
expect(providers).toEqual([
Svc,
CimdClientResolver,
ClaudeConnectorService,
TrekClientsStore,
TrekOAuthProvider,
OauthTokenRetentionJob,
@@ -1875,3 +1875,38 @@ describe('quirk fixes', () => {
expect(await storedFields(VacayYears, { plan: plan.id, year: year + 1 }, ['id'])).toBeUndefined();
});
});
describe('a day written for a year the plan has not opened', () => {
it('VACAY-SVC-079: adds that year with the member allowance, so the day shows in the calendar', async () => {
const { user, plan } = await setupUserWithPlan();
await allowWeekends(plan.id);
expect(await svc.listYears(plan.id)).not.toContain(2031);
const result = await svc.toggleEntry(user.id, plan.id, '2031-01-06', 1, 'vacation');
expect(result.action).toBe('added');
expect(await svc.listYears(plan.id)).toContain(2031);
expect(await storedRow(VacayUserYears, { user: user.id, plan: plan.id, year: 2031 })).toBeDefined();
});
it('VACAY-SVC-080: taking the day away again keeps the year it opened', async () => {
const { user, plan } = await setupUserWithPlan();
await allowWeekends(plan.id);
await svc.toggleEntry(user.id, plan.id, '2032-03-02', 1, 'vacation');
await svc.deleteYear(plan.id, 2032, undefined);
const before = await svc.listYears(plan.id);
await svc.toggleEntry(user.id, plan.id, '2032-03-02', 1, 'vacation');
await svc.toggleEntry(user.id, plan.id, '2032-03-02', 1, 'vacation');
expect(await svc.listYears(plan.id)).toEqual([...before, 2032].sort((a, b) => a - b));
});
it('VACAY-SVC-081: a company holiday in a year not opened yet adds that year too', async () => {
const { plan } = await setupUserWithPlan();
await svc.toggleCompanyHoliday(plan.id, '2033-12-25', 'Christmas', undefined);
expect(await svc.listYears(plan.id)).toContain(2033);
});
});
-10
View File
@@ -224,13 +224,3 @@ export const aiAdminSettingsSchema = z
})
.strict();
export type AiAdminSettings = z.infer<typeof aiAdminSettingsSchema>;
// ── Claude connector: /api/integrations/claude-connector ───────────────────
/** GET /api/integrations/claude-connector: adding TREK's MCP server in Claude, as a link and a QR code. */
export const aiClaudeConnectorResponseSchema = z.object({
mcpUrl: z.url(),
deepLink: z.url(),
qrSvg: z.string(),
});
export type AiClaudeConnectorResponse = z.infer<typeof aiClaudeConnectorResponseSchema>;
+177
View File
@@ -0,0 +1,177 @@
import { bookSpreadSchema, type BookDocument, type BookSpread } from '../book.schema';
import { matchDecoratedTemplate } from '../templates/template-match';
import { bookThemeOf } from '../theme/book-theme';
import { emptyBook } from './auto-book';
import {
AUTO_DEFAULT_THEME,
autoDesignedBook,
autoRelayoutKind,
autoRelayoutSpread,
type AutoDesignedInput,
} from './auto-designed';
import type { AutoPhoto } from './auto-types';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
/** The Studio's Auto layout on the designed templates: the whole book, and one spread again. */
interface Fixture {
input: AutoDesignedInput;
gallery?: AutoPhoto[];
}
const here = (file: string) => fileURLToPath(new URL(file, import.meta.url));
const fixtures = JSON.parse(readFileSync(here('./auto-layout.parity-inputs.json'), 'utf8')) as Record<string, Fixture>;
const fx = fixtures.A as Fixture;
const counter = () => {
let n = 0;
return (p: string) => `${p}-a${++n}`;
};
const input = (): AutoDesignedInput => ({ ...fx.input, gallery: fx.gallery, newId: counter() });
const book = (seed = 1, theme = bookThemeOf('riso')) => autoDesignedBook(emptyBook(input()), input(), { theme, seed });
/** The index of the first page of a kind, or -1. */
const indexOf = (doc: BookDocument, kind: ReturnType<typeof autoRelayoutKind>) =>
doc.spreads.findIndex((s) => autoRelayoutKind(s) === kind);
describe('the Studio auto layout on the designed templates', () => {
it("AUTODES-001: keeps the book's page format and its theme object, one of its own included", () => {
const own = {
...bookThemeOf('postcard'),
id: null,
palette: { ...bookThemeOf('postcard').palette, ink: '#203040' },
};
const start = { ...emptyBook(input()), page: { ...fx.input.page, pageWidth: 200, pageHeight: 250 } };
const doc = autoDesignedBook(start, input(), { theme: own, seed: 3 });
expect(doc.page.pageWidth).toBe(200);
expect(doc.page.pageHeight).toBe(250);
expect(doc.theme).toEqual(own);
expect(autoDesignedBook(null, input()).theme?.id).toBe(AUTO_DEFAULT_THEME);
});
it('AUTODES-002: another seed lays another book, the same seed the same', () => {
const ids = (doc: BookDocument) => doc.spreads.map((s) => matchDecoratedTemplate(s, doc.page)?.id ?? '-').join();
expect(ids(book(1))).toBe(ids(book(1)));
expect([2, 3, 4, 5].some((seed) => ids(book(seed)) !== ids(book(1)))).toBe(true);
});
it("AUTODES-003: this spread keeps the page's id and lays an entry's page on another template than its own and its neighbours'", () => {
const doc = book();
const at = doc.spreads.findIndex(
(s, i) => autoRelayoutKind(s) === 'entry' && matchDecoratedTemplate(doc.spreads[i] as BookSpread, doc.page),
);
const before = doc.spreads[at] as BookSpread;
const next = autoRelayoutSpread(doc, at, input(), { seed: 1 });
expect(next?.id).toBe(before.id);
expect(next?.entryId).toBe(before.entryId);
expect(bookSpreadSchema.safeParse(next).success).toBe(true);
const was = [at - 1, at, at + 1].map(
(i) => doc.spreads[i] && matchDecoratedTemplate(doc.spreads[i] as BookSpread, doc.page)?.id,
);
expect(was).not.toContain(matchDecoratedTemplate(next as BookSpread, doc.page)?.id);
const photos = (s: BookSpread) =>
[...s.elements, ...s.parked].flatMap((e) => (e.kind === 'photo' && e.photoId != null ? [e.photoId] : [])).sort();
expect(photos(next as BookSpread)).toEqual(photos(before));
});
it('AUTODES-004: pressed again and again, an entry page keeps moving on', () => {
let doc = book();
const at = indexOf(doc, 'entry');
const seen = new Set<string>();
for (let n = 0; n < 4; n++) {
const next = autoRelayoutSpread(doc, at, input(), { seed: n });
if (!next) throw new Error('an entry page lays again');
seen.add(matchDecoratedTemplate(next, doc.page)?.id ?? 'composed');
doc = { ...doc, spreads: doc.spreads.map((s, i) => (i === at ? next : s)) };
}
expect(seen.size).toBeGreaterThan(1);
});
it('AUTODES-005: cycles the cover through the covers of its archetype and then its kin', () => {
let doc = book();
const seen: string[] = [];
for (let n = 0; n < 6; n++) {
const next = autoRelayoutSpread(doc, 0, input(), { seed: 1 });
if (!next) throw new Error('the cover lays again');
expect(next.id).toBe(doc.spreads[0]?.id);
seen.push(matchDecoratedTemplate(next, doc.page)?.id ?? '-');
doc = { ...doc, spreads: doc.spreads.map((s, i) => (i === 0 ? next : s)) };
}
expect(new Set(seen).size).toBeGreaterThan(3);
expect(seen.some((id) => id.startsWith('riso-'))).toBe(true);
});
it('AUTODES-006: cycles the summary and the countries through their looks', () => {
const doc = book();
const summary = indexOf(doc, 'summary');
const looks: string[] = [];
let cur = doc;
for (let n = 0; n < 3; n++) {
const next = autoRelayoutSpread(cur, summary, input()) as BookSpread;
const stats = next.elements.find((e) => e.kind === 'stats' && e.metrics.length > 2);
looks.push(stats?.kind === 'stats' ? (stats.look ?? 'classic') : '-');
cur = { ...cur, spreads: cur.spreads.map((s, i) => (i === summary ? next : s)) };
}
expect(new Set(looks).size).toBe(3);
expect(looks).not.toContain('classic');
const countries = indexOf(doc, 'countries');
const again = autoRelayoutSpread(doc, countries, input()) as BookSpread;
const look = (s: BookSpread) => s.elements.flatMap((e) => (e.kind === 'countries' ? [e.look] : []))[0];
expect(look(again)).not.toBe(look(doc.spreads[countries] as BookSpread));
});
it('AUTODES-007: a page the engine did not make is not laid again', () => {
const doc = book();
const back = doc.spreads.findIndex((s) => s.role === 'back');
expect(autoRelayoutSpread(doc, back, input())).toBeNull();
const stations = doc.spreads.findIndex(
(s) => s.role === 'inner' && s.entryId === null && autoRelayoutKind(s) === null,
);
if (stations >= 0) expect(autoRelayoutSpread(doc, stations, input())).toBeNull();
expect(autoRelayoutSpread(doc, 999, input())).toBeNull();
});
it('AUTODES-008: keeps a locked element where it is', () => {
const doc = book();
const at = indexOf(doc, 'entry');
const page = doc.spreads[at] as BookSpread;
const lockedEl = { ...(page.elements[0] as BookSpread['elements'][number]), locked: true };
const locked = { ...page, elements: [lockedEl, ...page.elements.slice(1)] };
const withLock = { ...doc, spreads: doc.spreads.map((s, i) => (i === at ? locked : s)) };
const next = autoRelayoutSpread(withLock, at, input());
expect(next?.elements).toContainEqual(lockedEl);
});
it('AUTODES-009: the whole book keeps a stored page that holds a lock', () => {
const doc = book();
const at = indexOf(doc, 'entry');
const page = doc.spreads[at] as BookSpread;
const locked = { ...page, elements: page.elements.map((e, i) => (i === 0 ? { ...e, locked: true } : e)) };
const again = autoDesignedBook({ ...doc, spreads: doc.spreads.map((s, i) => (i === at ? locked : s)) }, input(), {
theme: doc.theme,
seed: 9,
});
expect(again.spreads).toContainEqual(locked);
});
it('AUTODES-010: an old Auto layout summary is laid again in a new look', () => {
const doc = book();
const summary = indexOf(doc, 'summary');
const old = doc.spreads[summary] as BookSpread;
const classic = {
...old,
elements: old.elements.map((e) => (e.kind === 'stats' ? { ...e, look: undefined } : e)),
} as BookSpread;
const next = autoRelayoutSpread(
{ ...doc, spreads: doc.spreads.map((s, i) => (i === summary ? classic : s)) },
summary,
input(),
);
const looks = next?.elements.flatMap((e) => (e.kind === 'stats' ? [e.look] : []));
expect(looks?.length).toBeGreaterThan(0);
expect(looks?.every((l) => l && l !== 'classic')).toBe(true);
});
});
+225
View File
@@ -0,0 +1,225 @@
import { layoutAroundLocks } from '../book-layout-locks';
import type { BookCountryLook, BookDocument, BookElement, BookSpread, BookStatsLook } from '../book.schema';
import { designBook, templatedSetting, type BookDesignInput, type TemplatedSetting } from '../design/design-book';
import type { BookDesignDirectivesInput } from '../design/design-directives.schema';
import { ARCHETYPE_KIN, type TemplatePickCtx } from '../design/design-template-pick';
import { layEntryPage, type LaidPage, type PageStory } from '../design/design-templated';
import {
COUNTRY_CYCLE,
SUMMARY_CYCLE,
composeTravelCountries,
composeTravelSummary,
coverOnTemplate,
} from '../design/design-templated-framing';
import { decoratedTemplatesFor } from '../templates/template-catalogue';
import { templatePhotoSlots } from '../templates/template-format';
import { matchDecoratedTemplate } from '../templates/template-match';
import { bookThemeOf } from '../theme/book-theme';
import type { BookTheme, BookThemeId } from '../theme/book-theme.schema';
import { isPortrait } from './auto-elements';
import type { AutoEntry, AutoInput, AutoPhoto } from './auto-types';
/**
* The Studio's Auto layout on the designed templates.
*
* "The whole book" is the design engine laying the book on the Studio's
* decorated templates (`designBook` with `layouts: 'templates'`), in the
* book's own theme, keeping every page that holds a locked element. "This
* spread" lays one page again on another template than the one on screen and
* its neighbours: an entry's page on the next best spread for its photos and
* words, the cover on the next cover of its archetype and then of its kin,
* the summary and the countries in their next look. The old Auto layout
* (`buildBook`, `relayoutSpread`) stays as it was, the design engine's
* classic style.
*/
/** The theme a book without one is laid in: calm, linen, serif. */
export const AUTO_DEFAULT_THEME: BookThemeId = 'fabric-classic';
/** The journey as the Studio's Auto layout takes it: the entries with their stops, and the loose gallery. */
export interface AutoDesignedInput extends AutoInput {
gallery?: readonly AutoPhoto[];
}
export interface AutoDesignedOptions {
/** The book's theme, a recoloured one of its own included; the default theme without one. */
theme?: BookTheme | null;
/** Another seed lays another book; the same one the same. */
seed?: number;
}
const designInput = (input: AutoDesignedInput, theme: BookTheme | null | undefined): BookDesignInput => ({
...input,
candidates: [],
photos: [],
theme: theme ?? bookThemeOf(AUTO_DEFAULT_THEME),
});
const directivesOf = (theme: BookTheme | null | undefined, seed: number | undefined): BookDesignDirectivesInput => ({
style: theme?.id ?? AUTO_DEFAULT_THEME,
layouts: 'templates',
keep: 'locked',
...(seed === undefined ? {} : { seed }),
});
/** The whole book laid on the designed templates over the stored one, its page format kept. */
export function autoDesignedBook(
doc: BookDocument | null,
input: AutoDesignedInput,
opts: AutoDesignedOptions = {},
): BookDocument {
const page = doc?.page ?? input.page;
return designBook(doc, designInput({ ...input, page }, opts.theme), directivesOf(opts.theme, opts.seed)).doc;
}
/** What "this spread" can lay again: an entry's page, the cover, the summary, the countries; null for the rest. */
export type AutoRelayoutKind = 'entry' | 'cover' | 'summary' | 'countries';
/** The kind of page a spread of the book is for "this spread", or null for one the engine did not make. */
export function autoRelayoutKind(spread: BookSpread | undefined): AutoRelayoutKind | null {
if (!spread) return null;
if (spread.role === 'cover') return 'cover';
if (spread.role !== 'inner') return null;
if (spread.entryId != null) return 'entry';
const has = (kind: BookElement['kind']) => spread.elements.some((e) => e.kind === kind);
if (has('stats') && has('map')) return 'summary';
if (has('countries') && !has('stats')) return 'countries';
return null;
}
/** The photos a page holds, on it and parked with it, in order. */
function photosOn(spread: BookSpread, entries: readonly AutoEntry[]): AutoPhoto[] {
const known = new Map(entries.flatMap((e) => e.photos.map((p) => [p.photoId, p] as const)));
return [...spread.elements, ...spread.parked].flatMap((e) =>
e.kind === 'photo' && e.photoId != null
? [known.get(e.photoId) ?? { photoId: e.photoId, width: null, height: null }]
: [],
);
}
/** The story a page of this entry sets, and whether it is the whole of it. */
function storyOn(spread: BookSpread, entryId: number): PageStory {
for (const e of spread.elements) {
if (e.kind === 'text' && e.binding?.source === 'entry.story' && e.binding.entryId === entryId && e.text.trim()) {
return { words: e.text, whole: !e.overridden };
}
}
return null;
}
/** The templates of the page before and after this one, never to be laid here. */
function neighbours(doc: BookDocument, index: number): string[] {
return [doc.spreads[index - 1], doc.spreads[index + 1]].flatMap((s) =>
s ? (matchDecoratedTemplate(s, doc.page)?.id ?? []) : [],
);
}
/** Templates tried for a page whose story would be cut, before one that cuts it is taken. */
const STORY_TRIES = 4;
/** An entry's page on the best template but the one on screen and its neighbours'. */
function relayEntry(s: TemplatedSetting, doc: BookDocument, index: number, free: BookSpread): BookSpread | null {
const entry = s.placed.find((e) => e.id === free.entryId);
const own = doc.spreads[index];
if (!entry || !own) return null;
const current = matchDecoratedTemplate(own, doc.page)?.id;
const around = neighbours(doc, index);
const avoid = [...(current ? [current] : []), ...around];
const page = { photos: photosOn(free, s.placed), story: storyOn(free, entry.id), entries: s.placed };
const pick = (more: readonly string[]): TemplatePickCtx => ({
archetype: s.archetype,
page: s.c.page,
theme: s.c.look.theme,
history: around.slice(0, 1),
calm: s.calm,
seed: s.c.directives.seed ?? 0,
key: `relay:${entry.id}`,
avoid: [...avoid, ...more],
});
let laid: LaidPage | null = null;
const tried: string[] = [];
for (let n = 0; n < STORY_TRIES; n++) {
const next = layEntryPage(s.c, entry, page, pick(tried));
laid ??= next;
if (!next.rest || !next.template) {
laid = next;
break;
}
tried.push(next.template.id);
}
if (!laid) return null;
return { ...s.finish(laid.spread, !!laid.template).spread, entryId: entry.id };
}
/** The cover on the next cover template of its archetype, then of its kin, for the photos there are. */
function relayCover(s: TemplatedSetting, doc: BookDocument, index: number, input: AutoDesignedInput) {
const own = doc.spreads[index];
if (!own) return null;
const others = doc.spreads.flatMap((sp) => (sp === own ? [] : photosOn(sp, s.placed)));
const photos = [...new Map([...photosOn(own, s.placed), ...others].map((p) => [p.photoId, p])).values()].slice(0, 8);
const order = [s.archetype, ...ARCHETYPE_KIN[s.archetype]].flatMap((a) => decoratedTemplatesFor('single', a));
const fits = order.filter(
(t) => templatePhotoSlots(t) <= photos.length && (photos.length > 0 || !templatePhotoSlots(t)),
);
if (!fits.length) return null;
const current = matchDecoratedTemplate(own, doc.page)?.id;
const at = fits.findIndex((t) => t.id === current);
// A cover the engine cannot read takes the first of the archetype's, a portrait one for a tall photo first.
const tall = photos[0] && isPortrait(photos[0]) ? fits.find((t) => /-portrait$|^cutout-/.test(t.id)) : undefined;
const tpl = at >= 0 ? fits[(at + 1) % fits.length] : (tall ?? fits[0]);
if (!tpl) return null;
const ownTitle = doc.title !== input.title ? doc.title : undefined;
const words = { title: input.title, subtitle: input.subtitle, ownTitle };
return s.finish(coverOnTemplate(s.c, words, { photos, entries: s.placed }, tpl), true).spread;
}
/** The look after this one in its cycle; the first for a look outside it (the classic one). */
function nextLook<T extends string>(cycle: readonly T[], look: string | undefined): T {
const at = look ? cycle.indexOf(look as T) : -1;
return cycle[(at + 1) % cycle.length] as T;
}
/** The summary or the countries in their next look. */
function relayTravel(s: TemplatedSetting, free: BookSpread, kind: 'summary' | 'countries'): BookSpread | null {
if (kind === 'summary') {
const stats = free.elements.find((e) => e.kind === 'stats' && e.metrics.length > 2);
const look = stats?.kind === 'stats' ? stats.look : undefined;
const next = composeTravelSummary(s.c, nextLook<BookStatsLook>(SUMMARY_CYCLE, look));
return next ? s.finish(next).spread : null;
}
const country = free.elements.find((e) => e.kind === 'countries');
const look = country?.kind === 'countries' ? country.look : undefined;
const next = composeTravelCountries(s.c, nextLook<BookCountryLook>(COUNTRY_CYCLE, look));
return next ? s.finish(next).spread : null;
}
/**
* One page of the book laid again on the designed templates, around its
* locked elements, its id kept: null for a page the engine did not make
* (stops, the first and last page, the back, a page made by hand), or when
* the page and its locks would not fit.
*/
export function autoRelayoutSpread(
doc: BookDocument,
index: number,
input: AutoDesignedInput,
opts: Pick<AutoDesignedOptions, 'seed'> = {},
): BookSpread | null {
const spread = doc.spreads[index];
const kind = autoRelayoutKind(spread);
if (!spread || !kind) return null;
const setting = templatedSetting(
doc,
designInput({ ...input, page: doc.page }, doc.theme),
directivesOf(doc.theme, opts.seed),
);
return layoutAroundLocks(spread, (free) => {
const next =
kind === 'entry'
? relayEntry(setting, doc, index, free)
: kind === 'cover'
? relayCover(setting, doc, index, input)
: relayTravel(setting, free, kind);
return next ? { ...next, id: spread.id } : null;
});
}
+5 -5
View File
@@ -65,7 +65,7 @@
"B/full/spread/0": "ac843317bb8d097c",
"B/full/spread/1": "bf4b62404c36aa0e",
"B/full/spread/2": "1af653e4557c90b9",
"B/full/spread/3": "f766c7b6a3cad0fa",
"B/full/spread/3": "b094a0b5aa67b2c0",
"B/full/relayout/3": "c0f3d2e84826b420",
"B/full/spread/4": "8563f01a381c3b8b",
"B/full/spread/5": "2458025780d8cbf3",
@@ -85,14 +85,14 @@
"C/full/head": "0ffba0f701b7ccaa",
"C/full/spread/0": "ee87e9e195e1edde",
"C/full/spread/1": "bf4b62404c36aa0e",
"C/full/spread/2": "973ed5d0aacb1063",
"C/full/relayout/2": "973ed5d0aacb1063",
"C/full/spread/2": "b40cfdb0e5bea780",
"C/full/relayout/2": "b40cfdb0e5bea780",
"C/full/spread/3": "8eb39a4e9d726f5e",
"C/full/relayout/3": "8eb39a4e9d726f5e",
"C/full/spread/4": "5a037193ef347c07",
"C/full/relayout/4": "5a037193ef347c07",
"C/full/spread/5": "71c9f776ce8ffc87",
"C/full/relayout/5": "71c9f776ce8ffc87",
"C/full/spread/5": "b6dedb14199ad559",
"C/full/relayout/5": "b6dedb14199ad559",
"C/full/spread/6": "0debcd2babd7ee6b",
"C/full/relayout/6": "0debcd2babd7ee6b",
"C/full/spread/7": "2458025780d8cbf3",
+3 -3
View File
@@ -281,7 +281,7 @@ export const AUTO_TEMPLATES: readonly AutoTemplate[] = [
opacity: 1,
locked: false,
kind: 'photo',
photoId: 69,
photoId: null,
fit: 'cover',
focalX: 0.42168673729754674,
focalY: 0.23833399057977409,
@@ -436,7 +436,7 @@ export const AUTO_TEMPLATES: readonly AutoTemplate[] = [
opacity: 1,
locked: false,
kind: 'photo',
photoId: 68,
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
@@ -457,7 +457,7 @@ export const AUTO_TEMPLATES: readonly AutoTemplate[] = [
opacity: 1,
locked: false,
kind: 'photo',
photoId: 69,
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
+2
View File
@@ -16,6 +16,8 @@ export interface AutoPhoto {
width: number | null;
height: number | null;
caption?: string | null;
/** When it was taken (ISO), where the gallery knows: the templated auto layout shares loose photos out by day. */
takenAt?: string | null;
}
export interface AutoEntry {
+11 -1
View File
@@ -1,5 +1,5 @@
/**
* The seven cover archetypes a decorated book starts from: the Studio's cover
* The twelve cover archetypes a decorated book starts from: the Studio's cover
* templates are drawn in them, and the assistant's art direction names one.
*
* - spine-band: a bleed photo with a coloured band on the binding side.
@@ -9,6 +9,11 @@
* - grid-badge: a two by two photo grid with a round title badge at its centre.
* - minimal-serif: a white page, a large photo with a hairline, a very large thin serif title.
* - stamp-collage: photos in arches and stamps over a checker band, with a round postmark.
* - riso: a risograph print, spot inks overprinted a little out of register, halftone dots, bold type.
* - editorial: a travel magazine, a huge masthead, thin rules, an issue line, a photo edge to edge.
* - vintage-poster: a 1950s travel poster, flat layered mountains under a sun, a stacked title, a thin frame.
* - night-film: a dark cinematic page, a letterboxed photo, a film strip with sprocket holes, a neon accent.
* - field-notes: a traveller's notebook, squared paper, prints taped in at an angle, pressed leaves.
*/
export const BOOK_COVER_ARCHETYPES = [
'spine-band',
@@ -18,5 +23,10 @@ export const BOOK_COVER_ARCHETYPES = [
'grid-badge',
'minimal-serif',
'stamp-collage',
'riso',
'editorial',
'vintage-poster',
'night-film',
'field-notes',
] as const;
export type BookCoverArchetype = (typeof BOOK_COVER_ARCHETYPES)[number];
@@ -93,6 +93,8 @@ export const bookDesignPlanRowSchema = z.object({
entryId: z.number().int().positive().nullable(),
photoIds: z.array(z.number().int().positive()).max(MAX_SPREAD_ELEMENTS),
findings: z.object({ severe: z.number().int().nonnegative(), warn: z.number().int().nonnegative() }),
/** The designed template the page was laid on, for a design with `layouts: 'templates'`. */
template: z.string().max(40).optional(),
});
export type BookDesignPlanRowOut = z.infer<typeof bookDesignPlanRowSchema>;
+220 -1
View File
@@ -1,5 +1,6 @@
import { buildBook, distributeGallery } from '../auto/auto-book';
import type { AutoEntry, AutoInput, AutoPhoto } from '../auto/auto-types';
import type { BookCoverArchetype } from '../book-archetypes';
import { briefFindings, type BookBriefArt } from '../book-findings-art';
import { bookLevelFindings, sortBookFindings } from '../book-findings-design';
import { withRealWeight } from '../book-fonts';
@@ -11,6 +12,8 @@ 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 { isBookFontFamily, mutedOn, readableOn } from '../theme/book-theme';
import type { BookTheme } from '../theme/book-theme.schema';
import { designArt, livelyLook, type BookDesignSticker } from './design-art';
import type { ComposeCtx } from './design-compose';
import type { CoverMaterial } from './design-cover-kit';
@@ -25,9 +28,18 @@ 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 {
TEMPLATED_ENTRY_PHOTOS,
choosePhotos,
shareGalleryByDay,
type BookDesignLeftOut,
type DesignPhotoChoice,
} from './design-photos';
import { repairSpread } from './design-repair';
import { designGrid, designLook, paperBackground, type BookDesignLook } from './design-style';
import { archetypeOfTheme } from './design-template-pick';
import { composeTemplatedInner } from './design-templated';
import { composeTemplatedCover, composeTravelBack } from './design-templated-framing';
import { themeDesignedSpread, unlinkDrifted } from './design-theme';
/**
@@ -64,6 +76,12 @@ export interface BookDesignInput extends AutoInput {
* for the stickers a decorated design places; without it nothing is stuck on.
*/
stickers?: readonly BookDesignSticker[];
/**
* The book's own theme, recoloured or not, for a book laid on the designed
* templates (`layouts: 'templates'`): it is kept whole in place of the
* style's preset. The composed pages ignore it.
*/
theme?: BookTheme;
}
export interface BookDesignResult {
@@ -117,6 +135,7 @@ function pageFor(doc: BookDocument | null, input: BookDesignInput, directives: B
*/
function classicOnly(d: BookDesignDirectives): boolean {
if (d.style !== 'classic' || d.photos || d.palette || d.type || d.brief) return false;
if (d.layouts === 'templates') return false;
if (d.pages || d.cover || d.text || d.travel) return false;
return d.chapters === 'entry' && d.density === 'balanced';
}
@@ -230,6 +249,7 @@ export function designBook(
const directives = withBriefTheme(bookDesignDirectivesSchema.parse(raw));
const entries = inScope(input.entries, directives.scope);
if (classicOnly(directives)) return classicDesign(doc, input, directives, entries);
if (directives.layouts === 'templates') return templatedDesign(doc, input, raw);
const newId = input.newId ?? bookElementId;
const page = pageFor(doc, input, directives);
@@ -381,3 +401,202 @@ const TITLE_PAGE_STYLES: ReadonlySet<string> = new Set(['editorial', 'journal',
function blank(c: ComposeCtx, role: 'first' | 'last'): BookSpread {
return { id: c.newId('sp'), role, background: paperBackground(c.look), elements: [], parked: [], entryId: null };
}
// ── the book laid on the designed templates ────────────────────────────────
/** The look with the book's own theme kept whole: its palette, its faces, its frames. */
function withOwnTheme(look: BookDesignLook, theme: BookTheme | undefined): BookDesignLook {
if (!theme) return look;
const paper = theme.palette.paper.toLowerCase();
const ink = readableOn(paper, theme.palette.ink);
const face = (f: string, fallback: BookDesignLook['heading']) => (isBookFontFamily(f) ? f : fallback);
return {
...look,
paper,
ink,
muted: mutedOn(paper, ink),
accent: theme.palette.accent,
heading: face(theme.fonts.heading.font, look.heading),
body: face(theme.fonts.body.font, look.body),
frameStyle: theme.frameStyle,
tilt: theme.tilt,
theme,
};
}
/** The directives with each entry's photos held to what its pages on the templates take. */
const cappedPhotos = (d: BookDesignDirectives): BookDesignDirectives => ({
...d,
photos: { pick: 'best', ...d.photos, perChapterMax: d.photos?.perChapterMax ?? TEMPLATED_ENTRY_PHOTOS },
});
/** The cover, the first page, the inner pages, the last page and the back of a templated book, in order. */
function templatedPages(
c: ComposeCtx,
input: BookDesignInput,
parts: { placed: AutoEntry[]; usedPhotos: AutoPhoto[]; inner: DesignedSpread[]; archetype: BookCoverArchetype },
): DesignedSpread[] {
const { directives } = c;
const words = {
title: input.title,
subtitle: input.subtitle,
ownTitle: directives.cover?.title,
ownSubtitle: directives.cover?.subtitle,
};
const cover = coverPhoto(input, directives, parts.usedPhotos);
const material = coverMaterial(cover, parts.usedPhotos, parts.placed);
const front = directives.cover?.template
? { spread: composeCover(c, words, cover, material), template: null }
: composeTemplatedCover(c, words, material, parts.archetype);
const titlePage = TITLE_PAGE_STYLES.has(c.look.theme.id ?? directives.style);
const first = titlePage ? composeTitlePage(c, words, parts.placed) : blank(c, 'first');
const page = (spread: BookSpread, beat: BookDesignBeat): DesignedSpread => ({
spread,
beat,
chapter: null,
findings: [],
});
return [
{ ...page(front.spread, 'cover'), ...(front.template ? { template: front.template } : {}) },
page(first, 'first'),
...parts.inner,
page(blank(c, 'last'), 'last'),
page(composeTravelBack(c, words, parts.placed), 'back'),
];
}
/**
* The book laid on the Studio's designed templates (`layouts: 'templates'`):
* the cover on a cover template of the theme's archetype, every chapter on
* its spreads, the summary, the countries and the back cover in the newer
* travel looks. The rest is the composer's: the scope, the photo picker, the
* page budget, the stored pages kept, the repair and the findings.
*/
/** What a book on the designed templates is laid with: its context, archetype, entries and photos. */
export interface TemplatedSetting {
c: ComposeCtx;
archetype: BookCoverArchetype;
/** The book counts as calm for template choice. */
calm: boolean;
/** The entries in date order, the gallery shared out to them. */
placed: AutoEntry[];
/** Loose photos no entry had room for. */
rest: AutoPhoto[];
choice: DesignPhotoChoice;
/** A page finished as the design finishes it: a templated one repaired, a composed one linked to the theme too. */
finish: (spread: BookSpread, templated?: boolean) => { spread: BookSpread; findings: BookFinding[] };
}
/**
* The setting of a book laid on the designed templates, read from the
* stored book, the journey and the directives: what the whole book is laid
* with, and what laying one of its pages again needs.
*/
export function templatedSetting(
doc: BookDocument | null,
input: BookDesignInput,
raw: BookDesignDirectivesInput,
): TemplatedSetting {
const directives = withBriefTheme(bookDesignDirectivesSchema.parse(raw));
const entries = inScope(input.entries, directives.scope);
const newId = input.newId ?? bookElementId;
const page = pageFor(doc, input, directives);
const shared = shareGalleryByDay(byDate(entries), input.gallery ?? [], input.candidates);
const placed = shared.entries;
const choice = choosePhotos(placed, input.candidates, cappedPhotos(directives), input.photos);
const usedPhotos = [...choice.byEntry.values()].flat();
const usedInfos = input.photos.filter((p) => usedPhotos.some((u) => u.photoId === p.photoId));
const plain = withOwnTheme(designLook(directives, page, usedInfos), input.theme);
const art = designArt(directives, plain.theme, input.stickers);
const look = livelyLook(plain, art, directives);
const c: ComposeCtx = {
look,
page,
grid: designGrid(page),
newId,
locale: input.locale,
infos: new Map(input.photos.map((p) => [p.photoId, p])),
travel: travelOf(input, placed),
directives,
notes: [],
art,
};
const archetype = directives.cover?.archetype ?? archetypeOfTheme(look.theme);
// A rich theme designed without a brief is lively for its templates, so a poster book gets its loud pages at all.
const calm = art.energy === 'calm' && !(look.theme.decoration === 'rich' && !art.briefed);
const repairCtx = { page, photos: input.photos, look, newId };
// A templated page has its colours linked to the theme by its template, so it is repaired, not relinked.
const finish = (spread: BookSpread, templated = false) => {
const fixed = repairSpread(templated ? spread : themeDesignedSpread(spread, look), repairCtx);
const weighted = { ...fixed.spread, elements: fixed.spread.elements.map(withRealWeight) };
return { spread: templated ? weighted : unlinkDrifted(weighted, look.theme), findings: fixed.findings };
};
return { c, archetype, calm, placed, rest: shared.rest, choice, finish };
}
/**
* The book laid on the Studio's designed templates (`layouts: 'templates'`):
* the cover on a cover template of the theme's archetype, every chapter on
* its spreads, the summary, the countries and the back cover in the newer
* travel looks. The rest is the composer's: the scope, the photo picker, the
* page budget, the stored pages kept, the repair and the findings.
*/
function templatedDesign(
doc: BookDocument | null,
input: BookDesignInput,
raw: BookDesignDirectivesInput,
): BookDesignResult {
const { c, archetype, calm, placed, rest, choice, finish } = templatedSetting(doc, input, raw);
const needs: FramingNeeds = { room: roomFor(doc, c.directives.keep), perStations: stationsPerSpread(c) };
const inner = composeTemplatedInner(c, placed, choice, input, needs, { archetype, calm, rest });
const usedPhotos = [...choice.byEntry.values()].flat();
const designed = templatedPages(c, input, { placed, usedPhotos, inner: inner.spreads, archetype });
const done = designed.map((d): DesignedSpread => ({ ...d, ...finish(d.spread, !!d.template) }));
const leftOut = [...choice.leftOut, ...inner.leftOut];
return templatedResult(doc, input, { c, done, finish, placed, leftOut, notes: inner.notes });
}
interface TemplatedParts {
c: ComposeCtx;
done: DesignedSpread[];
finish: (spread: BookSpread) => { spread: BookSpread; findings: BookFinding[] };
placed: AutoEntry[];
leftOut: BookDesignLeftOut[];
notes: string[];
}
/** The templated pages over the stored book, the overrides laid on them, and what is left to say. */
function templatedResult(doc: BookDocument | null, input: BookDesignInput, parts: TemplatedParts): BookDesignResult {
const { c, finish, placed, notes } = parts;
const { look, directives, page } = c;
const designed = withinRoom(parts.done, roomFor(doc, directives.keep), notes);
const laid = mergeDesign(doc, designed, directives.keep);
const built: BookDocument = {
version: 1,
title: (directives.cover?.title ?? input.title).slice(0, MAX_BOOK_TITLE),
page: { ...page, pageNumbers: { ...page.pageNumbers, font: look.body, startAt: 1 } },
spreads: laid.spreads,
...(doc?.layouts ? { layouts: doc.layouts } : {}),
theme: look.theme,
};
const overrides = directives.pages?.overrides ?? [];
const env = { c, entries: placed, finish, ops: { photos: input.photos, newId: c.newId } };
const merged = overrides.length
? applyPageOverrides(built, laid.plan, overrides, env)
: { doc: built, plan: laid.plan, notes: [] };
const residual = overrides.length
? redesignedFindings(merged.doc, merged.plan, input.photos)
: designed.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
const judged = briefOf(c);
const brief = judged ? briefFindings(merged.doc, judged, briefScope(merged.plan)) : [];
const used = [...new Set(merged.plan.flatMap((r) => (r.action === 'drop' ? [] : r.photoIds)))];
return {
doc: merged.doc,
plan: merged.plan,
style: { paper: look.paper, ink: look.ink, accent: look.accent, heading: look.heading, body: look.body },
photos: { used, left_out: parts.leftOut },
findings: sortBookFindings([...residual, ...bookLevelFindings(merged.doc), ...brief]),
notes: [...new Set([...notes, ...c.notes, ...merged.notes])],
...(judged ? { brief: judged } : {}),
};
}
@@ -32,6 +32,11 @@ export const BOOK_COVER_ARCHETYPE_LINES: Readonly<Record<BookCoverArchetype, str
'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',
riso: 'spot inks overprinted out of register, halftone dots, a duotone photo, a heavy title with an ink shadow',
editorial: 'a magazine cover: a huge masthead, a thin rule and an issue line, a photo edge to edge',
'vintage-poster': 'a retro travel poster: flat mountains under a sun, a photo in an arch, a stacked title',
'night-film': 'a near black page, a letterboxed photo, a film strip with sprocket holes, neon capitals',
'field-notes': 'squared notebook paper, prints taped in at an angle, pressed leaves, a script line',
};
/** The pages a lively or loud book is made of besides the calm ones (design-lively.ts). */
+1 -1
View File
@@ -56,7 +56,7 @@ describe('the art direction of design_book', () => {
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', () => {
it('DSNBRF-003: the cover takes an archetype of the twelve 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);
@@ -0,0 +1,108 @@
import type { BookElement, BookFrame, BookPhotoElement, BookTextElement } from '../book.schema';
import { frameOf } from './design-compose';
import {
addLockup,
bandPhoto,
coverText,
deepened,
inkOn,
mmOn,
mmToPt,
shapeEl,
spacedLine,
standOut,
titleText,
type CoverDraft,
type CoverKit,
} from './design-cover-kit';
/**
* Editorial: the cover of a travel magazine. The title as a huge masthead
* across the top of the paper, a thin rule under it with the issue line (the
* month on the left, the place on the right), the cover photo edge to edge
* below (a field of colour without one), and the subtitle as the cover line
* on a block of the accent in a lower corner, off the picture's detail.
*/
const holds = (b: BookFrame, x: number, y: number) => x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h;
/** A short line of spaced capitals set flush to one side. */
function issueLine(
k: CoverKit,
words: string,
frame: BookFrame,
color: string,
align: 'left' | 'right',
): BookTextElement {
return { ...spacedLine(k, words, frame, color, 8), align };
}
/** Where the cover line's block may lie: the lower left corner, else the lower right, clear of the photo's detail. */
function coverLineBox(k: CoverKit, print: BookPhotoElement | null, w: number, h: number): BookFrame | null {
const { W, H, m } = k;
const corners = [frameOf(m, H - m - h, w, h), frameOf(W - m - w, H - m - h, w, h)];
if (!print) return corners[0] ?? null;
const fx = print.frame.x + print.focalX * print.frame.w;
const fy = print.frame.y + print.focalY * print.frame.h;
return corners.find((b) => !holds(frameOf(b.x - 3, b.y - 3, b.w + 6, b.h + 6), fx, fy)) ?? null;
}
export function editorial(k: CoverKit): CoverDraft {
const { c, W, H, b, m } = k;
const draft: CoverDraft = { elements: [], groups: [] };
const ground = c.look.paper;
const ink = inkOn(c, ground);
const mast = H * k.rng.range(0.24, 0.28);
const zone = frameOf(m, m, W - m * 2, mast - m);
const ruleY = mast + mmOn(k, 1.5);
const lineY = ruleY + mmOn(k, 2);
const lineH = mmOn(k, 6);
const top = lineY + lineH + mmOn(k, 3);
const photo = k.photos[0];
const print = photo ? bandPhoto(k, photo, frameOf(-b, top, W + b * 2, H - top + b)) : null;
// Without a photo its place is a field of colour, as a magazine prints a cover of type alone.
draft.elements.push(print ?? shapeEl(k, 'rect', frameOf(-b, top, W + b * 2, H - top + b), standOut(c, ground)));
const head: BookElement[] = [];
const asked = addLockup(k, draft, { box: zone, ground });
if (!asked.length && k.title.words.trim()) {
head.push(
titleText(k, zone, {
max: mmToPt(zone.h * 0.82),
color: ink,
transform: 'upper',
leading: 0.9,
weight: 800,
}),
);
}
const thin = Math.max(0.3, mmOn(k, 0.4));
head.push(shapeEl(k, 'rect', frameOf(m, ruleY, W - m * 2, thin), ink));
const half = (W - m * 2) / 2 - mmOn(k, 2);
const left = k.words.dates || k.words.year || '';
const place = k.words.place ?? '';
if (left) head.push(issueLine(k, left, frameOf(m, lineY, half, lineH), ink, 'left'));
if (place && place !== left) head.push(issueLine(k, place, frameOf(W - m - half, lineY, half, lineH), ink, 'right'));
draft.elements.push(...head);
if (head.length > 1) draft.groups.push(head.map((e) => e.id));
// The cover line: the subtitle on a block of the accent over the photo, or on the paper without one.
const sub = k.sub.words.trim();
if (!sub || asked.length) return draft;
const bw = W * k.rng.range(0.42, 0.5);
const bh = Math.min(mmOn(k, 30), (H - top) * 0.36);
const box = coverLineBox(k, print, bw, bh);
if (!box) return draft;
const block = deepened(print ? standOut(c, ground) : inkOn(c, ground));
const pad = mmOn(k, 4);
const words = coverText(k, sub, frameOf(box.x + pad, box.y + pad, box.w - pad * 2, box.h - pad * 2), {
slot: 'heading',
max: 16,
color: inkOn(c, block),
align: 'left',
leading: 1.1,
});
const line = k.sub.bound ? { ...words, binding: { source: 'journey.subtitle' as const } } : words;
const parts = [shapeEl(k, 'rect', box, block), line];
draft.elements.push(...parts);
draft.groups.push(parts.map((e) => e.id));
return draft;
}
@@ -0,0 +1,155 @@
import { deltaE, luminance, parseHex } from '../book-colour';
import { solveBookTextFit } from '../book-text-fit';
import type { BookElement, BookFrame, BookPhotoElement, BookShapeId, BookTextElement } from '../book.schema';
import { mixHex } from '../theme/book-theme';
import { CALM_ART } from './design-art';
import { frameOf } from './design-compose';
import {
accentOn,
addLockup,
clearOfTrim,
coverText,
inkOn,
keptTurn,
mmOn,
mmToPt,
printEl,
shapeEl,
titleText,
type CoverDraft,
type CoverKit,
} from './design-cover-kit';
import { placeStickers, sharedArea, turnedBox } from './design-stickers';
/**
* Field notes: a page of a traveller's notebook. A sheet of squared paper
* laid a little askew on the kraft, the photos taped in as prints turned a
* few degrees each way with a strip of tape over their top edge, pressed
* leaves beside them, and under them the title in the display face, the
* subtitle (or the place) in the script, and the dates in mono.
*/
const WHITE = '#ffffff';
const lum = (hex: string) => luminance(parseHex(hex) ?? { r: 255, g: 255, b: 255 });
/** The sheet's paper: a light colour of the palette apart from the page, else the page lightened. */
function sheetOf(paper: string, accent2: string): string {
const tried = [accent2, mixHex(paper, WHITE, 0.6), '#f8f4ea'];
return tried.find((h) => lum(h) >= 0.72 && deltaE(h, paper) > 6) ?? '#f8f4ea';
}
/** Where the prints go, as shares of the page: a large one, one beside it and a small one under that. */
const SPOTS: readonly [number, number, number, number][] = [
[0.1, 0.1, 0.48, 0.4],
[0.62, 0.17, 0.28, 0.27],
[0.6, 0.5, 0.3, 0.22],
];
const LEAVES: readonly BookShapeId[] = ['leaf-long', 'leaf-oak', 'leaf'];
export function fieldNotes(k: CoverKit): CoverDraft {
const { c, W, H, m } = k;
const p = c.look.theme.palette;
const draft: CoverDraft = { elements: [], groups: [] };
const sheet = sheetOf(c.look.paper, p.accent2);
const ink = inkOn(c, sheet);
const tilt = -k.rng.range(1, 2.5);
const inset = m * 0.55;
const sheetFrame = clearOfTrim(k, frameOf(inset, inset, W - inset * 2, H - inset * 2), tilt);
const lines = mixHex(ink, sheet, 0.86);
const scale = Math.round(k.rng.range(0.55, 0.7) * 100) / 100;
draft.elements.push({
...shapeEl(k, 'rect', sheetFrame, sheet, tilt),
paint: { type: 'pattern', pattern: 'grid', fg: lines, bg: sheet, scale, angle: 0 },
});
// The words take the foot of the sheet, left of the small print.
const top = H * 0.62;
const zone = frameOf(m, top, W * 0.54 - m, H - m - top);
const leaf = p.accent3;
const placed: BookFrame[] = [];
const prints: BookPhotoElement[] = [];
k.photos.slice(0, SPOTS.length).forEach((photo, i) => {
const [x, y, w, h] = SPOTS[i] as [number, number, number, number];
const turn = keptTurn(k, (i % 2 === 0 ? -1 : 1) * k.rng.range(2, 4));
const print = printEl(k, photo, frameOf(W * x, H * y, W * w, H * h), {
turn,
border: { width: mmOn(k, 2.6), color: WHITE },
shadow: true,
});
const box = turnedBox(print.frame, print.rotation);
if (placed.some((o) => sharedArea(o, box) > 0) || sharedArea(box, zone) > 0) return;
placed.push(box);
prints.push(print);
});
// Pressed leaves on the sheet, behind the prints and clear of the words.
const leaves: BookElement[] = [];
const spots: [number, number][] = [
[W * 0.07, H * 0.5],
[W * 0.9, H * 0.86],
];
k.rng
.shuffle(LEAVES)
.slice(0, spots.length)
.forEach((shape, i) => {
const [cx, cy] = spots[i] as [number, number];
const size = Math.min(W, H) * k.rng.range(0.16, 0.22);
const turn = k.rng.range(-40, 40);
const frame = clearOfTrim(k, frameOf(cx - size / 2, cy - size / 2, size, size), turn);
if (sharedArea(turnedBox(frame, turn), zone) > 0) return;
leaves.push({ ...shapeEl(k, shape, frame, leaf, turn), opacity: 0.9 });
});
draft.elements.push(...leaves);
// Each print with its strip of tape over the top edge, the tape see-through so the picture shows under it.
const tape = mixHex(sheet, c.look.accent, 0.22);
for (const print of prints) {
const tw = print.frame.w * 0.4;
const th = mmOn(k, 7);
const frame = frameOf(print.frame.x + (print.frame.w - tw) / 2, print.frame.y - th / 2, tw, th);
const strip = { ...shapeEl(k, 'rect', frame, tape, print.rotation + k.rng.range(-7, 7)), opacity: 0.72 };
const parts = [print, { ...strip, edge: 'zigzag' as const, edgeSize: mmOn(k, 1) }];
draft.elements.push(...parts);
draft.groups.push(parts.map((e) => e.id));
}
if (!addLockup(k, draft, { box: zone, ground: sheet }).length) notes(k, draft, zone, sheet, ink);
const count = (c.art ?? CALM_ART).bare ? 0 : k.rng.int(1, 3);
draft.elements.push(
...placeStickers(c, draft.elements, {
count,
size: [0.1, 0.15],
sides: ['single'],
key: 'cover:field-notes',
avoid: [zone],
used: c.art?.used,
}),
);
return draft;
}
/** The notebook's own words in a box: the title, a line in the script, the dates in mono at the foot. */
function notes(k: CoverKit, draft: CoverDraft, zone: BookFrame, sheet: string, ink: string): void {
const words: BookTextElement[] = [];
const script = k.sub.words.trim() || k.words.place || '';
const dates = k.words.dates || k.words.year || '';
const scriptH = script ? Math.min(mmOn(k, 14), zone.h * 0.26) : 0;
const datesH = dates ? mmOn(k, 6) : 0;
const titleH = zone.h - scriptH - datesH - mmOn(k, 2);
if (k.title.words.trim()) {
const frame = frameOf(zone.x, zone.y, zone.w, titleH);
words.push(titleText(k, frame, { max: mmToPt(titleH * 0.6), color: ink, align: 'left', leading: 0.95 }));
}
if (script) {
const frame = frameOf(zone.x, zone.y + titleH, zone.w, scriptH);
const max = k.c.look.theme.fonts.script ? 26 : 14;
const line = coverText(k, script, frame, { slot: 'script', max, color: accentOn(k.c, sheet), align: 'left' });
const bound = script === k.sub.words.trim() && k.sub.bound;
words.push(bound ? { ...line, binding: { source: 'journey.subtitle' } } : line);
}
if (dates) {
const frame = frameOf(zone.x, zone.y + zone.h - datesH, zone.w, datesH);
const line = coverText(k, dates, frame, { slot: 'heading', max: 8, color: ink, align: 'left', tracking: 0.12 });
const mono: BookTextElement = { ...line, font: 'mono', weight: 400, fitMax: 8 };
words.push({ ...mono, size: solveBookTextFit(mono, 8) });
}
draft.elements.push(...words);
if (words.length > 1) draft.groups.push(words.map((e) => e.id));
}
+26 -1
View File
@@ -15,7 +15,7 @@ import type {
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 { bleedPage, frameOf, photoEl, printsAt, 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';
@@ -171,6 +171,12 @@ export function standOut(c: ComposeCtx, ground: string, skip: readonly string[]
return tried.find((h) => deltaE(h, ground) > 18 && contrastRatio(h, ground) >= 1.4) ?? inkOn(c, ground);
}
/** An accent small words read in on a ground (4.5:1), else the ink that reads there. */
export function accentOn(c: ComposeCtx, ground: string): string {
const p = c.look.theme.palette;
return [c.look.accent, p.accent3, p.accent2].find((h) => contrastRatio(h, ground) >= 4.5) ?? 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
@@ -397,6 +403,12 @@ export function spacedLine(k: PageKit, words: string, frame: BookFrame, color: s
});
}
/** A text on a band of a colour behind its words, set in the ink that reads on the band. */
export function onBand(k: PageKit, el: BookTextElement, band: string): BookTextElement {
const highlight = { color: band, alpha: 1, padX: mmOn(k, 1.6), padY: mmOn(k, 0.6), radius: 0 };
return { ...el, color: inkOn(k.c, band), highlight };
}
// ── shapes and photos ──────────────────────────────────────────────────────
/** A filled shape, unturned unless said, with no outline. */
@@ -456,6 +468,19 @@ export function bleedPhoto(k: CoverKit, photo: AutoPhoto): BookPhotoElement {
return photoEl(k.c, photo, bleedPage(k.c, 'single'), { bleed: { side: 'single' } });
}
/**
* A photo across the page in a band of it, to the bleed at both sides (a
* magazine's picture under its masthead, a letterboxed still): to the bleed
* when it prints sharp at that size, else as a print inside the margins over
* the same rows, as the cover photo falls back.
*/
export function bandPhoto(k: CoverKit, photo: AutoPhoto, band: BookFrame): BookPhotoElement {
if (printsAt(k.c, photo, band)) return photoEl(k.c, photo, band, { bleed: { side: 'single' } });
const y = Math.max(band.y, k.m);
const bottom = Math.min(band.y + band.h, k.H - k.m);
return printEl(k, photo, frameOf(k.m, y, k.W - k.m * 2, bottom - y));
}
interface PrintLook {
turn?: number;
border?: { width: number; color: string };
@@ -0,0 +1,142 @@
import { luminance, parseHex } from '../book-colour';
import { solveBookTextFit } from '../book-text-fit';
import type { BookElement, BookFrame, BookLineElement, BookTextElement } from '../book.schema';
import { mixHex } from '../theme/book-theme';
import { frameOf } from './design-compose';
import {
accentOn,
addLockup,
bandPhoto,
coverText,
inkOn,
mmOn,
mmToPt,
printEl,
shapeEl,
spacedLine,
titleText,
type CoverDraft,
type CoverKit,
} from './design-cover-kit';
/**
* Night film: a dark cinematic page. The month and the place in mono in the
* neon accent above a letterboxed still to the bleed, the title in tall
* condensed capitals widely spaced with a short neon rule, and across the
* foot a strip of film, its sprocket holes two dashed lines, holding the
* next photos as frames.
*/
const lum = (hex: string) => luminance(parseHex(hex) ?? { r: 255, g: 255, b: 255 });
/** The page's near black: the darker of the paper and the ink, taken towards black until it is one. */
function nightOf(paper: string, ink: string): string {
let night = lum(paper) <= lum(ink) ? paper : ink;
for (let step = 0; step < 8 && lum(night) > 0.012; step++) night = mixHex(night, '#000000', 0.35);
return night;
}
/** A straight dashed line across a frame, as a row of sprocket holes. */
function sprockets(k: CoverKit, frame: BookFrame, stroke: string): BookLineElement {
return {
id: k.c.newId('l'),
kind: 'line',
frame,
rotation: 0,
opacity: 1,
locked: false,
points: [
{ x: 0, y: 0.5 },
{ x: 1, y: 0.5 },
],
curve: 'straight',
stroke,
strokeWidth: Math.round(frame.h * 100) / 100,
dash: 'dashed',
squiggle: 'none',
startCap: 'none',
endCap: 'none',
wobble: 0,
ornament: 'none',
};
}
/** A line in mono, as a timecode is printed on the edge of a film. */
function monoLine(
k: CoverKit,
words: string,
frame: BookFrame,
color: string,
align: 'left' | 'right',
): BookTextElement {
const text = coverText(k, words, frame, { slot: 'heading', max: 8, color, align, tracking: 0.2 });
const mono: BookTextElement = { ...text, font: 'mono', weight: 500, transform: 'upper', fitMax: 8 };
return { ...mono, size: solveBookTextFit(mono, 8) };
}
export function nightFilm(k: CoverKit): CoverDraft {
const { c, W, H, b, m } = k;
const night = nightOf(c.look.paper, c.look.ink);
const draft: CoverDraft = { elements: [], groups: [], ...(night === c.look.paper ? {} : { background: night }) };
const light = inkOn(c, night);
const neon = accentOn(c, night);
// Down the page: the timecode line, the still, the title, the strip.
const metaH = mmOn(k, 6);
const stillY = m + metaH + mmOn(k, 4);
const stillH = Math.min(H * k.rng.range(0.36, 0.42), W * 0.46);
const rest = H - m - (stillY + stillH) - mmOn(k, 12);
const stripH = Math.min(rest * 0.5, W * 0.24);
const stripY = H - m - stripH;
const titleY = stillY + stillH + mmOn(k, 6);
const titleBox = frameOf(m, titleY, W - m * 2, stripY - mmOn(k, 6) - titleY);
const [photo, ...more] = k.photos;
if (photo) draft.elements.push(bandPhoto(k, photo, frameOf(-b, stillY, W + b * 2, stillH)));
// The film: a band a shade off the night, sprockets along both edges, the frames between them.
const film = mixHex(night, light, 0.1);
const hole = Math.max(0.6, stripH * 0.09);
draft.elements.push(
shapeEl(k, 'rect', frameOf(-b, stripY, W + b * 2, stripH), film),
sprockets(k, frameOf(-b, stripY + stripH * 0.12 - hole / 2, W + b * 2, hole), mixHex(light, film, 0.3)),
sprockets(k, frameOf(-b, stripY + stripH * 0.88 - hole / 2, W + b * 2, hole), mixHex(light, film, 0.3)),
);
const frames = more.slice(0, 3);
const gap = mmOn(k, 3);
const fw = frames.length ? (W - m * 2 - gap * (frames.length - 1)) / frames.length : 0;
frames.forEach((p, i) => {
draft.elements.push(printEl(k, p, frameOf(m + i * (fw + gap), stripY + stripH * 0.22, fw, stripH * 0.56)));
});
const meta: BookElement[] = [];
const when = k.words.dates || k.words.year || '';
const place = k.words.place ?? '';
const half = (W - m * 2) / 2 - mmOn(k, 2);
if (when) meta.push(monoLine(k, when, frameOf(m, m, half, metaH), neon, 'left'));
if (place && place !== when) meta.push(monoLine(k, place, frameOf(W - m - half, m, half, metaH), neon, 'right'));
draft.elements.push(...meta);
if (meta.length > 1) draft.groups.push(meta.map((e) => e.id));
if (addLockup(k, draft, { box: titleBox, ground: night }).length) return draft;
const head: BookElement[] = [];
const sub = k.sub.words.trim();
const subH = sub ? mmOn(k, 7) : 0;
if (k.title.words.trim()) {
const box = frameOf(titleBox.x, titleBox.y, titleBox.w, titleBox.h - subH - mmOn(k, 3));
head.push(
titleText(k, box, {
max: mmToPt(box.h * 0.85),
color: light,
transform: 'upper',
tracking: 0.14,
leading: 0.9,
align: 'left',
}),
);
}
const ruleY = titleBox.y + titleBox.h - subH - mmOn(k, 1.5);
head.push(shapeEl(k, 'rect', frameOf(m, ruleY, W * 0.16, Math.max(0.6, mmOn(k, 0.8))), neon));
if (sub) {
const line = spacedLine(k, sub, frameOf(m, titleBox.y + titleBox.h - subH, W * 0.6, subH), neon, 9);
head.push({ ...line, align: 'left', ...(k.sub.bound ? { binding: { source: 'journey.subtitle' as const } } : {}) });
}
draft.elements.push(...head);
if (head.length > 1) draft.groups.push(head.map((e) => e.id));
return draft;
}
+119
View File
@@ -0,0 +1,119 @@
import { luminance, parseHex } from '../book-colour';
import type { BookElement, BookFrame } from '../book.schema';
import { CALM_ART } from './design-art';
import { frameOf } from './design-compose';
import {
addLockup,
clearOfTrim,
deepened,
inkOn,
keptTurn,
mmOn,
mmToPt,
onBand,
printEl,
shapeEl,
spacedLine,
standOut,
titleText,
type CoverDraft,
type CoverKit,
} from './design-cover-kit';
import { placeStickers, turnedBox } from './design-stickers';
/**
* Riso print: a page printed in two or three spot inks, each a little out of
* register with the next. A disc of one ink and a halftone disc of another
* laid over it off centre, the cover photo as a duotone of the book's ink on
* a block of the first ink that shows past its edge, the title in heavy
* capitals with a hard shadow of that ink beside it, the subtitle and the
* year on bands of their own, and a few doodles.
*/
/** The darker and the lighter of two colours, for a duotone that keeps its tones the right way round in any theme. */
function tonesOf(a: string, b: string): { dark: string; light: string } {
const lum = (h: string) => luminance(parseHex(h) ?? { r: 255, g: 255, b: 255 });
return lum(a) <= lum(b) ? { dark: a, light: b } : { dark: b, light: a };
}
/** A box moved down until it starts below `top`, or left where it is. */
const below = (box: BookFrame, top: number): BookFrame => (box.y >= top ? box : { ...box, y: top });
export function riso(k: CoverKit): CoverDraft {
const { c, W, H, m } = k;
const draft: CoverDraft = { elements: [], groups: [] };
const ground = c.look.paper;
const first = standOut(c, ground);
const second = standOut(c, ground, [first]);
const titleZone = frameOf(m, m, W - m * 2, H * 0.3);
const titleFoot = titleZone.y + titleZone.h + mmOn(k, 12);
const photo = k.photos[0];
const pw = W * k.rng.range(0.5, 0.56);
const ph = Math.min(H * 0.46, H - m - titleFoot);
const at = frameOf(W - m - pw - k.rng.range(0, 0.04) * W, H - m - ph, pw, ph);
// The two inks of the discs, the second laid over the first a few millimetres off.
const d = Math.min(W, H) * k.rng.range(0.5, 0.6);
const miss = mmOn(k, k.rng.range(3, 5));
const disc = clearOfTrim(k, below(frameOf(at.x - d * 0.45, at.y + at.h * 0.35 - d / 2, d, d), titleFoot));
const solid = shapeEl(k, 'ellipse', disc, second);
const dots = shapeEl(k, 'ellipse', clearOfTrim(k, frameOf(disc.x + miss, disc.y - miss, d, d)), first);
const scale = Math.round(k.rng.range(0.35, 0.5) * 100) / 100;
draft.elements.push(
{ ...solid, opacity: 0.85 },
{ ...dots, paint: { type: 'pattern', pattern: 'dots', fg: first, bg: null, scale, angle: 45 } },
);
let photoBox: BookFrame | null = null;
if (photo) {
const turn = keptTurn(k, k.rng.sign() * k.rng.range(1.5, 3));
const print = printEl(k, photo, at, { turn });
const shift = mmOn(k, k.rng.range(2.5, 4));
const offset = frameOf(print.frame.x - shift, print.frame.y + shift, print.frame.w, print.frame.h);
const block = shapeEl(k, 'rect', offset, first, print.rotation);
draft.elements.push(block, { ...print, duotone: tonesOf(c.look.ink, c.look.paper) });
photoBox = turnedBox(offset, print.rotation);
}
const ink = inkOn(c, ground);
const head: BookElement[] = [];
if (!addLockup(k, draft, { box: titleZone, ground }).length) {
if (k.title.words.trim()) {
const title = titleText(k, titleZone, {
max: mmToPt(titleZone.h / 2 / 0.95),
color: ink,
transform: 'upper',
leading: 0.92,
weight: 800,
align: 'left',
});
const dx = mmOn(k, 1.6);
head.push({ ...title, shadow: { dx, dy: dx, color: first, alpha: 1 } });
}
const sub = k.sub.words.trim();
if (sub) {
const line = spacedLine(k, sub, frameOf(m, titleZone.y + titleZone.h + mmOn(k, 3), W * 0.5, mmOn(k, 7)), ink, 10);
const bound = k.sub.bound ? { ...line, binding: { source: 'journey.subtitle' as const } } : line;
head.push(onBand(k, { ...bound, align: 'left' }, ground));
}
draft.elements.push(...head);
if (head.length > 1) draft.groups.push(head.map((t) => t.id));
}
const year = k.words.year ?? '';
// The year on a band of the second ink at the foot, left of the photo and its block, deepened
// where a mid-tone ink would not carry a small line.
const room = (photoBox ? photoBox.x : W - m) - m - mmOn(k, 4);
if (year && room > mmOn(k, 18)) {
const frame = frameOf(m, H - m - mmOn(k, 7), Math.min(room, mmOn(k, 40)), mmOn(k, 7));
draft.elements.push(onBand(k, { ...spacedLine(k, year, frame, ink, 9), align: 'left' }, deepened(second)));
}
const count = (c.art ?? CALM_ART).bare ? 0 : k.rng.int(2, 4);
draft.elements.push(
...placeStickers(c, draft.elements, {
count,
size: [0.1, 0.16],
sides: ['single'],
key: 'cover:riso',
avoid: [titleZone],
used: c.art?.used,
}),
);
return draft;
}
@@ -0,0 +1,96 @@
import { deltaE } from '../book-colour';
import type { BookElement } from '../book.schema';
import { mixHex } from '../theme/book-theme';
import { frameOf } from './design-compose';
import {
addLockup,
inkOn,
mmOn,
mmToPt,
printEl,
shapeEl,
spacedLine,
titleText,
type CoverDraft,
type CoverKit,
} from './design-cover-kit';
/**
* Vintage poster: a travel poster of the 1950s. A thin rule framing the
* page, and inside it a scenic panel of flat colour: a sky, a sun with its
* rays at the horizon, two ranges of mountains and a band of sea, the cover
* photo standing in an arch before the sun with a white cut edge, and under
* the panel the title stacked in the display face with the month at the foot.
*/
const WHITE = '#ffffff';
/** A colour of the palette that stands apart from another (delta E over 18), else the fallback. */
const apart = (want: string, from: string, fallback: string) => (deltaE(want, from) > 18 ? want : fallback);
export function vintagePoster(k: CoverKit): CoverDraft {
const { c, W, H, m } = k;
const p = c.look.theme.palette;
const draft: CoverDraft = { elements: [], groups: [] };
const ground = c.look.paper;
const ink = inkOn(c, ground);
const sun = c.look.accent;
const sky = apart(mixHex(p.accent3, ground, 0.3), sun, mixHex(p.accent2, ground, 0.45));
const near = apart(p.accent2, sky, mixHex(ink, sky, 0.35));
const far = mixHex(near, sky, 0.45);
const sea = mixHex(near, ink, 0.25);
const hair = Math.max(0.3, mmOn(k, 0.6));
const edge = mmOn(k, 3.5);
const border = { ...shapeEl(k, 'rect', frameOf(edge, edge, W - edge * 2, H - edge * 2), ink), fill: null };
draft.elements.push({ ...border, stroke: ink, strokeWidth: hair });
const inset = mmOn(k, 7);
const panel = frameOf(inset, inset, W - inset * 2, H * k.rng.range(0.6, 0.64) - inset);
const horizon = panel.y + panel.h * k.rng.range(0.5, 0.56);
const rays = Math.min(panel.w, panel.h) * 0.92;
const ry = Math.min(Math.max(horizon - rays / 2, panel.y), panel.y + panel.h - rays);
const disc = rays * 0.6;
draft.elements.push(
shapeEl(k, 'rect', panel, sky),
{ ...shapeEl(k, 'sunburst-24', frameOf(W / 2 - rays / 2, ry, rays, rays), mixHex(sun, sky, 0.5)), opacity: 0.7 },
shapeEl(k, 'ellipse', frameOf(W / 2 - disc / 2, ry + (rays - disc) / 2, disc, disc), sun),
);
// The ranges from the horizon down to the panel's foot, the far one paler, the sea in front of them.
const foot = panel.y + panel.h;
const farW = panel.w * k.rng.range(0.6, 0.7);
const nearW = panel.w * k.rng.range(0.62, 0.72);
const left = k.rng.chance(0.5);
const seaH = panel.h * 0.12;
const farX = left ? panel.x : panel.x + panel.w - farW;
const nearX = left ? panel.x + panel.w - nearW : panel.x;
const nearY = horizon + (foot - horizon) * 0.25;
draft.elements.push(
shapeEl(k, 'mountain', frameOf(farX, horizon, farW, foot - horizon), far),
shapeEl(k, 'mountain', frameOf(nearX, nearY, nearW, foot - nearY), near),
shapeEl(k, 'rect', frameOf(panel.x, foot - seaH, panel.w, seaH), sea),
);
const photo = k.photos[0];
if (photo) {
const ph = panel.h * 0.74;
const pw = Math.min(ph * 0.58, panel.w * 0.42);
const arch = printEl(k, photo, frameOf(W / 2 - pw / 2, foot - ph - mmOn(k, 3), pw, ph), {
mask: 'arch-tall',
cutout: { width: mmOn(k, 2), color: WHITE },
});
draft.elements.push(arch);
}
// Under the panel: the title stacked in the display face, the month or the year at the foot.
const line = k.words.dates || k.words.year || '';
const lineH = line ? mmOn(k, 7) : 0;
const top = foot + mmOn(k, 5);
const box = frameOf(m, top, W - m * 2, H - m - top - lineH - mmOn(k, 2));
const words: BookElement[] = [];
if (!addLockup(k, draft, { box: frameOf(box.x, box.y, box.w, H - m - top), ground }).length) {
if (k.title.words.trim()) {
words.push(titleText(k, box, { max: mmToPt(box.h * 0.8), color: ink, transform: 'upper', leading: 0.92 }));
}
if (line) words.push(spacedLine(k, line, frameOf(m, H - m - lineH, W - m * 2, lineH), ink, 9));
draft.elements.push(...words);
if (words.length > 1) draft.groups.push(words.map((e) => e.id));
}
return draft;
}
+34 -1
View File
@@ -1,4 +1,5 @@
import { BOOK_COVER_ARCHETYPES, type BookCoverArchetype } from '../book-archetypes';
import { luminance, parseHex } from '../book-colour';
import { bookSpreadSchema, type BookElement, type BookSpread } from '../book.schema';
import { pageFindings } from '../describe-spread';
import { BOOK_STICKERS } from '../stickers/sticker-catalogue';
@@ -11,7 +12,7 @@ import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
/**
* The seven composed covers (design-cover.ts) on the fixture journeys: each
* The twelve 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
@@ -73,6 +74,38 @@ const SIGNATURE: Record<BookCoverArchetype, (s: BookSpread, W: number, H: number
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),
riso: (s) =>
shapes(s).some((e) => e.paint?.type === 'pattern' && e.paint.pattern === 'dots') &&
texts(s).some((t) => t.binding?.source === 'journey.title' && !!t.shadow) &&
photos(s).some(
(p) =>
!!p.duotone &&
shapes(s).some(
(e) =>
e.shape === 'rect' &&
Math.abs(e.frame.w - p.frame.w) < 0.5 &&
Math.abs(e.frame.h - p.frame.h) < 0.5 &&
(e.frame.x !== p.frame.x || e.frame.y !== p.frame.y),
),
),
editorial: (s, W, H) =>
shapes(s).some((e) => e.shape === 'rect' && e.frame.h <= 1 && e.frame.w >= W * 0.6) &&
texts(s).some((t) => t.binding?.source === 'journey.title' && t.frame.y + t.frame.h <= H * 0.32) &&
photos(s).some((p) => p.frame.w >= W * 0.6),
'vintage-poster': (s, W) =>
shapes(s).filter((e) => e.shape === 'mountain').length >= 2 &&
shapes(s).some((e) => e.shape === 'sunburst-24') &&
shapes(s).some((e) => e.fill === null && !!e.stroke && e.frame.w >= W * 0.8) &&
photos(s).every((p) => p.mask === 'arch-tall'),
'night-film': (s, W) =>
luminance(parseHex(s.background ?? '#ffffff') ?? { r: 255, g: 255, b: 255 }) < 0.05 &&
kinds(s, 'line').filter((e) => e.kind === 'line' && e.dash === 'dashed').length >= 2 &&
shapes(s).some((e) => e.shape === 'rect' && e.frame.w >= W),
'field-notes': (s) =>
shapes(s).some((e) => e.paint?.type === 'pattern' && e.paint.pattern === 'grid') &&
shapes(s).some((e) => e.shape.startsWith('leaf')) &&
photos(s).length > 0 &&
photos(s).every((p) => !!p.border),
};
/** Every word on a cover lies on the page, turned with its frame; every other element at least touches it. */
+11 -1
View File
@@ -4,6 +4,8 @@ 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 { editorial } from './design-cover-editorial';
import { fieldNotes } from './design-cover-field-notes';
import { postcard, posterPop } from './design-cover-graphic';
import {
addLockup,
@@ -13,12 +15,15 @@ import {
type CoverKit,
type CoverMaterial,
} from './design-cover-kit';
import { nightFilm } from './design-cover-night-film';
import { cutout, minimalSerif, spineBand } from './design-cover-photo';
import { riso } from './design-cover-riso';
import { vintagePoster } from './design-cover-vintage-poster';
import { titleWords, type CoverWords } from './design-cover-words';
import { paperBackground } from './design-style';
/**
* The composed covers: `cover.archetype` picks one of seven composers, each
* The composed covers: `cover.archetype` picks one of twelve 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
@@ -33,6 +38,11 @@ const COMPOSERS: Readonly<Record<BookCoverArchetype, (k: CoverKit) => CoverDraft
'grid-badge': gridBadge,
'minimal-serif': minimalSerif,
'stamp-collage': stampCollage,
riso,
editorial,
'vintage-poster': vintagePoster,
'night-film': nightFilm,
'field-notes': fieldNotes,
};
/** A draft as the cover page: on its ground, each lockup one group. */
@@ -158,6 +158,17 @@ export const bookDesignDirectivesSchema = z.strictObject({
/** Only honoured for a blank or a new book. */
preset: bookPagePresetSchema.optional(),
seed: z.number().int().min(0).max(2147483647).optional(),
/**
* What the pages are laid on: the engine's own composed pages (the default),
* or the Studio's designed templates in the archetype of the book's theme,
* with the summary, countries and marks in the newer travel looks.
*/
layouts: z
.enum(['composed', 'templates'])
.describe(
"composed: the engine's own pages (default). templates: every page laid on the Studio's designed templates in the archetype of the book's theme.",
)
.optional(),
});
export type BookDesignDirectives = z.infer<typeof bookDesignDirectivesSchema>;
export type BookDesignDirectivesInput = z.input<typeof bookDesignDirectivesSchema>;
+7
View File
@@ -23,6 +23,8 @@ export interface BookDesignPlanRow {
entryId: number | null;
photoIds: number[];
findings: { severe: number; warn: number };
/** The decorated template the page was laid on, when the design laid it on one. */
template?: string;
}
export interface DesignedSpread {
@@ -30,6 +32,8 @@ export interface DesignedSpread {
beat: BookDesignBeat;
chapter: string | null;
findings: BookFinding[];
/** The decorated template the page was laid on (`layouts: 'templates'`). */
template?: string;
}
const photoIdsOf = (spread: BookSpread) =>
@@ -55,6 +59,7 @@ interface Placed {
beat: BookDesignBeat;
chapter: string | null;
findings: BookFinding[];
template?: string;
}
const placedOf = (d: DesignedSpread, action: BookDesignAction, id?: string): Placed => ({
@@ -63,6 +68,7 @@ const placedOf = (d: DesignedSpread, action: BookDesignAction, id?: string): Pla
beat: d.beat,
chapter: d.chapter,
findings: d.findings,
...(d.template ? { template: d.template } : {}),
});
const keptOf = (spread: BookSpread, beat: BookDesignBeat): Placed => ({
@@ -133,6 +139,7 @@ export function mergeDesign(
entryId: p.spread.entryId,
photoIds: photoIdsOf(p.spread),
findings: { severe: count(p.findings, 'severe'), warn: count(p.findings, 'warn') },
...(p.template ? { template: p.template } : {}),
}));
for (const s of dropped) {
plan.push({
+60
View File
@@ -190,3 +190,63 @@ export function burstOf(photos: readonly AutoPhoto[], candidates: readonly Photo
});
return best;
}
/** The most photos one entry's pages take on the designed templates; the rest are left out as `chapter_max`. */
export const TEMPLATED_ENTRY_PHOTOS = 12;
/** The day a photo was taken on, from the photo or from the picker's facts of it. */
function dayOf(photo: AutoPhoto, candidates: readonly PhotoPickCandidate[]): string | null {
const at = photo.takenAt ?? candidates.find((c) => c.photoId === photo.photoId)?.takenAt ?? null;
return at ? at.slice(0, 10) : null;
}
/** Days between two ISO dates, for the nearest entry to a photo; far for an undated one. */
function daysApart(a: string | null, b: string | null): number {
if (!a || !b) return 1e6;
return Math.abs(Date.parse(a) - Date.parse(b)) / 86_400_000;
}
/**
* The journey's loose photos shared out to its entries, for a book laid on
* the designed templates: each first to the entry it is linked to, then to
* the entry of the day it was taken, then, in the order they were taken, to
* the entry that has the fewest photos (the nearest in time of those), never
* past `cap` photos an entry. What no entry has room for comes back as
* `rest`, for the book's closing pages of more moments. Unlike
* `distributeGallery` no photo is dropped on the way.
*/
export function shareGalleryByDay(
entries: readonly AutoEntry[],
gallery: readonly AutoPhoto[],
candidates: readonly PhotoPickCandidate[],
cap = TEMPLATED_ENTRY_PHOTOS,
): { entries: AutoEntry[]; rest: AutoPhoto[] } {
const out = entries.map((e) => ({ ...e, photos: [...e.photos] }));
const placed = new Set(out.flatMap((e) => e.photos.map((p) => p.photoId)));
const loose = gallery.filter((p) => !placed.has(p.photoId));
const room = (e: AutoEntry) => e.photos.length < cap;
const later: AutoPhoto[] = [];
for (const photo of loose) {
const linked = candidates.find((c) => c.photoId === photo.photoId)?.entryIds ?? [];
const day = dayOf(photo, candidates);
const home =
out.find((e) => linked.includes(e.id) && room(e)) ?? out.find((e) => day !== null && e.date === day && room(e));
if (home) home.photos.push(photo);
else later.push(photo);
}
const dated = [...later].sort((a, b) =>
(dayOf(a, candidates) ?? '9999').localeCompare(dayOf(b, candidates) ?? '9999'),
);
const rest: AutoPhoto[] = [];
for (const photo of dated) {
const day = dayOf(photo, candidates);
const open = out.filter(room);
const fewest = Math.min(...open.map((e) => e.photos.length));
const home = open
.filter((e) => e.photos.length === fewest)
.sort((a, b) => daysApart(a.date, day) - daysApart(b.date, day))[0];
if (home) home.photos.push(photo);
else rest.push(photo);
}
return { entries: out, rest };
}
@@ -0,0 +1,247 @@
import type { AutoPhoto } from '../auto/auto-types';
import { BOOK_COVER_ARCHETYPES } from '../book-archetypes';
import { bookPageSetupForPreset } from '../book-pages';
import { decoratedTemplatesFor, findDecoratedTemplate } from '../templates/template-catalogue';
import { templatePhotoSlots, templateRoles, type BookDecoratedTemplate } from '../templates/template-format';
import { bookThemeOf } from '../theme/book-theme';
import { BOOK_THEME_IDS } from '../theme/book-theme.schema';
import {
ARCHETYPE_KIN,
THEME_ARCHETYPE,
archetypeOfTheme,
bodyCapacity,
chunkPhotos,
chunkSizesOf,
innerPool,
pickCoverTemplate,
pickInnerTemplate,
templateEnergy,
type TemplateChunk,
type TemplatePickCtx,
} from './design-template-pick';
import { describe, expect, it } from 'vitest';
/** Picking a decorated spread for a page's photos and words. */
const page = bookPageSetupForPreset('square-210');
const wide = (photoId: number): AutoPhoto => ({ photoId, width: 4000, height: 3000 });
const tall = (photoId: number): AutoPhoto => ({ photoId, width: 3000, height: 4000 });
const photos = (n: number, at = 1) => Array.from({ length: n }, (_, i) => (i % 3 === 2 ? tall(at + i) : wide(at + i)));
const STORY = 'We walked up from the harbour in the morning and found the old town still asleep. ';
const ctxOf = (over: Partial<TemplatePickCtx> = {}): TemplatePickCtx => ({
archetype: 'spine-band',
page,
theme: bookThemeOf('fabric-classic'),
history: [],
calm: false,
seed: 7,
key: 'k',
...over,
});
const chunk = (n: number, story = '', mappable = false): TemplateChunk => ({ photos: photos(n), story, mappable });
const roles = (t: BookDecoratedTemplate) => templateRoles(t) as readonly string[];
/** A book of pages picked one after the other, as the auto layout picks them. */
function book(chunks: readonly TemplateChunk[], over: Partial<TemplatePickCtx> = {}): BookDecoratedTemplate[] {
const out: BookDecoratedTemplate[] = [];
chunks.forEach((c, i) => {
const pick = pickInnerTemplate(c, ctxOf({ ...over, history: out.map((t) => t.id), key: `e${i}` }));
if (pick) out.push(pick);
});
return out;
}
const varied = Array.from({ length: 20 }, (_, i) =>
chunk(i % 9, i % 3 === 0 ? STORY.repeat(1 + (i % 4)) : '', i % 5 === 0),
);
describe('the template picker', () => {
it('AUTOTPL-001: never picks a template that would leave a photo frame empty', () => {
for (const archetype of BOOK_COVER_ARCHETYPES) {
for (let n = 0; n <= 9; n++) {
for (const story of ['', STORY]) {
const pick = pickInnerTemplate(chunk(n, story), ctxOf({ archetype }));
if (pick) expect(templatePhotoSlots(pick), `${archetype} ${n}`).toBeLessThanOrEqual(n);
}
}
}
});
it('AUTOTPL-002: a story always lands on a template with a story frame', () => {
for (const archetype of BOOK_COVER_ARCHETYPES) {
for (let n = 0; n <= 8; n++) {
const pick = pickInnerTemplate(chunk(n, STORY), ctxOf({ archetype }));
expect(pick, `${archetype} ${n}`).toBeDefined();
expect(roles(pick as BookDecoratedTemplate)).toContain('body');
}
}
});
it('AUTOTPL-003: a map template only where there is a place to draw', () => {
for (const archetype of BOOK_COVER_ARCHETYPES) {
for (let n = 1; n <= 8; n++) {
const pick = pickInnerTemplate(chunk(n), ctxOf({ archetype }));
if (pick) expect(roles(pick), `${archetype} ${n}`).not.toContain('map');
}
}
const mapped = [1, 2, 3, 4, 5].map((n) =>
pickInnerTemplate(chunk(n, '', true), ctxOf({ archetype: 'field-notes' })),
);
expect(mapped.every((t) => t !== undefined)).toBe(true);
});
it('AUTOTPL-004: never the same template twice running, never loud after busy, unless only that keeps every photo framed in the style', () => {
const busy = (t: BookDecoratedTemplate) => ['loud', 'lively'].includes(templateEnergy(t));
for (const archetype of BOOK_COVER_ARCHETYPES) {
const picks = book(varied, { archetype, theme: null });
expect(picks.length, archetype).toBe(varied.length);
picks.forEach((t, i) => {
const before = picks[i - 1];
const page = varied[i];
if (!before || !page) return;
const keeps = (x: BookDecoratedTemplate) =>
x.id !== before.id && !(templateEnergy(x) === 'loud' && busy(before));
if (keeps(t)) return;
// The rhythm gives way only where no template of the book's styles frames every photo and keeps it.
const fits = (x: BookDecoratedTemplate) =>
templatePhotoSlots(x) === page.photos.length &&
(!page.story || roles(x).includes('body')) &&
(!roles(x).includes('map') || page.mappable);
expect(
innerPool(archetype)
.filter((x) => fits(x) && keeps(x))
.map((x) => x.id),
`${archetype} at ${i}`,
).toEqual([]);
});
}
});
it('AUTOTPL-005: twenty varied entries are laid on at least eight different templates', () => {
for (const archetype of BOOK_COVER_ARCHETYPES) {
const picks = book(varied, { archetype });
expect(new Set(picks.map((t) => t.id)).size, archetype).toBeGreaterThanOrEqual(8);
}
});
it("AUTOTPL-006: the theme's own archetype wins over a kin one that fits as well", () => {
for (const archetype of BOOK_COVER_ARCHETYPES) {
for (const tpl of decoratedTemplatesFor('inner', archetype)) {
if (roles(tpl).includes('map')) continue;
const n = templatePhotoSlots(tpl);
const pick = pickInnerTemplate(
{ photos: photos(n), story: '', mappable: true },
ctxOf({ archetype, theme: null }),
);
expect(pick?.archetype, `${archetype} ${n}`).toBe(archetype);
}
}
});
it('AUTOTPL-007: the same seed picks the same templates, another seed others', () => {
const one = book(varied, { seed: 1 }).map((t) => t.id);
expect(book(varied, { seed: 1 }).map((t) => t.id)).toEqual(one);
const others = [2, 3, 4, 5, 6].map((seed) =>
book(varied, { seed })
.map((t) => t.id)
.join(),
);
expect(others.some((o) => o !== one.join())).toBe(true);
});
it('AUTOTPL-008: every theme has an archetype, and every archetype kin of its own kind', () => {
for (const id of BOOK_THEME_IDS) expect(BOOK_COVER_ARCHETYPES).toContain(THEME_ARCHETYPE[id]);
expect(THEME_ARCHETYPE).toMatchObject({
riso: 'riso',
magazine: 'editorial',
'retro-travel': 'vintage-poster',
midnight: 'night-film',
'field-notes': 'field-notes',
});
for (const a of BOOK_COVER_ARCHETYPES) {
expect(ARCHETYPE_KIN[a].length).toBeGreaterThan(0);
expect(ARCHETYPE_KIN[a]).not.toContain(a);
}
expect(ARCHETYPE_KIN['night-film']).toEqual(['editorial', 'minimal-serif']);
});
it('AUTOTPL-009: a story frame holds more the larger it is drawn', () => {
const base = findDecoratedTemplate('story-quote') as BookDecoratedTemplate;
const grown = (k: number): BookDecoratedTemplate => ({
...base,
id: `story-quote-x${k}`,
elements: base.elements.map((e) => (e.role === 'body' ? { ...e, frame: { ...e.frame, h: e.frame.h * k } } : e)),
});
const sizes = [0.5, 1, 1.5, 2].map((k) => bodyCapacity(grown(k), page));
for (let i = 1; i < sizes.length; i++) expect(sizes[i] as number).toBeGreaterThanOrEqual(sizes[i - 1] as number);
expect(sizes[3] as number).toBeGreaterThan(sizes[0] as number);
expect(bodyCapacity(findDecoratedTemplate('grid-tight') as BookDecoratedTemplate, page)).toBe(0);
});
it('AUTOTPL-010: photos are cut into pages that leave no frame empty and park as few as there can be', () => {
const sizes = { any: new Set([0, 1, 2, 3, 4, 5, 6, 7, 8]), story: new Set([0, 1, 2, 3, 4, 6]) };
for (let n = 1; n <= 20; n++) {
for (const story of [false, true]) {
const cut = chunkPhotos(n, story, sizes);
expect(cut.sizes.reduce((a, b) => a + b, 0) + cut.parked).toBe(n);
expect(cut.parked, `${n}`).toBe(0);
if (story) expect(sizes.story.has(cut.sizes[0] as number)).toBe(true);
}
}
expect(chunkPhotos(12, false, sizes).sizes).toEqual([6, 6]);
expect(chunkPhotos(5, true, sizes).sizes).toEqual([2, 3]);
// A story too long for a small page's frame goes on a page of words of its own.
expect(chunkPhotos(2, true, { ...sizes, pageCost: (s, first) => (first && s > 0 ? 30 : 0) }).sizes).toEqual([0, 2]);
expect(chunkPhotos(3, false, { any: new Set([2]), story: new Set() })).toEqual({ sizes: [2], parked: 1 });
});
it('AUTOTPL-011: a cover template never leaves a frame empty, and with no photo only one drawn for none', () => {
for (const archetype of BOOK_COVER_ARCHETYPES) {
for (let n = 0; n <= 5; n++) {
const pick = pickCoverTemplate({ archetype, photos: n, portrait: false, seed: 3 });
if (pick) expect(templatePhotoSlots(pick)).toBeLessThanOrEqual(n);
if (n >= 4) expect(pick, `${archetype} ${n}`).toBeDefined();
}
}
expect(pickCoverTemplate({ archetype: 'postcard', photos: 0, portrait: false, seed: 1 })?.id).toBe(
'postcard-coast',
);
expect(pickCoverTemplate({ archetype: 'riso', photos: 0, portrait: false, seed: 1 })).toBeUndefined();
expect(pickCoverTemplate({ archetype: 'minimal-serif', photos: 1, portrait: true, seed: 1 })?.id).toBe(
'minimal-serif-portrait',
);
});
it("AUTOTPL-012: a theme of the book's own takes the archetype of the theme it still sets the faces of", () => {
expect(archetypeOfTheme({ ...bookThemeOf('midnight'), id: null })).toBe('night-film');
const odd = bookThemeOf('riso');
const own = { ...odd, id: null, fonts: { ...odd.fonts, display: { font: 'bodoni' }, heading: { font: 'bodoni' } } };
expect(archetypeOfTheme(own)).toBe('spine-band');
expect(archetypeOfTheme(null)).toBe('spine-band');
expect(innerPool('riso').every((t) => ['riso', 'poster-pop', 'cutout'].includes(t.archetype))).toBe(true);
expect([...chunkSizesOf(innerPool('riso')).story]).toContain(0);
});
it("AUTOTPL-013: a page the book's own styles can frame every photo of stays in them, whatever the rhythm", () => {
for (const archetype of BOOK_COVER_ARCHETYPES) {
const kin = [archetype, ...ARCHETYPE_KIN[archetype]];
for (let n = 0; n <= 12; n++) {
for (const story of ['', STORY]) {
const frames = (t: BookDecoratedTemplate) =>
templatePhotoSlots(t) === n && (!story || roles(t).includes('body')) && !roles(t).includes('map');
if (!innerPool(archetype).some(frames)) continue;
const pick = pickInnerTemplate(chunk(n, story), ctxOf({ archetype, theme: null }));
expect(pick && templatePhotoSlots(pick), `${archetype} ${n}`).toBe(n);
expect(kin, `${archetype} ${n} ${story ? 'story' : ''}`).toContain(pick?.archetype);
// The page before on its own is given up rather than a frame or the style.
const again = pickInnerTemplate(chunk(n, story), ctxOf({ archetype, theme: null, history: [pick!.id] }));
expect(again && templatePhotoSlots(again), `${archetype} ${n} again`).toBe(n);
expect(kin).toContain(again?.archetype);
}
}
}
});
});
@@ -0,0 +1,403 @@
import { isPortrait } from '../auto/auto-elements';
import type { AutoPhoto } from '../auto/auto-types';
import type { BookCoverArchetype } from '../book-archetypes';
import { DESIGN_TEXT_HEADROOM, textHeightNeeded } from '../book-findings';
import type { BookPageSetup, BookTextElement } from '../book.schema';
import { layDecoratedTemplate } from '../templates/template-apply';
import { decoratedTemplatesFor } from '../templates/template-catalogue';
import { templatePhotoSlots, templateRoles, type BookDecoratedTemplate } from '../templates/template-format';
import { themedElement } from '../theme/book-theme-apply';
import type { BookTheme, BookThemeId } from '../theme/book-theme.schema';
import { BOOK_THEME_PRESETS } from '../theme/book-themes.data';
import { isLandscape } from './design-photos';
import { designRng } from './design-random';
/**
* Which decorated template a page of the auto layout is laid on.
*
* A book is in a theme, and a theme belongs to one of the twelve archetypes
* the templates are drawn in (`THEME_ARCHETYPE`). Its pages are picked from
* that archetype's templates and from those of the archetypes that sit well
* beside it (`ARCHETYPE_KIN`), by what a page holds: how many photos, whether
* a story has to fit, whether the stop can draw a map. Templates that would
* leave an empty frame, lose the story or repeat the page before are never
* candidates; of the rest the best is taken, the theme's own first, its
* story fitting its body, its first frame the shape of the first photo, not
* one used a few pages back, and calm where the book should breathe. Equal
* ones are chosen by the design's seed, so the same journey and seed give the
* same book and another seed another.
*/
/** The archetype each built-in theme's pages are drawn in. */
export const THEME_ARCHETYPE: Readonly<Record<BookThemeId, BookCoverArchetype>> = {
'poster-pop': 'poster-pop',
tropical: 'poster-pop',
cutout: 'cutout',
postcard: 'postcard',
riviera: 'postcard',
'fabric-classic': 'spine-band',
alpine: 'spine-band',
'minimal-serif': 'minimal-serif',
nordic: 'minimal-serif',
'grid-badge': 'grid-badge',
'scrapbook-script': 'stamp-collage',
'india-stamp': 'stamp-collage',
riso: 'riso',
magazine: 'editorial',
'retro-travel': 'vintage-poster',
midnight: 'night-film',
'field-notes': 'field-notes',
};
/** The archetypes whose spreads sit well in a book of another, nearest first. */
export const ARCHETYPE_KIN: Readonly<Record<BookCoverArchetype, readonly BookCoverArchetype[]>> = {
'spine-band': ['minimal-serif', 'postcard'],
'poster-pop': ['cutout', 'riso', 'stamp-collage'],
cutout: ['poster-pop', 'riso'],
postcard: ['vintage-poster', 'spine-band'],
'grid-badge': ['minimal-serif', 'stamp-collage'],
'minimal-serif': ['editorial', 'spine-band'],
'stamp-collage': ['field-notes', 'postcard'],
riso: ['poster-pop', 'cutout'],
editorial: ['minimal-serif', 'grid-badge'],
'vintage-poster': ['postcard', 'stamp-collage'],
'night-film': ['editorial', 'minimal-serif'],
'field-notes': ['stamp-collage', 'spine-band'],
};
/** The archetype a book without a recognisable theme takes. */
const FALLBACK_ARCHETYPE: BookCoverArchetype = 'spine-band';
/**
* The archetype of a book's theme: a built-in theme's own; a theme of the
* book's own (no id) that of the built-in one whose faces it still sets,
* else the calm bookish one.
*/
export function archetypeOfTheme(theme: BookTheme | null | undefined): BookCoverArchetype {
if (!theme) return FALLBACK_ARCHETYPE;
if (theme.id) return THEME_ARCHETYPE[theme.id];
const kin = BOOK_THEME_PRESETS.find(
(p) => p.fonts.display.font === theme.fonts.display.font && p.fonts.heading.font === theme.fonts.heading.font,
);
return kin ? THEME_ARCHETYPE[kin.id] : FALLBACK_ARCHETYPE;
}
/** How loud a template is, from its tags: a loud poster, a lively collage, a graphic grid, a calm page. */
type TemplateEnergy = 'loud' | 'lively' | 'medium' | 'calm';
/** A template's energy. Graphic pages, and the few drawn without a word for it, count as medium. */
export function templateEnergy(tpl: Pick<BookDecoratedTemplate, 'tags'>): TemplateEnergy {
if (tpl.tags.includes('loud')) return 'loud';
if (tpl.tags.includes('lively')) return 'lively';
if (tpl.tags.includes('calm') && !tpl.tags.includes('graphic')) return 'calm';
return 'medium';
}
const isLoud = (e: TemplateEnergy | undefined) => e === 'loud';
const isBusy = (e: TemplateEnergy | undefined) => e === 'loud' || e === 'lively';
/** A page format and theme as a cache key: what a laid template's measures depend on. */
const formatKey = (page: BookPageSetup, theme: BookTheme | null | undefined) =>
`${page.pageWidth}x${page.pageHeight}|${theme?.fonts.body.font ?? '-'}|${theme?.fonts.body.weight ?? '-'}`;
/** Words of an ordinary length, to measure a body frame by. */
const FILLER =
'We left early and walked along the water while the town woke up, stopped for coffee, ' +
'and found the road that runs up the hill to the old walls and the view over the bay. ';
const laidBodies = new Map<string, BookTextElement | null>();
/** The body text of a template as it is laid on this format in this theme, empty; null without one. */
function laidBody(tpl: BookDecoratedTemplate, page: BookPageSetup, theme: BookTheme | null | undefined) {
const key = `${tpl.id}|${formatKey(page, theme)}`;
if (laidBodies.has(key)) return laidBodies.get(key) ?? null;
let n = 0;
const laid = layDecoratedTemplate(tpl, page, theme ?? null, (p) => `${p}${++n}`);
const found = laid.find((l) => l.role === 'body' && l.el.kind === 'text')?.el;
const body = found?.kind === 'text' ? (theme ? themedElement(found, theme) : found) : null;
laidBodies.set(key, body);
return body;
}
/** Whether these words fit a laid body frame as it is set. */
function fitsBody(body: BookTextElement, words: string): boolean {
return textHeightNeeded({ ...body, text: words }, DESIGN_TEXT_HEADROOM) <= body.frame.h;
}
const capacities = new Map<string, number>();
/**
* How many characters of ordinary running text a template's story frame
* holds on this page format in this theme's body face: 0 for a template
* without one. Measured once per template, format and face.
*/
export function bodyCapacity(tpl: BookDecoratedTemplate, page: BookPageSetup, theme?: BookTheme | null): number {
const key = `${tpl.id}|${formatKey(page, theme)}`;
const known = capacities.get(key);
if (known !== undefined) return known;
const body = laidBody(tpl, page, theme);
let lo = 0;
if (body) {
let hi = 4000;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (fitsBody(body, FILLER.repeat(Math.ceil(mid / FILLER.length)).slice(0, mid))) lo = mid;
else hi = mid - 1;
}
}
capacities.set(key, lo);
return lo;
}
/** The parts a page of photos is cut into: how many photos each takes, the first one carrying the words. */
interface PhotoChunking {
sizes: number[];
/** Photos that find no frame: noted and parked, never lost. */
parked: number;
}
/** What a chunking may use: the photo counts of the templates there are, those with a story frame apart. */
export interface ChunkSizes {
any: ReadonlySet<number>;
story: ReadonlySet<number>;
/**
* What a page of this many photos costs beyond being a page: its story cut
* short when it is the first, a template from outside the book's own pool.
*/
pageCost?: (size: number, first: boolean) => number;
}
/** A spread costs this much, and every photo left without a frame more. */
const SPREAD_COST = 10;
const PARKED_COST = 15;
/** The most spreads one entry's photos are cut into. */
const MOST_CHUNKS = 3;
/**
* An entry's photos cut into page-sized parts that leave no frame empty,
* as few pages and as few photos without a frame as there can be: every
* spread costs 10, every photo left over 15, and the first part, which
* carries the story, is one a story template takes. Of two equal cuts the
* more even one is kept, so twelve photos make six and six rather than ten
* and two.
*/
export function chunkPhotos(n: number, hasStory: boolean, sizes: ChunkSizes): PhotoChunking {
type Plan = { sizes: number[]; cost: number; parked: number };
const firsts = [...(hasStory ? sizes.story : sizes.any)].filter((s) => s <= n);
const rest = [...sizes.any].filter((s) => s > 0);
let best: Plan = { sizes: [], cost: Infinity, parked: n };
const better = (a: Plan, b: Plan) =>
a.cost < b.cost ||
(a.cost === b.cost && Math.max(...a.sizes) - Math.min(...a.sizes) < Math.max(...b.sizes) - Math.min(...b.sizes));
const walk = (taken: number[], left: number, cost: number) => {
const plan: Plan = { sizes: taken, cost: cost + left * PARKED_COST, parked: left };
if (taken.length && (!best.sizes.length || better(plan, best))) best = plan;
if (taken.length >= MOST_CHUNKS) return;
const options = taken.length ? rest : firsts;
for (const s of options) {
if (s > left || (taken.length && s === 0)) continue;
const extra = sizes.pageCost?.(s, !taken.length) ?? 0;
walk([...taken, s], left - s, cost + SPREAD_COST + extra);
}
};
walk([], n, 0);
return best.sizes.length ? { sizes: best.sizes, parked: best.parked } : { sizes: [], parked: n };
}
/** What a page to be laid holds, as the picker reads it. */
export interface TemplateChunk {
photos: readonly AutoPhoto[];
/** The words a story frame must take; empty when the page sets none. */
story: string;
/** The stop or the journey has a place a map can draw. */
mappable: boolean;
}
/** What the picker knows of the book around the page. */
export interface TemplatePickCtx {
archetype: BookCoverArchetype;
page: BookPageSetup;
theme: BookTheme | null | undefined;
/** Template ids of the spreads laid so far, in order. */
history: readonly string[];
/** The book's energy for template choice: a calm book keeps its busy pages few. */
calm: boolean;
seed: number;
/** Names the decision for the seed: the same page of the same journey picks the same. */
key: string;
/** Ids never to take here (the page's own on "this spread", its neighbours'). */
avoid?: readonly string[];
}
const hasRole = (tpl: BookDecoratedTemplate, role: string) => (templateRoles(tpl) as readonly string[]).includes(role);
const slotsOf = new Map<string, number>();
const slots = (tpl: BookDecoratedTemplate) => {
const known = slotsOf.get(tpl.id);
if (known !== undefined) return known;
const n = templatePhotoSlots(tpl);
slotsOf.set(tpl.id, n);
return n;
};
/** The inner templates of an archetype and of its kin, the own ones first. */
export function innerPool(archetype: BookCoverArchetype): BookDecoratedTemplate[] {
return [archetype, ...ARCHETYPE_KIN[archetype]].flatMap((a) => decoratedTemplatesFor('inner', a));
}
/** The photo counts a pool of templates takes, and those of its templates with a story frame. */
export function chunkSizesOf(pool: readonly BookDecoratedTemplate[]): { any: Set<number>; story: Set<number> } {
return {
any: new Set(pool.map(slots)),
story: new Set(pool.filter((t) => hasRole(t, 'body')).map(slots)),
};
}
/** Whether a template can take this page at all, after the one before it. */
function admits(
tpl: BookDecoratedTemplate,
chunk: TemplateChunk,
ctx: TemplatePickCtx,
before: TemplateEnergy | undefined,
) {
return fitsAtAll(tpl, chunk, ctx) && !keepsApart(tpl, ctx, before);
}
/** Whether a template can hold the page: no frame left empty, the story and the map where it has them. */
function fitsAtAll(tpl: BookDecoratedTemplate, chunk: TemplateChunk, ctx: TemplatePickCtx) {
if (tpl.kind !== 'inner' || slots(tpl) > chunk.photos.length || ctx.avoid?.includes(tpl.id)) return false;
if (chunk.story && !hasRole(tpl, 'body')) return false;
return !hasRole(tpl, 'map') || chunk.mappable;
}
/** The rhythm a book keeps where it can: never the page before again, never loud after loud or lively. */
function keepsApart(tpl: BookDecoratedTemplate, ctx: TemplatePickCtx, before: TemplateEnergy | undefined) {
return ctx.history[ctx.history.length - 1] === tpl.id || (isLoud(templateEnergy(tpl)) && isBusy(before));
}
/** The orientation of a template's first frame on this format: wide, tall or square. */
function firstFrameShape(tpl: BookDecoratedTemplate, page: BookPageSetup): 'wide' | 'tall' | 'square' {
const el = tpl.elements.find((e) => e.role === 'photo:1');
if (!el) return 'square';
const across = el.frame.w * page.pageWidth;
const down = el.frame.h * page.pageHeight;
if (across >= down * 1.2) return 'wide';
return down >= across * 1.1 ? 'tall' : 'square';
}
/** Points for the story: it fits the frame, or it loses in proportion to how far it runs over. */
function storyScore(tpl: BookDecoratedTemplate, chunk: TemplateChunk, ctx: TemplatePickCtx): number {
if (!chunk.story) return 0;
const room = bodyCapacity(tpl, ctx.page, ctx.theme);
if (room >= chunk.story.length) return 10;
return -Math.min(40, (20 * (chunk.story.length - room)) / Math.max(room, 1));
}
/** What a photo left without a frame costs a template. */
const UNUSED_PHOTO = 60;
/** What a template of an archetype neither the book's nor its kin costs, where only such can take the page. */
const FOREIGN = 25;
/** How well a template suits the page, the higher the better. */
function scoreOf(
tpl: BookDecoratedTemplate,
chunk: TemplateChunk,
ctx: TemplatePickCtx,
before: TemplateEnergy | undefined,
) {
// A photo the page has no frame for waits in the tray.
let score = 100 - UNUSED_PHOTO * (chunk.photos.length - slots(tpl));
if (tpl.archetype === ctx.archetype) score += 20;
else if (ARCHETYPE_KIN[ctx.archetype].includes(tpl.archetype)) score += 8;
else score -= FOREIGN;
const first = chunk.photos[0];
const shape = firstFrameShape(tpl, ctx.page);
if (first && ((shape === 'wide' && isLandscape(first)) || (shape === 'tall' && isPortrait(first)))) score += 6;
score += storyScore(tpl, chunk, ctx);
const recent = ctx.history.slice(-3);
if (recent.includes(tpl.id)) score -= 40;
score -= 8 * ctx.history.slice(0, -3).filter((id) => id === tpl.id).length;
const energy = templateEnergy(tpl);
if ((ctx.calm || isBusy(before)) && isBusy(energy)) score -= 30;
return score;
}
/** Scores this close count as equal, and the seed chooses among them. */
const TIE = 5;
/** The best of scored templates, equals chosen by the seed. */
function bestOf(scored: { tpl: BookDecoratedTemplate; score: number }[], seed: number, key: string) {
if (!scored.length) return undefined;
const top = Math.max(...scored.map((s) => s.score));
const near = scored.filter((s) => s.score >= top - TIE).map((s) => s.tpl);
return designRng(seed, key).pick(near);
}
/** The energy of the template a page was laid on, by its id. */
function energyOfId(pool: readonly BookDecoratedTemplate[], id: string | undefined): TemplateEnergy | undefined {
const tpl = id ? pool.find((t) => t.id === id) : undefined;
return tpl ? templateEnergy(tpl) : undefined;
}
/**
* The template a page is laid on. A frame for every photo comes first, then
* the book's own styles (its archetype and its kin), then the rhythm:
*
* 1. the own styles, every photo framed, the rhythm kept;
* 2. the own styles, every photo framed, the rhythm given up for this page;
* 3. another archetype, every photo framed, the rhythm kept;
* 4. the own styles with a photo left to wait in the tray;
* 5. whatever can take the page at all.
*
* Undefined when nothing can.
*/
export function pickInnerTemplate(chunk: TemplateChunk, ctx: TemplatePickCtx): BookDecoratedTemplate | undefined {
const all = decoratedTemplatesFor('inner');
const own = innerPool(ctx.archetype);
const before = energyOfId(all, ctx.history[ctx.history.length - 1]);
const full = (t: BookDecoratedTemplate) => slots(t) === chunk.photos.length;
const kept = (t: BookDecoratedTemplate) => admits(t, chunk, ctx, before);
const held = (t: BookDecoratedTemplate) => fitsAtAll(t, chunk, ctx);
const tiers: [readonly BookDecoratedTemplate[], (t: BookDecoratedTemplate) => boolean][] = [
[own, (t) => full(t) && kept(t)],
[own, (t) => full(t) && held(t)],
[all, (t) => full(t) && kept(t)],
[own, kept],
[all, held],
];
for (const [pool, takes] of tiers) {
const scored = pool.filter(takes).map((tpl) => ({ tpl, score: scoreOf(tpl, chunk, ctx, before) }));
if (scored.length) return bestOf(scored, ctx.seed, ctx.key);
}
return undefined;
}
/** What a cover may be laid with: how many photos there are, and whether the cover photo stands tall. */
interface CoverPick {
archetype: BookCoverArchetype;
photos: number;
portrait: boolean;
seed: number;
/** Ids never to take (the cover on screen, on "this spread"). */
avoid?: readonly string[];
}
const PORTRAIT_COVER = /-portrait$|^cutout-/;
/**
* The cover template of an archetype for the photos there are: one that
* leaves no frame empty and fills the most of them, a tall one for a tall
* cover photo, equals chosen by the seed. Undefined when the archetype has
* none to take them, for the composed cover to stand in.
*/
export function pickCoverTemplate(p: CoverPick): BookDecoratedTemplate | undefined {
const scored = decoratedTemplatesFor('single', p.archetype)
.filter((t) => slots(t) <= p.photos && !p.avoid?.includes(t.id))
// Without a photo only a cover drawn for none stands; a photo frame left empty does not.
.filter((t) => p.photos > 0 || slots(t) === 0)
.map((tpl) => {
const tall = PORTRAIT_COVER.test(tpl.id);
return { tpl, score: slots(tpl) * 10 + (tall === p.portrait ? 8 : 0) };
});
return bestOf(scored, p.seed, `cover:${p.archetype}`);
}
@@ -0,0 +1,346 @@
import { summaryMetrics } from '../auto/auto-book-spreads';
import { isPortrait } from '../auto/auto-elements';
import type { AutoEntry } from '../auto/auto-types';
import type { BookCoverArchetype } from '../book-archetypes';
import {
MAX_BOOK_COUNTRIES,
type BookCountryLook,
type BookElement,
type BookFrame,
type BookSpread,
type BookStatsLook,
} from '../book.schema';
import type { JourneyStats } from '../journey-stats.schema';
import { applyDecoratedTemplate } from '../templates/template-apply';
import type { BookDecoratedTemplate } from '../templates/template-format';
import { bookTemplateWords, type BookTemplateWords } from '../templates/template-words';
import { themedElement } from '../theme/book-theme-apply';
import { bookTravelLooks, BOOK_TRAVEL_COUNTRY_ACCENT, type BookTravelLooks } from '../travel/book-travel-looks';
import { frameOf, liveBox, photoEl, scaled, textEl, type ComposeCtx } from './design-compose';
import { composeArchetypeCover } from './design-cover';
import type { CoverMaterial } from './design-cover-kit';
import { titleWords, type CoverWords } from './design-cover-words';
import { composeBack } from './design-framing';
import { innerSpread } from './design-spreads';
import { pickCoverTemplate } from './design-template-pick';
/**
* The pages around the chapters when the book is laid on the designed
* templates: a cover laid on one of the archetype's cover templates, and the
* journey at a glance, its countries and the back cover built from the
* travel looks the Travel panel offers (a summary as a poster, tiles or a
* sign, countries as stamps, visas or labels, marks as stamps and tags).
* Nothing here sets a summary, a country or a mark in the classic look.
*/
/** The travel looks of this book, an element of a size centred on the right page unless a frame is named. */
function looksOf(c: ComposeCtx): BookTravelLooks | null {
const stats = c.travel.stats;
if (!stats) return null;
const box = liveBox(c, 'right');
const place = (size: { w: number; h: number }) =>
frameOf(box.x + (box.w - size.w) / 2, box.y + (box.h - size.h) / 2, size.w, size.h);
return bookTravelLooks({ page: c.page, stats, travel: c.travel, theme: c.look.theme, newId: c.newId, place });
}
/** A size scaled down to fit a box, its proportions kept, centred in it. */
function fitIn(size: { w: number; h: number }, box: BookFrame): BookFrame {
const k = Math.min(1, box.w / size.w, box.h / size.h);
const w = size.w * k;
const h = size.h * k;
return frameOf(box.x + (box.w - w) / 2, box.y + (box.h - h) / 2, w, h);
}
// ── the cover ─────────────────────────────────────────────────────────────
/**
* The cover's own words and pictures as a page, for a template to pour:
* the title bound to the journey's (unless the person gave their own), the
* subtitle bound the same way, the year it was travelled, and the photos.
* Poured from a page, the title keeps its binding, which the template's
* words for a cover would not give it.
*/
function coverSeed(c: ComposeCtx, words: CoverWords, material: CoverMaterial, slots: number): BookSpread {
const box = liveBox(c, 'single');
const els: BookElement[] = [];
const title = titleWords(words.ownTitle, words.title);
if (title.words.trim()) {
const binding = title.bound ? { source: 'journey.title' as const } : null;
els.push(textEl(c, box, title.words, 'heading', { binding, size: c.look.sizes.display }));
}
const sub = titleWords(words.ownSubtitle, words.subtitle ?? '');
if (sub.words.trim()) {
const binding = sub.bound ? { source: 'journey.subtitle' as const } : null;
els.push(textEl(c, box, sub.words, 'sub', { binding }));
}
const year = material.entries.find((e) => e.date && /^\d{4}/.test(e.date))?.date?.slice(0, 4);
if (year) els.push(textEl(c, box, year, 'meta'));
els.push(...material.photos.slice(0, slots).map((p) => photoEl(c, p, box)));
return { id: c.newId('sp'), role: 'cover', background: null, elements: els, parked: [], entryId: null };
}
/**
* A page laid on a decorated template in the book's theme, its empty roles
* taking the journey's words, or the words `given` for a role by name.
*/
export function pourTemplate(
c: ComposeCtx,
seed: BookSpread,
tpl: BookDecoratedTemplate,
entries: readonly AutoEntry[],
given: BookTemplateWords = {},
) {
const words = { ...bookTemplateWords(seed, { bookTitle: '', locale: c.locale, entries }), ...given };
return applyDecoratedTemplate(seed, tpl, c.page, {
theme: c.look.theme,
travel: c.travel,
locale: c.locale,
newId: c.newId,
words,
});
}
/**
* The cover on one of the archetype's cover templates: one that leaves no
* frame empty and fills the most, a tall one for a tall cover photo. An
* archetype with none for the photos there are (none at all, or fewer than
* its grid takes) has its cover composed instead (design-cover.ts).
*/
export function composeTemplatedCover(
c: ComposeCtx,
words: CoverWords,
material: CoverMaterial,
archetype: BookCoverArchetype,
avoid: readonly string[] = [],
): { spread: BookSpread; template: string | null } {
const first = material.photos[0];
const seed = c.directives.seed ?? 0;
const tpl = pickCoverTemplate({
archetype,
photos: material.photos.length,
portrait: !!first && isPortrait(first),
seed,
avoid,
});
if (!tpl) return { spread: composeArchetypeCover(c, archetype, words, material), template: null };
return { spread: coverOnTemplate(c, words, material, tpl), template: tpl.id };
}
/** The cover laid on this cover template, its title and subtitle bound to the journey's. */
export function coverOnTemplate(
c: ComposeCtx,
words: CoverWords,
material: CoverMaterial,
tpl: BookDecoratedTemplate,
): BookSpread {
const laid = pourTemplate(c, coverSeed(c, words, material, material.photos.length), tpl, material.entries);
// The photos the cover has no frame for are the book's own pages', not the cover's to park.
return { ...laid, parked: [] };
}
// ── the journey at a glance ───────────────────────────────────────────────
/** The summary's look for an archetype: tiles for the collage and grid books, a sign for the postcard ones. */
export function summaryLookFor(archetype: BookCoverArchetype): BookStatsLook {
if (archetype === 'stamp-collage' || archetype === 'grid-badge') return 'tiles';
return archetype === 'postcard' ? 'sign' : 'poster';
}
/** The summary looks "this spread" goes through, in turn. */
export const SUMMARY_CYCLE: readonly BookStatsLook[] = ['poster', 'tiles', 'sign'];
/** Whether the journey adds up to a summary worth a spread: a way of two stops at least. */
const hasSummary = (stats: JourneyStats | null): stats is JourneyStats => !!stats && stats.points.length >= 2;
/**
* The journey at a glance: its map with a photo at every stop on the left
* page; on the right the figures in the look's panel, a ticket stub of how
* far and how long below it, and the furthest point on an odometer when the
* journey went anywhere. Null when there is nothing to sum up.
*/
export function composeTravelSummary(c: ComposeCtx, look: BookStatsLook): BookSpread | null {
const stats = c.travel.stats;
const looks = looksOf(c);
if (!hasSummary(stats) || !looks) return null;
const els: BookElement[] = [looks.mapEl({ style: 'minimal' }, 'journey', 'vector', liveBox(c, 'left'))];
const box = liveBox(c, 'right');
const parts: { size: { w: number; h: number }; make: (f: BookFrame) => BookElement }[] = [];
const metrics = summaryMetrics(stats);
const main = looks.summarySize(look);
parts.push({ size: { w: box.w, h: (box.w * main.h) / main.w }, make: (f) => looks.summaryEl(look, f, metrics) });
const stub = looks.summarySize('ticket');
parts.push({
size: { w: box.w * 0.72, h: (box.w * 0.72 * stub.h) / stub.w },
make: (f) => looks.summaryEl('ticket', f, ['distance', 'days']),
});
if (stats.furthest > 0) {
const drums = looks.figureSize('odometer');
parts.push({
size: { w: box.w * 0.5, h: (box.w * 0.5 * drums.h) / drums.w },
make: (f) => looks.figureEl('furthest', 'odometer', f),
});
}
els.push(...stacked(c, parts, box));
return innerSpread(c, els, null);
}
/** Parts stacked down a box with a gap between, all scaled alike to fit it, each centred across. */
function stacked(
c: ComposeCtx,
parts: readonly { size: { w: number; h: number }; make: (f: BookFrame) => BookElement }[],
box: BookFrame,
): BookElement[] {
const gap = scaled(c, 8);
const tall = parts.reduce((h, p) => h + p.size.h, 0);
const k = Math.min(1, (box.h - gap * (parts.length - 1)) / tall);
let y = box.y + (box.h - (tall * k + gap * (parts.length - 1))) / 2;
return parts.map((p) => {
const w = p.size.w * k;
const h = p.size.h * k;
const el = p.make(frameOf(box.x + (box.w - w) / 2, y, w, h));
y += h + gap;
return el;
});
}
// ── the countries ─────────────────────────────────────────────────────────
/** The country look for an archetype: stamps for the collage books, postcards, labels for the quiet ones, else visas. */
export function countryLookFor(archetype: BookCoverArchetype): BookCountryLook {
if (archetype === 'stamp-collage') return 'stamp';
if (archetype === 'postcard') return 'postcard';
return archetype === 'spine-band' || archetype === 'minimal-serif' ? 'label' : 'visa';
}
/** The country looks "this spread" goes through, in turn. */
export const COUNTRY_CYCLE: readonly BookCountryLook[] = ['stamp', 'visa', 'label', 'postcard'];
/** Past this many countries one panel of stamps holds them all, rather than a stamp each. */
const MOST_COUNTRY_CARDS = 12;
/** A country in its look, its accent the look's or the theme's first by turns, so a grid is not one colour. */
function countryCard(
c: ComposeCtx,
looks: BookTravelLooks,
code: string,
look: BookCountryLook,
at: number,
frame: BookFrame,
) {
const el = looks.countryEl(code, look, frame);
const own = BOOK_TRAVEL_COUNTRY_ACCENT[look] ?? 'accent';
if (at % 2 === 0 || own === 'accent') return el;
return themedElement({ ...el, accentToken: 'accent' } as BookElement, c.look.theme);
}
/** The cells of a grid of `n` on one page's live box, at most three across. */
function grid(box: BookFrame, n: number, gap: number): BookFrame[] {
const cols = Math.min(3, n);
const rows = Math.ceil(n / cols);
const w = (box.w - gap * (cols - 1)) / cols;
const h = Math.min(w, (box.h - gap * (rows - 1)) / rows);
const top = box.y + (box.h - (h * rows + gap * (rows - 1))) / 2;
return Array.from({ length: n }, (_, i) =>
frameOf(box.x + (i % cols) * (w + gap), top + Math.floor(i / cols) * (h + gap), w, h),
);
}
/**
* The countries of the journey: one country as its map cut to its shape
* beside its stamp; up to twelve as a card each over both pages; more as one
* panel of stamps. Null for a journey that crossed no border it knows of.
*/
export function composeTravelCountries(c: ComposeCtx, look: BookCountryLook): BookSpread | null {
const stats = c.travel.stats;
const looks = looksOf(c);
const codes = stats?.countries.map((k) => k.code) ?? [];
if (!looks || !codes.length) return null;
const card = looks.countrySize(look);
const [only] = codes;
if (codes.length === 1 && only) {
const els = [
looks.countryMapEl(only, 'shape', liveBox(c, 'left')),
countryCard(c, looks, only, look, 0, fitIn({ w: card.w * 1.4, h: card.h * 1.4 }, liveBox(c, 'right'))),
];
return innerSpread(c, els, null);
}
if (codes.length > MOST_COUNTRY_CARDS) {
// One panel on the right page, every country a stamp in it, beside the journey's map.
const map = looks.mapEl({ style: 'minimal', pins: 'numbered' }, 'journey', 'vector', liveBox(c, 'left'));
return innerSpread(
c,
[map, withCodes(looks.countryEl(only ?? '', 'stamp', liveBox(c, 'right')), looks, codes)],
null,
);
}
const left = Math.ceil(codes.length / 2);
const gap = scaled(c, 6);
const cells = [...grid(liveBox(c, 'left'), left, gap), ...grid(liveBox(c, 'right'), codes.length - left, gap)];
const els = codes.map((code, i) => countryCard(c, looks, code, look, i, fitIn(card, cells[i] as BookFrame)));
return innerSpread(c, els, null);
}
/** A countries panel holding every one of the journey's countries, named in the book's language. */
function withCodes(el: BookElement, looks: BookTravelLooks, codes: readonly string[]): BookElement {
if (el.kind !== 'countries') return el;
const all = codes.slice(0, MAX_BOOK_COUNTRIES);
return { ...el, codes: all.map((k) => k.toUpperCase()), names: all.map((k) => looks.countryName(k)), layout: 'grid' };
}
// ── the back cover ────────────────────────────────────────────────────────
/**
* The back cover: the title and when it was, as the composed back sets
* them, then a band of how long, how many places and how many photos, and a
* row of up to four country stamps. One page: everything stays inside its
* live area.
*/
export function composeTravelBack(c: ComposeCtx, words: CoverWords, entries: readonly AutoEntry[]): BookSpread {
const back = composeBack(c, words, entries);
const looks = looksOf(c);
const stats = c.travel.stats;
if (!looks || !stats) return back;
const box = liveBox(c, 'single');
const els: BookElement[] = [...back.elements];
const bandH = Math.min(c.page.pageHeight * 0.13, box.h * 0.16);
els.push(looks.summaryEl('band', frameOf(box.x, box.y + box.h * 0.66, box.w, bandH), ['days', 'places', 'photos']));
const codes = stats.countries.slice(0, 4).map((k) => k.code);
if (codes.length) {
const gap = scaled(c, 4);
const side = Math.min((box.w - gap * 3) / 4, box.y + box.h - (box.y + box.h * 0.66 + bandH + gap));
const row = codes.length * side + gap * (codes.length - 1);
const y = box.y + box.h - side;
codes.forEach((code, i) => {
els.push(looks.countryEl(code, 'stamp', frameOf(box.x + (box.w - row) / 2 + i * (side + gap), y, side, side)));
});
}
return { ...back, elements: els };
}
// ── a page of words ───────────────────────────────────────────────────────
/**
* A page of an entry's words where no template takes them (a story with no
* photo, or the rest of one that ran over its template's frame): the story
* in a column on the left page, and on the right the stop's map with its
* stops numbered, its day as a stamp and its country as a tag.
*/
export function composeWordsPage(c: ComposeCtx, entry: AutoEntry, story: BookElement | null): BookSpread {
const looks = looksOf(c);
const els: BookElement[] = story ? [story] : [];
const right = liveBox(c, 'right');
if (looks && c.travel.stats && c.travel.stats.points.length) {
const map = frameOf(right.x, right.y, right.w, right.h * 0.62);
els.push(looks.mapEl({ style: 'minimal', pins: 'numbered' }, 'journey', 'vector', map));
const marks = [looks.markEl('day', 'stamp', { entry }), looks.markEl('flag', 'tag', { entry })];
let x = right.x;
for (const mark of marks) {
if (!mark) continue;
const { w = 0, h = 0 } = mark.frame;
const frame = fitIn({ w, h }, frameOf(x, right.y + right.h * 0.7, right.w / 2 - scaled(c, 4), right.h * 0.3));
els.push({ ...mark, frame: frameOf(x, frame.y, frame.w, frame.h) });
x += frame.w + scaled(c, 8);
}
}
return innerSpread(c, els, entry.id);
}
@@ -0,0 +1,717 @@
{
"D": {
"input": {
"locale": "en-GB",
"title": "North by the coast",
"subtitle": null,
"coverPhotoId": null,
"entries": [
{
"id": 101,
"title": "Stop 1",
"story": "We walked out along the harbour wall before the light came up, and the town was still asleep behind us.",
"location": "Bergen",
"date": "2026-06-01",
"photos": [],
"lat": 60,
"lng": 5,
"country": "NO",
"dayNumber": 1,
"dayCount": 8
},
{
"id": 102,
"title": "Stop 2",
"story": null,
"location": "Flam",
"date": "2026-06-02",
"photos": [
{
"photoId": 500,
"width": 4000,
"height": 3000,
"caption": null
}
],
"lat": 61,
"lng": 6,
"country": "NO",
"dayNumber": 2,
"dayCount": 8
},
{
"id": 103,
"title": "Stop 3",
"story": "We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us.",
"location": "Alesund",
"date": "2026-06-03",
"photos": [
{
"photoId": 501,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 502,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 503,
"width": 3000,
"height": 4000,
"caption": null
}
],
"lat": 62,
"lng": 7,
"country": "NO",
"dayNumber": 3,
"dayCount": 8
},
{
"id": 104,
"title": "Stop 4",
"story": null,
"location": "Geiranger",
"date": "2026-06-04",
"photos": [
{
"photoId": 504,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 505,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 506,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 507,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 508,
"width": 4000,
"height": 3000,
"caption": null
}
],
"lat": 63,
"lng": 8,
"country": "NO",
"dayNumber": 4,
"dayCount": 8
},
{
"id": 105,
"title": "Stop 5",
"story": "We walked out along the harbour wall before the light came up, and the town was still asleep behind us.",
"location": "Trondheim",
"date": "2026-06-05",
"photos": [
{
"photoId": 509,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 510,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 511,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 512,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 513,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 514,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 515,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 516,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 517,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 518,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 519,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 520,
"width": 3000,
"height": 4000,
"caption": null
}
],
"lat": 64,
"lng": 9,
"country": "NO",
"dayNumber": 5,
"dayCount": 8
},
{
"id": 106,
"title": "Stop 6",
"story": null,
"location": "Bodo",
"date": "2026-06-06",
"photos": [
{
"photoId": 521,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 522,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 523,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 524,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 525,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 526,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 527,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 528,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 529,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 530,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 531,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 532,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 533,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 534,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 535,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 536,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 537,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 538,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 539,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 540,
"width": 4000,
"height": 3000,
"caption": null
}
],
"lat": 65,
"lng": 10,
"country": "NO",
"dayNumber": 6,
"dayCount": 8
},
{
"id": 107,
"title": "Stop 7",
"story": null,
"location": "Tromso",
"date": "2026-06-07",
"photos": [],
"lat": 66,
"lng": 11,
"country": "NO",
"dayNumber": 7,
"dayCount": 8
},
{
"id": 108,
"title": "Stop 8",
"story": "We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us.",
"location": "Alta",
"date": "2026-06-08",
"photos": [
{
"photoId": 541,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 542,
"width": 4000,
"height": 3000,
"caption": null
}
],
"lat": 67,
"lng": 12,
"country": "NO",
"dayNumber": 8,
"dayCount": 8
}
],
"page": {
"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"
}
},
"stats": {
"journeyId": 9,
"distance": 2100000,
"days": 8,
"steps": 20,
"photos": 80,
"places": 8,
"furthest": 1500000,
"countries": [
{
"code": "NO",
"name": "Norway",
"places": 8,
"firstVisit": "2026-06-01"
}
],
"points": [
{
"lat": 60,
"lng": 5,
"label": "Bergen",
"date": "2026-06-01",
"country": "NO",
"tripId": null,
"photoId": null,
"entryId": 101
},
{
"lat": 61,
"lng": 6,
"label": "Flam",
"date": "2026-06-02",
"country": "NO",
"tripId": null,
"photoId": null,
"entryId": 102
},
{
"lat": 62,
"lng": 7,
"label": "Alesund",
"date": "2026-06-03",
"country": "NO",
"tripId": null,
"photoId": null,
"entryId": 103
},
{
"lat": 63,
"lng": 8,
"label": "Geiranger",
"date": "2026-06-04",
"country": "NO",
"tripId": null,
"photoId": null,
"entryId": 104
},
{
"lat": 64,
"lng": 9,
"label": "Trondheim",
"date": "2026-06-05",
"country": "NO",
"tripId": null,
"photoId": null,
"entryId": 105
},
{
"lat": 65,
"lng": 10,
"label": "Bodo",
"date": "2026-06-06",
"country": "NO",
"tripId": null,
"photoId": null,
"entryId": 106
},
{
"lat": 66,
"lng": 11,
"label": "Tromso",
"date": "2026-06-07",
"country": "NO",
"tripId": null,
"photoId": null,
"entryId": 107
},
{
"lat": 67,
"lng": 12,
"label": "Alta",
"date": "2026-06-08",
"country": "NO",
"tripId": null,
"photoId": null,
"entryId": 108
}
],
"trips": [],
"excluded": [],
"start": "2026-06-01",
"end": "2026-06-08"
},
"path": [],
"stationsLabel": "Stations",
"dayLabel": "DAY {day}",
"summaryLabel": "Trip summary",
"countriesLabel": "Countries"
},
"gallery": [
{
"photoId": 900,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-01T10:00:00Z"
},
{
"photoId": 901,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-02T10:00:00Z"
},
{
"photoId": 902,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-03T10:00:00Z"
},
{
"photoId": 903,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-04T10:00:00Z"
},
{
"photoId": 904,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-05T10:00:00Z"
},
{
"photoId": 905,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-06T10:00:00Z"
},
{
"photoId": 906,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-07T10:00:00Z"
},
{
"photoId": 907,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-08T10:00:00Z"
},
{
"photoId": 908,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-09T10:00:00Z"
},
{
"photoId": 909,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-01T10:00:00Z"
},
{
"photoId": 910,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-02T10:00:00Z"
},
{
"photoId": 911,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-03T10:00:00Z"
},
{
"photoId": 912,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-04T10:00:00Z"
},
{
"photoId": 913,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-05T10:00:00Z"
},
{
"photoId": 914,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-06T10:00:00Z"
},
{
"photoId": 915,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-07T10:00:00Z"
},
{
"photoId": 916,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-08T10:00:00Z"
},
{
"photoId": 917,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-09T10:00:00Z"
},
{
"photoId": 918,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-01T10:00:00Z"
},
{
"photoId": 919,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-02T10:00:00Z"
},
{
"photoId": 920,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-03T10:00:00Z"
},
{
"photoId": 921,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-04T10:00:00Z"
},
{
"photoId": 922,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-05T10:00:00Z"
},
{
"photoId": 923,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-06T10:00:00Z"
},
{
"photoId": 924,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-07T10:00:00Z"
},
{
"photoId": 925,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-08T10:00:00Z"
},
{
"photoId": 926,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-09T10:00:00Z"
},
{
"photoId": 927,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-01T10:00:00Z"
},
{
"photoId": 928,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-02T10:00:00Z"
},
{
"photoId": 929,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-03T10:00:00Z"
}
]
}
}
@@ -0,0 +1,228 @@
{
"A/fabric-classic/head": "74bb0439e4575ed7",
"A/fabric-classic/plan": "95b55b052b9ff07b",
"A/fabric-classic/spread/0": "a4d090166287cbe5",
"A/fabric-classic/spread/1": "aa637b0de2518bf5",
"A/fabric-classic/spread/10": "febf8fa0b4cd3aef",
"A/fabric-classic/spread/11": "26253da4f1f688a0",
"A/fabric-classic/spread/12": "4dbf29b2c63f9231",
"A/fabric-classic/spread/13": "5deb8ece6f27e514",
"A/fabric-classic/spread/14": "db4f2ede14bb6023",
"A/fabric-classic/spread/15": "7d011356ea8fc70f",
"A/fabric-classic/spread/16": "205c5f0f86f90cb4",
"A/fabric-classic/spread/17": "de849a3986a487c1",
"A/fabric-classic/spread/18": "22b9cb0c78e0ff66",
"A/fabric-classic/spread/19": "9df44ff280b76f64",
"A/fabric-classic/spread/2": "8278d3c2507527f9",
"A/fabric-classic/spread/20": "972976260c457cd3",
"A/fabric-classic/spread/21": "9102a5fa147693fe",
"A/fabric-classic/spread/22": "0bc9c54f6ee8b3e9",
"A/fabric-classic/spread/23": "b11b5b3fb34a34eb",
"A/fabric-classic/spread/24": "a55189e7bfd3934d",
"A/fabric-classic/spread/3": "2ceb4890ba95c249",
"A/fabric-classic/spread/4": "7743a5784df1d3e5",
"A/fabric-classic/spread/5": "dbbbfb7bedd90da2",
"A/fabric-classic/spread/6": "c36b6c75a4254fad",
"A/fabric-classic/spread/7": "b7694f72f7cbe2bb",
"A/fabric-classic/spread/8": "bdafe534fcbe56f0",
"A/fabric-classic/spread/9": "c8a03b3fbdb60114",
"A/midnight/head": "ce0d82281cbc3e1f",
"A/midnight/plan": "7fde812131c9b570",
"A/midnight/spread/0": "409beb5fbb1accce",
"A/midnight/spread/1": "d9f5d78659332b45",
"A/midnight/spread/10": "53f13a2ec3a3c5f4",
"A/midnight/spread/11": "888c0c66730d32dc",
"A/midnight/spread/12": "8d6c62bc4ee5ed1d",
"A/midnight/spread/13": "fc0673a56f5b4d4b",
"A/midnight/spread/14": "33db0be88fd469a7",
"A/midnight/spread/15": "72105c668088bc4c",
"A/midnight/spread/16": "bd11f4dc18e03108",
"A/midnight/spread/17": "2e7a0f09822c3556",
"A/midnight/spread/18": "f4264c3ee121b8d1",
"A/midnight/spread/19": "4f27a48a457d95bb",
"A/midnight/spread/2": "b00839cd0dc1451b",
"A/midnight/spread/20": "dff6e523732c8481",
"A/midnight/spread/21": "26d666dde7be6641",
"A/midnight/spread/22": "eba329fd185958b3",
"A/midnight/spread/23": "9664fbd24505a632",
"A/midnight/spread/3": "742dfb4105263101",
"A/midnight/spread/4": "ea9613c918951560",
"A/midnight/spread/5": "8aa571b4d51a5234",
"A/midnight/spread/6": "2275359a3c03f457",
"A/midnight/spread/7": "3ef3c265b38598e2",
"A/midnight/spread/8": "3a77eec4f6c9f860",
"A/midnight/spread/9": "5bdff7027bea103c",
"A/riso/head": "7d43d0900fbe0f57",
"A/riso/plan": "f802ad819f1fb262",
"A/riso/spread/0": "1f4502d29eca9017",
"A/riso/spread/1": "8de92788de29277b",
"A/riso/spread/10": "2a92b79192575cd0",
"A/riso/spread/11": "5785a9cc59ddd735",
"A/riso/spread/12": "cd4f6004153fe694",
"A/riso/spread/13": "b1f87a92cb7f775c",
"A/riso/spread/14": "873bf5f7a8404fe4",
"A/riso/spread/15": "c1aab0214328ef3b",
"A/riso/spread/16": "d58f70c381d9f6ae",
"A/riso/spread/17": "00caa407cb2a0677",
"A/riso/spread/18": "ed41aca064456614",
"A/riso/spread/19": "36647f06c594e715",
"A/riso/spread/2": "5c9a93de9e6467d6",
"A/riso/spread/20": "8518ca61c110fd8a",
"A/riso/spread/21": "d4ba78b2ffd5ae04",
"A/riso/spread/22": "de5116307e12ce61",
"A/riso/spread/23": "a748ada7a028818e",
"A/riso/spread/24": "9abf456403732463",
"A/riso/spread/3": "20a199a47535dea7",
"A/riso/spread/4": "93b84be6e48097b5",
"A/riso/spread/5": "6d5ae8e80c2f0d21",
"A/riso/spread/6": "e7f5c928785be48d",
"A/riso/spread/7": "b672fd04b61ea7b9",
"A/riso/spread/8": "d9e84bbad744e450",
"A/riso/spread/9": "017d695be27dc988",
"B/fabric-classic/head": "6af0d11dd09eae78",
"B/fabric-classic/plan": "cae85517611abbca",
"B/fabric-classic/spread/0": "87325aa216fe9c6a",
"B/fabric-classic/spread/1": "b3f8e9c5043f8302",
"B/fabric-classic/spread/2": "5951c4e8d72b4b3f",
"B/fabric-classic/spread/3": "110c2e11d8079468",
"B/fabric-classic/spread/4": "6f02c19ed62524d1",
"B/fabric-classic/spread/5": "5b0cb3424c4b894c",
"B/fabric-classic/spread/6": "28fec4ed0f8fd482",
"B/fabric-classic/spread/7": "9d68ef6e7d8b5f5a",
"B/fabric-classic/spread/8": "b11b5b3fb34a34eb",
"B/fabric-classic/spread/9": "3194c096e5c7d16f",
"B/midnight/head": "cfd309bdf9e41811",
"B/midnight/plan": "b3e4cb03c2d620a4",
"B/midnight/spread/0": "7fdd20dcda48881f",
"B/midnight/spread/1": "d9f5d78659332b45",
"B/midnight/spread/10": "db97ebe8475e0371",
"B/midnight/spread/2": "a094503b82019808",
"B/midnight/spread/3": "6e3e57121d8b8683",
"B/midnight/spread/4": "468100c3bebad30d",
"B/midnight/spread/5": "ca669ba76ca4d9bd",
"B/midnight/spread/6": "60b4cb974e35c55d",
"B/midnight/spread/7": "46fe0841298e4f7b",
"B/midnight/spread/8": "25e4510c5fb81817",
"B/midnight/spread/9": "eba329fd185958b3",
"B/riso/head": "69a6c3112de2a047",
"B/riso/plan": "d8906a53b3532db8",
"B/riso/spread/0": "2bbd6e6b4e6d1b4d",
"B/riso/spread/1": "8de92788de29277b",
"B/riso/spread/2": "40f8d2b093d0fc8d",
"B/riso/spread/3": "1fc8aedc7486cbeb",
"B/riso/spread/4": "311c9fcde888d578",
"B/riso/spread/5": "51562d8f08f46011",
"B/riso/spread/6": "c114a720b5c5beff",
"B/riso/spread/7": "6f4c43c7b5f1dbc3",
"B/riso/spread/8": "a748ada7a028818e",
"B/riso/spread/9": "877657cfa95e3cac",
"C/fabric-classic/head": "ddd21691c506a600",
"C/fabric-classic/plan": "c31dcaa9b3603acc",
"C/fabric-classic/spread/0": "92d21ac9d8e866df",
"C/fabric-classic/spread/1": "7094bacb28cc210a",
"C/fabric-classic/spread/10": "b11b5b3fb34a34eb",
"C/fabric-classic/spread/11": "f7d9814c6b8649fd",
"C/fabric-classic/spread/2": "3e514b7dd87c7d49",
"C/fabric-classic/spread/3": "a964aa1e7f057294",
"C/fabric-classic/spread/4": "4a275bcefa9a8eb0",
"C/fabric-classic/spread/5": "10ed185ab3fd9756",
"C/fabric-classic/spread/6": "7bfbef523f58c3fc",
"C/fabric-classic/spread/7": "71330d722ab54efd",
"C/fabric-classic/spread/8": "a7f0ed591793b5e1",
"C/fabric-classic/spread/9": "31665bce59abeafe",
"C/midnight/head": "f1e6077175cb2e20",
"C/midnight/plan": "14fb454d02caf6f9",
"C/midnight/spread/0": "79eae9202a85d430",
"C/midnight/spread/1": "d9f5d78659332b45",
"C/midnight/spread/10": "6694684925255e94",
"C/midnight/spread/11": "247394453c45c63c",
"C/midnight/spread/12": "abe0197797669d57",
"C/midnight/spread/13": "eba329fd185958b3",
"C/midnight/spread/14": "ebbba57a4116faa3",
"C/midnight/spread/2": "7b99390522f39fe5",
"C/midnight/spread/3": "7391268691e50ede",
"C/midnight/spread/4": "4f08a3fc6da821a5",
"C/midnight/spread/5": "6d06a2d469c2d5e2",
"C/midnight/spread/6": "6238c8bc28c5964e",
"C/midnight/spread/7": "8a5cc2233372bb47",
"C/midnight/spread/8": "a445fe1b9090293d",
"C/midnight/spread/9": "e2af9bfdb8789b4b",
"C/riso/head": "d19997b07778d1dc",
"C/riso/plan": "175dadeb8bb730a6",
"C/riso/spread/0": "8453dd7b94afe68d",
"C/riso/spread/1": "8de92788de29277b",
"C/riso/spread/10": "6b5aa1dac73ab777",
"C/riso/spread/11": "c20262603c86d43e",
"C/riso/spread/12": "8e263c445c0418d2",
"C/riso/spread/13": "a748ada7a028818e",
"C/riso/spread/14": "81e4b4a0dcc6961a",
"C/riso/spread/2": "5e43386b045d926c",
"C/riso/spread/3": "9587f2fe2fc9cd04",
"C/riso/spread/4": "edb0ebe3a843a937",
"C/riso/spread/5": "f8a935188537c224",
"C/riso/spread/6": "d2ca03597f3732c6",
"C/riso/spread/7": "7a4374ec7906855f",
"C/riso/spread/8": "2dd867bf81ca7901",
"C/riso/spread/9": "aea9baf94aa13bc8",
"D/fabric-classic/head": "54fa8987fea87ce4",
"D/fabric-classic/plan": "a2d0423833838dcc",
"D/fabric-classic/spread/0": "1136daea45c98c7d",
"D/fabric-classic/spread/1": "931682bc5827ad8a",
"D/fabric-classic/spread/10": "98449436f064fc7c",
"D/fabric-classic/spread/11": "e58c5ea28b6e5d14",
"D/fabric-classic/spread/12": "a422c04ae2d9e32f",
"D/fabric-classic/spread/13": "b512aff5192eae71",
"D/fabric-classic/spread/14": "b92cc5c3c532f2cb",
"D/fabric-classic/spread/15": "b11b5b3fb34a34eb",
"D/fabric-classic/spread/16": "c40cdcb18e2ef0bc",
"D/fabric-classic/spread/2": "86dd9a101af346f4",
"D/fabric-classic/spread/3": "afe19cb105dfefa0",
"D/fabric-classic/spread/4": "80d31e5986b670e2",
"D/fabric-classic/spread/5": "664cd81373c14243",
"D/fabric-classic/spread/6": "c1d232a5aed51318",
"D/fabric-classic/spread/7": "8d08e54aa79c8c74",
"D/fabric-classic/spread/8": "18f531e0a85ff3de",
"D/fabric-classic/spread/9": "a7ddf2eb33bb75f6",
"D/midnight/head": "da510cf5f95bad40",
"D/midnight/plan": "3bd0d52ed4e476c7",
"D/midnight/spread/0": "440b7d69daa18a7a",
"D/midnight/spread/1": "d9f5d78659332b45",
"D/midnight/spread/10": "824d62135a0fe827",
"D/midnight/spread/11": "03bbcf527fcbce9f",
"D/midnight/spread/12": "d2e1ae86b69d8a5c",
"D/midnight/spread/13": "7025aef32f444ab2",
"D/midnight/spread/14": "581f0008497c1143",
"D/midnight/spread/15": "f86784f4b061a31e",
"D/midnight/spread/16": "ec6c98d1713ff353",
"D/midnight/spread/17": "03888512d15a5895",
"D/midnight/spread/18": "eba329fd185958b3",
"D/midnight/spread/19": "6e6e6c877443c5e7",
"D/midnight/spread/2": "cef4235690d44e53",
"D/midnight/spread/3": "cd34cb85119476ee",
"D/midnight/spread/4": "f9782906a14ae092",
"D/midnight/spread/5": "597d729fb1cfb962",
"D/midnight/spread/6": "0023fe5672ad9141",
"D/midnight/spread/7": "f79ae699d0684ce5",
"D/midnight/spread/8": "ea1a0157b2405cff",
"D/midnight/spread/9": "5d38d38d1226fe1e",
"D/riso/head": "1c8f99d99acdea28",
"D/riso/plan": "f2a8704019582093",
"D/riso/spread/0": "7b0d816ffda1dd01",
"D/riso/spread/1": "8de92788de29277b",
"D/riso/spread/10": "c85c0634205e3232",
"D/riso/spread/11": "f97635aee763f93e",
"D/riso/spread/12": "37f5d0dc381cfdb4",
"D/riso/spread/13": "7083d486cc5556c5",
"D/riso/spread/14": "711a3787fce72914",
"D/riso/spread/15": "27d0db240d32de9a",
"D/riso/spread/16": "a748ada7a028818e",
"D/riso/spread/17": "ed87c0a4c77f241c",
"D/riso/spread/2": "78f24755fb7ad38a",
"D/riso/spread/3": "047741da3e1adc20",
"D/riso/spread/4": "294d0c2a0600ed4a",
"D/riso/spread/5": "b1f6c2832a5f5737",
"D/riso/spread/6": "a898defea41081ec",
"D/riso/spread/7": "027026c49ea68a27",
"D/riso/spread/8": "4ff93cb4d24f729c",
"D/riso/spread/9": "988a20c009c9d562"
}
@@ -0,0 +1,416 @@
import type { AutoEntry, AutoInput, AutoPhoto } from '../auto/auto-types';
import { normalizeBookDocument } from '../book-normalize';
import {
MAX_SPREADS,
MAX_SPREAD_ELEMENTS,
bookDocumentSchema,
type BookDocument,
type BookSpread,
} from '../book.schema';
import { pageFindings } from '../describe-spread';
import { findDecoratedTemplate } from '../templates/template-catalogue';
import { templatePhotoSlots } from '../templates/template-format';
import { matchDecoratedTemplate } from '../templates/template-match';
import { bookThemeColor, bookThemeOf } from '../theme/book-theme';
import { applyBookTheme } from '../theme/book-theme-apply';
import { designBook, type BookDesignInput, type BookDesignResult } from './design-book';
import type { BookDesignDirectivesInput } from './design-directives.schema';
import { ARCHETYPE_KIN, THEME_ARCHETYPE } from './design-template-pick';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
/**
* A book laid on the designed templates (`layouts: 'templates'`): the
* Studio's Auto layout and `design_book` with the templates.
*
* AUTODSN-020 pins a digest of every page on the auto layout's three parity
* journeys and on a fourth (`design-templated.parity-inputs.json`: entries of
* 0, 1, 2, 3, 5, 12 and 20 photos and a gallery), in three themes. A
* deliberate change to a layout changes its digests: say so in the change
* and write the new ones (the failure prints the whole map as it is now).
*/
interface Fixture {
input: AutoInput & Partial<BookDesignInput>;
gallery?: AutoPhoto[];
}
const here = (file: string) => fileURLToPath(new URL(file, import.meta.url));
const read = <T>(file: string) => JSON.parse(readFileSync(here(file), 'utf8')) as T;
const parity = {
...read<Record<string, Fixture>>('../auto/auto-layout.parity-inputs.json'),
...read<Record<string, Fixture>>('./design-templated.parity-inputs.json'),
};
const designFixtures = read<Record<string, { input: BookDesignInput }>>('./design-book.fixtures.json');
const expected = read<Record<string, string>>('./design-templated.parity.json');
/** Every journey the specs lay out, as designBook takes it, with ids from a counter. */
function inputs(): [string, BookDesignInput][] {
const out: [string, BookDesignInput][] = [];
for (const [name, fx] of Object.entries(parity)) {
out.push([name, { ...fx.input, gallery: fx.gallery, candidates: [], photos: [] }]);
}
for (const [name, fx] of Object.entries(designFixtures)) out.push([name, fx.input]);
return out;
}
const counter = () => {
let n = 0;
return (p: string) => `${p}-${++n}`;
};
const THEMES = ['fabric-classic', 'riso', 'midnight'] as const;
function laid(input: BookDesignInput, style: string, more: BookDesignDirectivesInput = {}): BookDesignResult {
const directives = { style, layouts: 'templates', seed: 1, ...more } as BookDesignDirectivesInput;
return designBook(null, { ...input, newId: counter() }, directives);
}
const photoIdsOn = (spreads: readonly BookSpread[], where: 'elements' | 'parked') =>
spreads.flatMap((s) => s[where].flatMap((e) => (e.kind === 'photo' && e.photoId != null ? [e.photoId] : [])));
/** Every photo the journey has, on its entries and loose in its gallery. */
const journeyPhotos = (input: BookDesignInput) => [
...input.entries.flatMap((e) => e.photos.map((p) => p.photoId)),
...(input.gallery ?? []).map((p) => p.photoId),
];
const STORY =
'The ferry left in the rain and we stood on deck anyway, watching the islands go by one after the other. ';
/** A journey of one entry with this many photos, a story or none. */
function single(photos: number, story: boolean): BookDesignInput {
const base = parity.A as Fixture;
const entry: AutoEntry = {
id: 1,
title: 'Islands',
story: story ? STORY.repeat(3).trim() : null,
location: 'Lofoten',
date: '2026-06-02',
photos: Array.from({ length: photos }, (_, i) => ({ photoId: 700 + i, width: 4000, height: 3000 })),
lat: 68.2,
lng: 13.6,
country: 'NO',
dayNumber: 1,
dayCount: 1,
};
return { ...base.input, entries: [entry], candidates: [], photos: [] };
}
describe('the book on the designed templates', () => {
it('AUTODSN-001: is a valid book, as normalised, within the spread and element caps', () => {
for (const [name, input] of inputs()) {
for (const theme of THEMES) {
const { doc } = laid(input, theme);
expect(bookDocumentSchema.safeParse(doc).success, `${name} ${theme}`).toBe(true);
expect(normalizeBookDocument(doc), `${name} ${theme}`).toEqual(doc);
expect(doc.spreads.length).toBeLessThanOrEqual(MAX_SPREADS);
for (const s of doc.spreads) expect(s.elements.length).toBeLessThanOrEqual(MAX_SPREAD_ELEMENTS);
}
}
});
it("AUTODSN-002: the cover is a cover template of the theme's archetype", () => {
for (const [name, input] of inputs()) {
for (const theme of THEMES) {
const result = laid(input, theme);
const cover = result.doc.spreads[0] as BookSpread;
const id = result.plan[0]?.template;
if (!id) continue; // a journey without photos for any of its covers is composed
expect(findDecoratedTemplate(id)?.archetype, `${name} ${theme}`).toBe(THEME_ARCHETYPE[theme]);
expect(matchDecoratedTemplate(cover, result.doc.page)?.id, `${name} ${theme}`).toBe(id);
}
}
});
it('AUTODSN-003: every entry with something to show has a page, in the order of the journey', () => {
for (const [name, input] of inputs()) {
const { doc } = laid(input, 'fabric-classic');
const told = [...new Set(doc.spreads.flatMap((s) => (s.entryId == null ? [] : [s.entryId])))];
const substance = input.entries.filter((e) => e.photos.length || (e.story ?? '').trim()).map((e) => e.id);
for (const id of substance) expect(told, `${name} ${id}`).toContain(id);
const dated = [...input.entries].filter((e) => told.includes(e.id) && e.date);
const order = told.filter((id) => dated.some((e) => e.id === id));
const byDate = [...dated].sort((a, b) => (a.date ?? '').localeCompare(b.date ?? '')).map((e) => e.id);
expect(order, name).toEqual(byDate);
}
});
it('AUTODSN-004: no photo vanishes: it is on a page, parked with one, or said to be left out', () => {
for (const [name, input] of inputs()) {
for (const theme of THEMES) {
const result = laid(input, theme);
const on = new Set([
...photoIdsOn(result.doc.spreads, 'elements'),
...photoIdsOn(result.doc.spreads, 'parked'),
]);
const left = new Set(result.photos.left_out.map((l) => l.photoId));
const lost = journeyPhotos(input).filter((id) => !on.has(id) && !left.has(id));
expect(lost, `${name} ${theme}`).toEqual([]);
}
}
});
it('AUTODSN-005: never a classic summary, countries or mark', () => {
for (const [name, input] of inputs()) {
for (const theme of THEMES) {
for (const s of laid(input, theme).doc.spreads) {
for (const e of s.elements) {
if (e.kind === 'stats' || e.kind === 'countries') expect(e.look ?? 'classic', name).not.toBe('classic');
if (e.kind === 'badge') expect(['plain', 'chip'], name).not.toContain(e.style);
}
}
}
}
});
it('AUTODSN-006: is kept in its theme, and another theme recolours the templated pages', () => {
const input = inputs()[0]?.[1] as BookDesignInput;
const { doc, plan } = laid(input, 'riso');
expect(doc.theme?.id).toBe('riso');
const night = bookThemeOf('midnight');
const again = applyBookTheme(doc, night);
const templated = plan.flatMap((r, i) => (r.template ? [i] : []));
expect(templated.length).toBeGreaterThan(3);
for (const i of templated) {
const linked = (again.spreads[i] as BookSpread).elements.filter((e) => e.kind === 'text' && e.token === 'ink');
for (const e of linked) if (e.kind === 'text') expect(e.color).toBe(bookThemeColor(night, 'ink'));
}
expect(again.spreads.some((s, i) => JSON.stringify(s) !== JSON.stringify(doc.spreads[i]))).toBe(true);
});
it("AUTODSN-007: titles follow the entry's title and stories its story, a cut story going on over the next page", () => {
const input = parity.A as Fixture;
const { doc, notes } = laid({ ...input.input, candidates: [], photos: [] }, 'fabric-classic');
const pages = doc.spreads.filter((s) => s.entryId != null);
for (const s of pages) {
const bound = s.elements.some(
(e) => e.kind === 'text' && (e.binding?.source === 'entry.title' || e.binding?.source === 'entry.location'),
);
expect(bound, s.id).toBe(true);
}
const stories = pages.flatMap((s) =>
s.elements.filter((e) => e.kind === 'text' && e.binding?.source === 'entry.story'),
);
expect(stories.length).toBeGreaterThan(0);
const cut = stories.filter((e) => e.kind === 'text' && e.overridden);
if (cut.length) expect(notes.some((n) => n.includes('cut to fit'))).toBe(true);
for (const e of stories) if (e.kind === 'text' && !e.overridden) expect(e.text.length).toBeGreaterThan(0);
});
it('AUTODSN-008: an entry of 0 to 20 photos, with a story or none, fills every frame it is laid on', () => {
for (const n of [0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 20]) {
for (const story of [false, true]) {
if (!n && !story) continue;
const input = single(n, story);
const result = laid(input, 'fabric-classic');
const pages = result.doc.spreads.filter((s) => s.entryId === 1);
expect(pages.length, `${n} ${story}`).toBeGreaterThan(0);
for (const s of pages) {
for (const e of s.elements) if (e.kind === 'photo') expect(e.photoId, `${n} ${story}`).not.toBeNull();
}
const placed = photoIdsOn(pages, 'elements').length + photoIdsOn(pages, 'parked').length;
const capped = result.photos.left_out.filter((l) => l.reason === 'chapter_max').length;
expect(placed + capped, `${n} ${story}`).toBe(n);
expect(capped).toBe(Math.max(0, n - 12));
if (story) {
const told = pages.some((s) =>
s.elements.some((e) => e.kind === 'text' && e.binding?.source === 'entry.story'),
);
expect(told, `${n}`).toBe(true);
}
}
}
});
it("AUTODSN-009: the gallery's photos go to the entry of their day first, the rest to closing pages", () => {
const fx = parity.D as Fixture;
const input: BookDesignInput = { ...fx.input, gallery: fx.gallery, candidates: [], photos: [] };
const { doc } = laid(input, 'fabric-classic');
const of = (entryId: number) =>
photoIdsOn(
doc.spreads.filter((s) => s.entryId === entryId),
'elements',
);
// 900 was taken on 1 June, the day of entry 101, which had no photos of its own.
expect(of(101)).toContain(900);
// An entry already holding all it takes leaves the loose photos to the closing pages of more moments.
const full = single(12, false);
const loose = Array.from({ length: 10 }, (_, i) => ({
photoId: 800 + i,
width: 4000,
height: 3000,
takenAt: null,
}));
const more = laid({ ...full, gallery: loose }, 'fabric-classic');
const closing = more.doc.spreads.filter((s) => s.role === 'inner' && s.entryId === null);
const shown = photoIdsOn(closing, 'elements');
expect(shown.length).toBeGreaterThan(0);
expect(shown.every((id) => id >= 800)).toBe(true);
const left = more.photos.left_out.map((l) => l.photoId);
expect(loose.every((p) => shown.includes(p.photoId) || left.includes(p.photoId))).toBe(true);
});
it('AUTODSN-010: keeps a stored page that holds a locked element', () => {
const input = inputs()[0]?.[1] as BookDesignInput;
const first = laid(input, 'fabric-classic').doc;
const page = first.spreads[5] as BookSpread;
const locked: BookSpread = {
...page,
elements: page.elements.map((e, i) => (i === 0 ? { ...e, locked: true } : e)),
};
const stored: BookDocument = { ...first, spreads: first.spreads.map((s) => (s.id === page.id ? locked : s)) };
const again = designBook(
stored,
{ ...input, newId: counter() },
{ style: 'fabric-classic', layouts: 'templates', seed: 2 },
);
expect(again.doc.spreads).toContainEqual(locked);
expect(again.plan.find((r) => r.spreadId === page.id)?.action).toBe('keep');
});
it('AUTODSN-011: no page has a severe finding', () => {
for (const [name, input] of inputs()) {
for (const theme of THEMES) {
const { doc } = laid(input, theme);
doc.spreads.forEach((s, i) => {
const severe = pageFindings(s, doc.page, input.photos).filter((f) => f.severity === 'severe');
expect(
severe.map((f) => f.code),
`${name} ${theme} page ${i}`,
).toEqual([]);
});
}
}
});
it('AUTODSN-012: the plan names the template every templated page came from', () => {
const { plan, doc } = laid(inputs()[0]?.[1] as BookDesignInput, 'midnight');
const named = plan.filter((r) => r.template);
expect(named.length).toBeGreaterThan(3);
for (const r of named) {
expect(findDecoratedTemplate(r.template ?? '')).toBeDefined();
const s = doc.spreads.find((x) => x.id === r.spreadId) as BookSpread;
expect(matchDecoratedTemplate(s, doc.page)?.id, r.template).toBe(r.template);
}
});
it('AUTODSN-013: the same seed lays the same book, another seed another', () => {
const input = inputs()[0]?.[1] as BookDesignInput;
const ids = (seed: number) =>
laid(input, 'riso', { seed })
.plan.map((r) => r.template ?? r.beat)
.join();
expect(ids(4)).toBe(ids(4));
expect([5, 6, 7, 8].some((seed) => ids(seed) !== ids(4))).toBe(true);
});
it("AUTODSN-014: a theme of the book's own is kept whole", () => {
const own = {
...bookThemeOf('postcard'),
id: null,
palette: { ...bookThemeOf('postcard').palette, accent: '#123456' },
};
const input = inputs()[0]?.[1] as BookDesignInput;
const { doc } = designBook(
null,
{ ...input, theme: own, newId: counter() },
{ style: 'postcard', layouts: 'templates' },
);
expect(doc.theme).toEqual(own);
});
it('AUTODSN-015: without layouts the design is composed as before, with no template named', () => {
const input = inputs()[0]?.[1] as BookDesignInput;
const composed = designBook(null, { ...input, newId: counter() }, { style: 'riso', seed: 1 });
expect(composed.plan.every((r) => r.template === undefined)).toBe(true);
expect(composed.doc).toEqual(
designBook(null, { ...input, newId: counter() }, { style: 'riso', seed: 1, layouts: 'composed' }).doc,
);
});
it("AUTODSN-016: bare stops are listed on a words-only template of the book's styles, every name in its story", () => {
const base = parity.A as Fixture;
const entries = base.input.entries.map((e) => ({ ...e, story: null, photos: [] }));
const input = { ...base.input, entries, coverPhotoId: null, candidates: [], photos: [] };
for (const theme of THEMES) {
const { plan, doc } = laid(input, theme);
const lists = plan.filter((r) => r.beat === 'stations');
expect(lists.length, theme).toBeGreaterThan(0);
const words: string[] = [];
for (const r of lists) {
const tpl = findDecoratedTemplate(r.template ?? '');
expect(tpl && templatePhotoSlots(tpl), `${theme} ${r.template}`).toBe(0);
expect([THEME_ARCHETYPE[theme], ...ARCHETYPE_KIN[THEME_ARCHETYPE[theme]]]).toContain(tpl?.archetype);
const s = doc.spreads.find((x) => x.id === r.spreadId) as BookSpread;
const texts = s.elements.flatMap((e) => (e.kind === 'text' ? [e.text] : []));
expect(texts, theme).toContain(input.stationsLabel);
words.push(...texts);
}
const listed = words.join(' ');
for (const e of entries) {
const name = e.title || e.location || '';
if (name) expect(listed, `${theme} ${name}`).toContain(name);
}
}
});
it('AUTODSN-020: lays the parity journeys exactly as pinned', () => {
const actual = digests();
const differ = [...actual].filter(([k, v]) => expected[k] !== v).map(([k]) => k);
const missing = Object.keys(expected).filter((k) => !actual.has(k));
const message = `the templated auto layout changed; if that is deliberate, write design-templated.parity.json as:\n${JSON.stringify(Object.fromEntries([...actual].sort()), null, 1)}`;
expect(differ.length + missing.length, message).toBe(0);
});
});
// ── the digests ──────────────────────────────────────────────────────────
/** The canonical form auto-layout.parity.spec.ts digests: keys sorted, every id read as `#new`. */
function canonical(value: unknown): string {
const walk = (v: unknown, key?: string): unknown => {
if (Array.isArray(v)) return v.map((x) => walk(x));
if (v && typeof v === 'object') {
const o = v as Record<string, unknown>;
return Object.fromEntries(
Object.keys(o)
.sort()
.map((k) => [k, walk(o[k], k)]),
);
}
if (key === 'id' && typeof v === 'string') return '#new';
return v;
};
return JSON.stringify(walk(value));
}
const digest = (v: unknown) => createHash('sha256').update(canonical(v)).digest('hex').slice(0, 16);
/** Every page of every parity journey in every theme, digested. */
function digests(): Map<string, string> {
const out = new Map<string, string>();
for (const [name, fx] of Object.entries(parity)) {
const input = { ...fx.input, gallery: fx.gallery, candidates: [], photos: [] };
for (const theme of THEMES) {
const result = laid(input, theme);
const doc = result.doc;
const put = (key: string, v: unknown) => out.set(`${name}/${theme}/${key}`, digest(v));
put('head', {
version: doc.version,
title: doc.title,
page: doc.page,
theme: doc.theme,
count: doc.spreads.length,
});
put(
'plan',
result.plan.map((r) => [r.beat, r.template ?? null, r.entryId, r.photoIds]),
);
doc.spreads.forEach((s, i) => put(`spread/${i}`, s));
}
}
return out;
}
+463
View File
@@ -0,0 +1,463 @@
import type { AutoEntry, AutoPhoto } from '../auto/auto-types';
import type { BookCoverArchetype } from '../book-archetypes';
import { DESIGN_TEXT_HEADROOM, textHeightNeeded } from '../book-findings';
import { formatBookDate } from '../book-text';
import type { BookElement, BookSpread, BookTextElement } from '../book.schema';
import { decoratedTemplatesFor } from '../templates/template-catalogue';
import { templatePhotoSlots, templateRoles, type BookDecoratedTemplate } from '../templates/template-format';
import type { BookTemplateWord } from '../templates/template-words';
import type { BookDesignInput } from './design-book';
import { cutWords, liveBox, photoEl, textEl, type ComposeCtx } from './design-compose';
import { composeStations } from './design-framing';
import type { FramingNeeds } from './design-inner';
import type { DesignedSpread } from './design-merge';
import type { BookDesignBeat } from './design-pacing';
import { buildChapters, type DesignChapter } from './design-pacing';
import type { BookDesignLeftOut, DesignPhotoChoice } from './design-photos';
import { innerSpread } from './design-spreads';
import {
bodyCapacity,
chunkPhotos,
chunkSizesOf,
innerPool,
type ChunkSizes,
pickInnerTemplate,
templateEnergy,
type TemplatePickCtx,
} from './design-template-pick';
import {
composeTravelCountries,
composeTravelSummary,
composeWordsPage,
countryLookFor,
pourTemplate,
summaryLookFor,
} from './design-templated-framing';
/**
* The inner pages of a book laid on the designed templates.
*
* The journey at a glance and its countries open it, in the travel looks.
* Then every chapter: its photos cut into page-sized parts that leave no
* frame empty (`chunkPhotos`), the first part carrying the chapter's words,
* and each part laid on the decorated template that suits it best
* (`pickInnerTemplate`), in the book's theme, its title, place, date and
* story bound to the journal. A story too long for its frame is cut at a
* sentence and goes on over a page of words. Runs of stops nobody wrote
* about share a list as in every design. Photos of the journey's gallery that
* found no entry with room close the book on pages of more moments.
*/
interface TemplatedInnerResult {
spreads: DesignedSpread[];
leftOut: BookDesignLeftOut[];
notes: string[];
}
/** What the inner pages are laid with beside the composer's context. */
interface TemplatedPlan {
archetype: BookCoverArchetype;
/** The book counts as calm for template choice: its busy pages stay few. */
calm: boolean;
/** Loose photos of the journey no entry had room for, for the closing pages of more moments. */
rest: readonly AutoPhoto[];
}
/** The closing pages of more moments a book takes at most. */
const MORE_MOMENTS = 3;
/** Pages of words one story runs on to at most past its own. */
const MOST_CONTINUED = 2;
/** The classic mark styles: never set by the auto layout, which sets a date mark as a stamp. */
const CLASSIC_MARKS = new Set(['plain', 'chip', 'outline', 'stacked']);
/** The beat a templated page plays in the plan, read from its template's energy and photos. */
function beatOf(tpl: BookDecoratedTemplate | null, photos: number): BookDesignBeat {
if (!tpl) return photos ? 'grid' : 'story';
const energy = templateEnergy(tpl);
if (energy === 'loud') return 'hero';
if (energy === 'lively' || energy === 'medium') return photos >= 4 ? 'grid' : photos >= 2 ? 'duo' : 'quiet';
return photos === 0 ? 'story' : 'quiet';
}
/** The words of a story the directives let a book set: all, an excerpt, or none. */
function storyOf(c: ComposeCtx, entry: AutoEntry | undefined): string {
const words = (entry?.story ?? '').trim();
const mode = c.directives.text?.story ?? 'full';
if (!words || mode === 'none') return '';
return mode === 'excerpt' ? cutWords(words, c.directives.text?.excerptChars ?? 600) : words;
}
/**
* A page holding an entry's content for a template to pour: the story (bound
* when it is the whole of it), the first photo's caption when captions are
* asked of each photo, and the photos in order. The title, place and date the
* template takes from the journal itself, bound to the entry.
*/
function entrySeedSpread(
c: ComposeCtx,
entry: AutoEntry,
photos: readonly AutoPhoto[],
story: { words: string; whole: boolean } | null,
): BookSpread {
const box = liveBox(c, 'right');
const els: BookElement[] = [];
if (story?.words) {
const binding = { source: 'entry.story' as const, entryId: entry.id };
els.push(textEl(c, box, story.words, 'body', { binding, overridden: !story.whole }));
}
const caption = photos[0]?.caption?.trim();
const first = photos[0];
if (caption && first && c.directives.text?.captions === 'photo') {
const binding = { source: 'photo.caption' as const, photoId: first.photoId };
els.push(textEl(c, box, caption, 'caption', { binding }));
}
els.push(...photos.map((p) => photoEl(c, p, box)));
return innerSpread(c, els, entry.id);
}
/** The longest start of `words` its laid frame holds, cut at a sentence where one is near. */
function cutToFrame(el: BookTextElement, words: string): string {
const fits = (s: string) => textHeightNeeded({ ...el, text: s }, DESIGN_TEXT_HEADROOM) <= el.frame.h;
if (fits(words)) return words;
let lo = 0;
let hi = words.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (fits(`${words.slice(0, mid)}…`)) lo = mid;
else hi = mid - 1;
}
return cutWords(words, lo);
}
const isStory = (e: BookElement): e is BookTextElement => e.kind === 'text' && e.binding?.source === 'entry.story';
/**
* A laid page with its story cut to its frame where it runs over: marked as
* no longer following the journal and said in the notes, the rest handed
* back for the next page. A story that fits stays as it is.
*/
function fittedStory(c: ComposeCtx, spread: BookSpread, entry: AutoEntry): { spread: BookSpread; rest: string } {
const story = spread.elements.find(isStory);
if (!story) return { spread, rest: '' };
const fitting = cutToFrame(story, story.text);
if (fitting === story.text) return { spread, rest: '' };
c.notes.push(`the story of entry ${entry.id} is cut to fit its page and no longer follows the journal`);
const rest = story.text.slice(fitting.replace(/…$/, '').length).trim();
const elements = fitting
? spread.elements.map((e) => (e === story ? { ...story, text: fitting, overridden: true } : e))
: spread.elements.filter((e) => e !== story);
return { spread: { ...spread, elements }, rest };
}
/** A laid page as the auto layout leaves it: date marks as stamps, and only its photos parked. */
function tidied(spread: BookSpread): BookSpread {
const elements = spread.elements.map((e) =>
e.kind === 'badge' && CLASSIC_MARKS.has(e.style) ? { ...e, style: 'stamp' as const } : e,
);
// The words a template has no role for are the journal's own, bound to it: nothing is lost leaving them.
return { ...spread, elements, parked: spread.parked.filter((e) => e.kind === 'photo') };
}
/** The pick context for the next page of the book. */
type PickBase = Omit<TemplatePickCtx, 'history' | 'key'>;
interface Laying {
c: ComposeCtx;
base: PickBase;
/** Template ids laid so far, in order, for the picker's variety and rhythm. */
history: string[];
entries: readonly AutoEntry[];
out: DesignedSpread[];
}
/** A story to set on a page: its words, and whether they are the whole of the entry's. */
export type PageStory = { words: string; whole: boolean } | null;
/** One page as it is laid: the spread, the template it was laid on (none for a composed one) and the story's rest. */
export interface LaidPage {
spread: BookSpread;
template: BookDecoratedTemplate | null;
rest: string;
}
/**
* One page of an entry laid on the template that suits it best, in the
* book's theme, or composed as a page of words where none takes it; the
* story cut to its frame, its rest handed back for the next page.
*/
export function layEntryPage(
c: ComposeCtx,
entry: AutoEntry,
page: { photos: readonly AutoPhoto[]; story: PageStory; entries: readonly AutoEntry[] },
pick: TemplatePickCtx,
): LaidPage {
const mappable = (entry.lat != null && entry.lng != null) || !!c.travel.stats?.points.length;
const chunk = { photos: page.photos, story: page.story?.words ?? '', mappable };
const tpl = pickInnerTemplate(chunk, pick);
if (!tpl) {
const fitted = fittedStory(c, composeWordsPage(c, entry, wordsColumn(c, entry, page.story)), entry);
return { ...fitted, template: null };
}
const laid = tidied(pourTemplate(c, entrySeedSpread(c, entry, page.photos, page.story), tpl, page.entries));
return { ...fittedStory(c, laid, entry), template: tpl };
}
/** One page of the book laid and added; the rest of its story handed back. */
function layPage(
l: Laying,
entry: AutoEntry,
photos: readonly AutoPhoto[],
story: PageStory,
chapter: string | null,
): string {
const key = `e${entry.id}:${l.out.length}`;
const laid = layEntryPage(l.c, entry, { photos, story, entries: l.entries }, { ...l.base, history: l.history, key });
if (laid.template) l.history.push(laid.template.id);
l.out.push({
spread: laid.spread,
beat: laid.template ? beatOf(laid.template, photos.length) : 'story',
chapter,
findings: [],
...(laid.template ? { template: laid.template.id } : {}),
});
return laid.rest;
}
/**
* A story set in a column down the left page, where no template takes it:
* bound to the entry while it is the whole of it, for `fittedStory` to cut.
*/
function wordsColumn(c: ComposeCtx, entry: AutoEntry, story: PageStory) {
if (!story?.words) return null;
const binding = { source: 'entry.story' as const, entryId: entry.id };
const el = textEl(c, liveBox(c, 'left'), story.words, 'body', { binding, overridden: !story.whole });
return { ...el, frame: liveBox(c, 'left') };
}
/** The best capacity of the pool's story templates for each photo count, for what a long story costs a page. */
function storyRoom(c: ComposeCtx, pool: readonly BookDecoratedTemplate[]): Map<number, number> {
const room = new Map<number, number>();
for (const t of pool) {
const cap = bodyCapacity(t, c.page, c.look.theme);
if (cap > 0) room.set(templatePhotoSlots(t), Math.max(room.get(templatePhotoSlots(t)) ?? 0, cap));
}
return room;
}
/** A first page whose story frame cannot hold the story costs this much more: a page of words follows. */
const SHORT_STORY_PAGE = 12;
/** A page only another archetype has a template for costs this much more, so as few as can be are. */
const FOREIGN_PAGE = 8;
/**
* The photo counts a chapter's pages may take: those of the book's own pool
* of templates, the pool the picker lays them from. `wide` adds those only
* another archetype has, at a price, for a chapter the own pool would leave
* a photo of without a frame.
*/
function chunkSizesFor(c: ComposeCtx, archetype: BookCoverArchetype, words: string, wide = false): ChunkSizes {
const pool = innerPool(archetype);
const own = chunkSizesOf(pool);
const sizes = wide ? chunkSizesOf(decoratedTemplatesFor('inner')) : own;
const room = storyRoom(c, words ? (wide ? decoratedTemplatesFor('inner') : pool) : []);
const pageCost = (s: number, first: boolean) => {
const foreign = !(first && words ? own.story : own.any).has(s);
const short = first && words && (room.get(s) ?? 0) < words.length;
return (foreign ? FOREIGN_PAGE : 0) + (short ? SHORT_STORY_PAGE : 0);
};
return { any: sizes.any, story: sizes.story, pageCost };
}
/** A chapter's photos cut into pages of the own pool, or of any where the own pool leaves one without a frame. */
function cutChapter(c: ComposeCtx, archetype: BookCoverArchetype, words: string, photos: number) {
const own = chunkPhotos(photos, !!words, chunkSizesFor(c, archetype, words));
if (!own.parked) return own;
const wide = chunkPhotos(photos, !!words, chunkSizesFor(c, archetype, words, true));
return wide.parked < own.parked ? wide : own;
}
/** A chapter's pages: its photos cut into parts, the first taking its first entry's words, then the rest of them. */
function chapterPages(l: Laying, chapter: DesignChapter, notes: string[]) {
const entry = chapter.entries[0];
if (!entry) return;
const name = chapter.label ?? entry.title ?? null;
const words = storyOf(l.c, entry);
const cut = cutChapter(l.c, l.base.archetype, words, chapter.photos.length);
let at = 0;
let rest = '';
cut.sizes.forEach((size, i) => {
const story = i === 0 && words ? { words, whole: words === (entry.story ?? '').trim() } : null;
rest = layPage(l, entry, chapter.photos.slice(at, at + size), story, name) || rest;
at += size;
});
const parked = chapter.photos.slice(at);
const last = l.out[l.out.length - 1];
if (parked.length && last) {
last.spread = {
...last.spread,
parked: [...last.spread.parked, ...parked.map((p) => photoEl(l.c, p, liveBox(l.c, 'right')))],
};
const what = parked.length === 1 ? '1 photo' : `${parked.length} photos`;
notes.push(`${what} of entry ${entry.id} found no frame and wait with its last page`);
}
for (let n = 0; rest && n < MOST_CONTINUED; n++) rest = layPage(l, entry, [], { words: rest, whole: false }, name);
if (rest) notes.push(`part of the story of entry ${entry.id} found no room on its pages and is left out`);
// The further entries of a chapter of a day or a place each tell their own story on a page of words.
for (const more of chapter.entries.slice(1)) {
const told = storyOf(l.c, more);
if (told) layPage(l, more, [], { words: told, whole: told === (more.story ?? '').trim() }, name);
}
}
/** The most stops one page of the list takes, so a page of them still reads as a list. */
const STOPS_PER_PAGE = 10;
/** Bare stops as a list: under each date the names of the stops of that day, a blank line between days. */
function stopList(c: ComposeCtx, stops: readonly AutoEntry[], dated: boolean): string {
const days: { date: string; names: string[] }[] = [];
for (const e of stops) {
const date = e.date && dated ? formatBookDate(e.date, c.locale) : '';
const name = (e.title || e.location || '').trim();
if (!name) continue;
const day = days[days.length - 1];
if (day && day.date === date) day.names.push(name);
else days.push({ date, names: [name] });
}
return days.map((d) => [d.date, ...d.names].filter(Boolean).join('\n')).join('\n\n');
}
/** The one day all these stops were on, bound to the first of them, as a date role takes it; null without one. */
function oneDay(c: ComposeCtx, stops: readonly AutoEntry[]): BookTemplateWord | null {
const days = new Set(stops.map((e) => e.date ?? ''));
const [day] = days;
const first = stops[0];
if (days.size !== 1 || !day || !first) return null;
return { text: formatBookDate(day, c.locale), binding: { source: 'entry.date', entryId: first.id, value: day } };
}
/**
* One page of bare stops on a words-only template of the book's styles: the
* list's label as its title, the stops as its story (given by role, since a
* short list that opens on a date would read as a date line), as many as its
* story frame holds. Stops of one day set that day in the template's date
* and only their names in the list. Undefined when no template takes a list.
*/
function stationsPage(l: Laying, stops: readonly AutoEntry[], label: string) {
for (let n = Math.min(stops.length, STOPS_PER_PAGE); n > 0; n--) {
const some = stops.slice(0, n);
const key = `stations:${l.out.length}`;
const pick = (list: string) =>
pickInnerTemplate({ photos: [], story: list, mappable: false }, { ...l.base, history: l.history, key });
const tpl = pick(stopList(l.c, some, true));
if (!tpl) return undefined;
const day = templateRoles(tpl).includes('date') ? oneDay(l.c, some) : null;
const list = stopList(l.c, some, !day);
if (bodyCapacity(tpl, l.c.page, l.c.look.theme) < list.length && n > 1) continue;
const words = {
title: { text: label },
body: { text: list },
...(day ? { date: day } : {}),
};
return { spread: tidied(pourTemplate(l.c, innerSpread(l.c, [], null), tpl, l.entries, words)), tpl, placed: n };
}
return undefined;
}
/** The pages of a list of bare stops: as many as the stops need, each on a words-only template where one takes it. */
function stationsPages(l: Laying, chapter: DesignChapter, input: BookDesignInput) {
let rest = chapter.entries;
while (rest.length) {
const page = stationsPage(l, rest, input.stationsLabel);
if (page) {
l.history.push(page.tpl.id);
l.out.push({ spread: page.spread, beat: 'stations', chapter: null, findings: [], template: page.tpl.id });
rest = rest.slice(page.placed);
continue;
}
const set = composeStations(l.c, rest, input.stationsLabel);
if (!set.placed) break;
l.out.push({ spread: innerSpread(l.c, set.elements, null), beat: 'stations', chapter: null, findings: [] });
rest = rest.slice(set.placed);
}
}
/** The closing pages of more moments: the photos no entry had room for, on grids, at most three pages. */
function moreMoments(l: Laying, rest: readonly AutoPhoto[], leftOut: BookDesignLeftOut[]) {
let left = [...rest];
const sizes = chunkSizesOf(innerPool(l.base.archetype)).any;
const biggest = Math.max(...[...sizes].filter((s) => s > 0), 1);
for (let n = 0; left.length && n < MORE_MOMENTS; n++) {
const take = left.slice(0, biggest);
const tpl = pickInnerTemplate(
{ photos: take, story: '', mappable: false },
{ ...l.base, history: l.history, key: `more:${n}` },
);
if (!tpl) break;
const used = take.slice(0, templatePhotoSlots(tpl));
const seed = innerSpread(
l.c,
used.map((p) => photoEl(l.c, p, liveBox(l.c, 'right'))),
null,
);
const laid = tidied(pourTemplate(l.c, seed, tpl, l.entries));
l.history.push(tpl.id);
l.out.push({ spread: laid, beat: beatOf(tpl, used.length), chapter: null, findings: [], template: tpl.id });
left = left.slice(used.length);
}
for (const p of left) leftOut.push({ photoId: p.photoId, reason: 'page_budget' });
}
/** The pages past the budget are left out from the end, their photos said to be left out for it. */
function withinCap(body: DesignedSpread[], keep: number, leftOut: BookDesignLeftOut[], notes: string[]) {
if (body.length <= keep) return body;
const cut = body.slice(Math.max(0, keep));
for (const d of cut) {
for (const e of [...d.spread.elements, ...d.spread.parked]) {
if (e.kind === 'photo' && e.photoId != null) leftOut.push({ photoId: e.photoId, reason: 'page_budget' });
}
}
notes.push(`${cut.length} pages past the page budget are left out`);
return body.slice(0, Math.max(0, keep));
}
/** The journey at a glance and its countries, as the directives allow them. */
function framingPages(c: ComposeCtx, archetype: BookCoverArchetype): DesignedSpread[] {
const travel = c.directives.travel;
const out: DesignedSpread[] = [];
const summary = (travel?.summary ?? true) ? composeTravelSummary(c, summaryLookFor(archetype)) : null;
if (summary) out.push({ spread: summary, beat: 'summary', chapter: null, findings: [] });
const countries = (travel?.countries ?? true) ? composeTravelCountries(c, countryLookFor(archetype)) : null;
if (countries) out.push({ spread: countries, beat: 'countries', chapter: null, findings: [] });
return out;
}
/** Every inner page of a book laid on the designed templates, in order. */
export function composeTemplatedInner(
c: ComposeCtx,
entries: readonly AutoEntry[],
choice: DesignPhotoChoice,
input: BookDesignInput,
needs: FramingNeeds,
plan: TemplatedPlan,
): TemplatedInnerResult {
const notes: string[] = [];
const leftOut: BookDesignLeftOut[] = [];
const cap = Math.max(0, Math.min(c.directives.pages?.max ?? needs.room, needs.room));
const head = framingPages(c, plan.archetype).slice(0, Math.max(0, cap - 1));
const base: PickBase = {
archetype: plan.archetype,
page: c.page,
theme: c.look.theme,
calm: plan.calm,
seed: c.directives.seed ?? 0,
};
const l: Laying = { c, base, history: [], entries, out: [] };
for (const chapter of buildChapters(entries, choice.byEntry, c.directives, input.candidates)) {
if (chapter.stations) stationsPages(l, chapter, input);
else chapterPages(l, chapter, notes);
}
moreMoments(l, plan.rest, leftOut);
const body = withinCap(l.out, cap - head.length, leftOut, notes);
return { spreads: [...head, ...body], leftOut, notes };
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,844 @@
import type { BookDecoratedTemplate } from '../template-format';
/**
* More grid and badge pages: a cover of eight tiles round a red centre
* badge, a words only spread with a large seal and the story, and a spread
* of a hero photo with a scalloped badge beside six tall tiles. Drawn in
* grid-badge.
*
* Drawn on a 210 mm page and kept as fractions of it (template-format.ts),
* in the colours and faces of the theme named above; their links let any
* other theme recolour them.
*/
export const MORE_GRID_BADGE: readonly BookDecoratedTemplate[] = [
{
id: 'grid-badge-tiles',
labelKey: 'studio.templates.name.gridBadgeTiles',
kind: 'single',
archetype: 'grid-badge',
tags: ['grid', 'eight-photos', 'badge', 'tiles', 'red', 'postmark', 'graphic'],
descr:
'Eight bleed photos three by three with 4 mm white gutters round a red centre tile holding a white hairline ring, a script line, the title in heavy capitals, a short rule and the year, an ink postmark on its corner.',
background: null,
backgroundToken: 'paper',
elements: [
{
id: 'p1',
role: 'photo:1',
frame: { x: -0.0143, y: -0.0143, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p2',
role: 'photo:2',
frame: { x: 0.3349, y: -0.0143, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p3',
role: 'photo:3',
frame: { x: 0.6841, y: -0.0143, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p4',
role: 'photo:4',
frame: { x: -0.0143, y: 0.3349, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p5',
role: 'photo:5',
frame: { x: 0.6841, y: 0.3349, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p6',
role: 'photo:6',
frame: { x: -0.0143, y: 0.6841, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p7',
role: 'photo:7',
frame: { x: 0.3349, y: 0.6841, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p8',
role: 'photo:8',
frame: { x: 0.6841, y: 0.6841, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 's9',
frame: { x: 0.3349, y: 0.3349, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'rect',
fill: '#e2412f',
gradient: 'none',
stroke: null,
token: 'accent',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 's10',
keep: true,
frame: { x: 0.3619, y: 0.3619, w: 0.2762, h: 0.2762 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'ellipse',
fill: null,
gradient: 'none',
stroke: '#ffffff',
strokeToken: 'paper',
strokeWidth: 0.0019,
strokeStyle: 'solid',
radius: 0,
},
{
id: 't11',
role: 'subtitle',
keep: true,
on: 'accent',
frame: { x: 0.4048, y: 0.3952, w: 0.1905, h: 0.0476 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'allura',
size: 0.0667,
weight: 400,
italic: false,
align: 'center',
leading: 1,
tracking: 0,
color: '#ffffff',
token: 'paper',
fontRole: 'script',
binding: null,
overridden: false,
vAlign: 'bottom',
fit: 'shrink',
fitMax: 0.0667,
},
{
id: 't12',
role: 'title',
keep: true,
on: 'accent',
frame: { x: 0.3857, y: 0.4476, w: 0.2286, h: 0.0857 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0952,
weight: 800,
italic: false,
align: 'center',
leading: 0.95,
tracking: 0,
color: '#ffffff',
token: 'paper',
fontRole: 'display',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0952,
},
{
id: 's13',
keep: true,
frame: { x: 0.4762, y: 0.5429, w: 0.0476, h: 0.0038 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'rect',
fill: '#ffffff',
gradient: 'none',
stroke: null,
token: 'paper',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 't14',
role: 'date',
keep: true,
on: 'accent',
frame: { x: 0.4048, y: 0.5571, w: 0.1905, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0333,
weight: 600,
italic: false,
align: 'center',
leading: 1,
tracking: 0.4,
color: '#ffffff',
token: 'paper',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0333,
},
{
id: 's15',
tint: { colors: ['ink'] },
frame: { x: 0.6, y: 0.6476, w: 0.1714, h: 0.0997 },
rotation: -12,
opacity: 0.85,
locked: false,
kind: 'sticker',
stickerId: 'postmark',
colors: ['#141414'],
flipX: false,
},
],
},
{
id: 'grid-badge-letter',
labelKey: 'studio.templates.name.gridBadgeLetter',
kind: 'inner',
archetype: 'grid-badge',
tags: ['words-only', 'story', 'seal', 'badge', 'squared-paper', 'airmail', 'calm'],
descr:
'The left page cream squared paper with a large red seal holding the subtitle round its rim, the title in heavy capitals and the date; the right page a dashed airmail line to a small plane, the long story and a passport stamp.',
background: null,
backgroundToken: 'paper',
elements: [
{
id: 's1',
tint: { fg: 'accent3', bg: 'accent2' },
frame: { x: -0.0143, y: -0.0143, w: 1.0143, h: 1.0286 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'shape',
shape: 'rect',
fill: '#6f6f6f',
gradient: 'none',
paint: { type: 'pattern', pattern: 'grid', fg: '#6f6f6f', bg: '#f2eee7', scale: 1.2, angle: 0 },
stroke: null,
token: 'accent3',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 's2',
keep: true,
frame: { x: 0.1857, y: 0.1457, w: 0.6286, h: 0.6286 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'seal',
fill: '#e2412f',
gradient: 'none',
stroke: null,
token: 'accent',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 's3',
keep: true,
frame: { x: 0.2429, y: 0.2029, w: 0.5143, h: 0.5143 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'ellipse',
fill: null,
gradient: 'none',
stroke: '#ffffff',
strokeToken: 'paper',
strokeWidth: 0.0019,
strokeStyle: 'solid',
radius: 0,
},
{
id: 't4',
role: 'subtitle',
keep: true,
on: 'accent',
frame: { x: 0.2619, y: 0.2219, w: 0.4762, h: 0.4762 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0429,
weight: 600,
italic: false,
align: 'center',
leading: 1,
tracking: 0.3,
color: '#ffffff',
token: 'paper',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
curve: { type: 'circle', bend: 40, start: 0 },
fit: 'shrink',
fitMax: 0.0429,
},
{
id: 't5',
role: 'title',
keep: true,
on: 'accent',
frame: { x: 0.3095, y: 0.3905, w: 0.381, h: 0.1238 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.1524,
weight: 800,
italic: false,
align: 'center',
leading: 0.95,
tracking: 0,
color: '#ffffff',
token: 'paper',
fontRole: 'display',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.1524,
},
{
id: 's6',
keep: true,
frame: { x: 0.4619, y: 0.5238, w: 0.0762, h: 0.0038 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'rect',
fill: '#ffffff',
gradient: 'none',
stroke: null,
token: 'paper',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 't7',
role: 'date',
keep: true,
on: 'accent',
frame: { x: 0.3333, y: 0.5381, w: 0.3333, h: 0.0381 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 600,
italic: false,
align: 'center',
leading: 1,
tracking: 0.4,
color: '#ffffff',
token: 'paper',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 'l8',
tint: { stroke: 'ink' },
frame: { x: 1.0762, y: 0.0833, w: 0.6762, h: 0.0143 },
rotation: 0,
opacity: 1,
locked: false,
group: 'head',
kind: 'line',
points: [
{ x: 0, y: 0.5 },
{ x: 1, y: 0.5 },
],
curve: 'straight',
stroke: '#141414',
strokeWidth: 0.0029,
dash: 'dashed',
squiggle: 'none',
startCap: 'none',
endCap: 'none',
wobble: 0,
ornament: 'none',
},
{
id: 's9',
tint: { colors: ['accent3', 'accent', 'paper'] },
frame: { x: 1.7714, y: 0.0476, w: 0.1524, h: 0.0912 },
rotation: -8,
opacity: 1,
locked: false,
group: 'head',
kind: 'sticker',
stickerId: 'airplane',
colors: ['#6f6f6f', '#e2412f', '#ffffff'],
flipX: false,
},
{
id: 't10',
role: 'body',
frame: { x: 1.0762, y: 0.1619, w: 0.6667, h: 0.7333 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'text',
text: '',
font: 'inter',
size: 0.0476,
weight: 400,
italic: false,
align: 'left',
leading: 1.55,
tracking: 0,
color: '#141414',
token: 'ink',
fontRole: 'body',
binding: null,
overridden: false,
vAlign: 'top',
},
{
id: 's11',
tint: { colors: ['accent'] },
frame: { x: 1.7524, y: 0.7333, w: 0.1905, h: 0.1333 },
rotation: 14,
opacity: 0.9,
locked: false,
kind: 'sticker',
stickerId: 'passportStamp',
colors: ['#e2412f'],
flipX: false,
},
],
},
{
id: 'grid-badge-hero-six',
labelKey: 'studio.templates.name.gridBadgeHeroSix',
kind: 'inner',
archetype: 'grid-badge',
tags: ['grid', 'seven-photos', 'hero', 'badge', 'scalloped', 'full-bleed', 'graphic'],
descr:
'A bleed hero photo on the left page with a white scalloped badge low on it, a dashed red ring, a red pin, the subtitle, the title and the date; six tall photos three by two on the right page with 3 mm white gutters.',
background: null,
backgroundToken: 'paper',
elements: [
{
id: 'p1',
role: 'photo:1',
frame: { x: -0.0143, y: -0.0143, w: 1.0072, h: 1.0286 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.6,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p2',
role: 'photo:2',
frame: { x: 1.0071, y: -0.0143, w: 0.3262, h: 0.5071 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p3',
role: 'photo:3',
frame: { x: 1.3476, y: -0.0143, w: 0.3262, h: 0.5071 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p4',
role: 'photo:4',
frame: { x: 1.6881, y: -0.0143, w: 0.3262, h: 0.5071 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p5',
role: 'photo:5',
frame: { x: 1.0071, y: 0.5071, w: 0.3262, h: 0.5071 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p6',
role: 'photo:6',
frame: { x: 1.3476, y: 0.5071, w: 0.3262, h: 0.5071 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p7',
role: 'photo:7',
frame: { x: 1.6881, y: 0.5071, w: 0.3262, h: 0.5071 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 's8',
keep: true,
frame: { x: 0.0857, y: 0.5333, w: 0.381, h: 0.381 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'scallop-circle',
fill: '#ffffff',
gradient: 'none',
stroke: null,
token: 'paper',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 's9',
keep: true,
frame: { x: 0.1048, y: 0.5524, w: 0.3429, h: 0.3429 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'ellipse',
fill: null,
gradient: 'none',
stroke: '#e2412f',
strokeToken: 'accent',
strokeWidth: 0.0029,
strokeStyle: 'dashed',
radius: 0,
},
{
id: 's10',
tint: { colors: ['accent', 'paper'] },
frame: { x: 0.2381, y: 0.4952, w: 0.0762, h: 0.1088 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'sticker',
stickerId: 'mapPin',
colors: ['#e2412f', '#ffffff'],
flipX: false,
},
{
id: 't11',
role: 'subtitle',
keep: true,
on: 'paper',
frame: { x: 0.1381, y: 0.6333, w: 0.2762, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.031,
weight: 600,
italic: false,
align: 'center',
leading: 1,
tracking: 0.32,
color: '#5b5b5b',
token: 'muted',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.031,
},
{
id: 't12',
role: 'title',
keep: true,
on: 'paper',
frame: { x: 0.1238, y: 0.6762, w: 0.3048, h: 0.1048 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.1238,
weight: 800,
italic: false,
align: 'center',
leading: 0.95,
tracking: 0,
color: '#141414',
token: 'ink',
fontRole: 'display',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.1238,
},
{
id: 's13',
keep: true,
frame: { x: 0.2381, y: 0.7905, w: 0.0762, h: 0.0038 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'rect',
fill: '#e2412f',
gradient: 'none',
stroke: null,
token: 'accent',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 't14',
role: 'date',
keep: true,
on: 'paper',
frame: { x: 0.1714, y: 0.8048, w: 0.2095, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0333,
weight: 600,
italic: false,
align: 'center',
leading: 1,
tracking: 0.4,
color: '#141414',
token: 'ink',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0333,
},
],
},
];
@@ -0,0 +1,637 @@
import type { BookDecoratedTemplate } from '../template-format';
/**
* More minimal serif pages: an oval cameo cover inside a hairline frame, a
* words only essay spread and a triptych of three numbered portrait plates.
* Drawn in minimal-serif.
*
* Drawn on a 210 mm page and kept as fractions of it (template-format.ts),
* in the colours and faces of the theme named above; their links let any
* other theme recolour them.
*/
export const MORE_MINIMAL_SERIF: readonly BookDecoratedTemplate[] = [
{
id: 'minimal-serif-cameo',
labelKey: 'studio.templates.name.minimalSerifCameo',
kind: 'single',
archetype: 'minimal-serif',
tags: ['minimal', 'serif', 'white', 'oval', 'cameo', 'hairline', 'elegant'],
descr:
'A white page inside a hairline frame: the year in spaced capitals at the head, an oval photo in a fine accent ring, a large thin italic serif title, a diamond rule and the subtitle.',
background: null,
backgroundToken: 'paper',
elements: [
{
id: 's1',
frame: { x: 0.0476, y: 0.0476, w: 0.9048, h: 0.9048 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'shape',
shape: 'rect',
fill: null,
gradient: 'none',
stroke: '#222222',
strokeToken: 'ink',
strokeWidth: 0.0012,
strokeStyle: 'solid',
radius: 0,
},
{
id: 't2',
role: 'date',
frame: { x: 0.3333, y: 0.0857, w: 0.3333, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'center',
leading: 1.4,
tracking: 0.45,
color: '#222222',
token: 'ink',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 's3',
keep: true,
frame: { x: 0.2952, y: 0.1429, w: 0.4095, h: 0.4952 },
rotation: 0,
opacity: 1,
locked: false,
group: 'picture',
kind: 'shape',
shape: 'ellipse',
fill: null,
gradient: 'none',
stroke: '#857d72',
strokeToken: 'accent',
strokeWidth: 0.0012,
strokeStyle: 'solid',
radius: 0,
},
{
id: 'p4',
role: 'photo:1',
keep: true,
tint: { border: 'ink' },
frame: { x: 0.3143, y: 0.1667, w: 0.3714, h: 0.4476 },
rotation: 0,
opacity: 1,
locked: false,
group: 'picture',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.45,
radius: 0,
filter: 'none',
mask: 'ellipse',
frameStyle: 'none',
border: { width: 0.0014, color: '#222222' },
},
{
id: 't5',
role: 'title',
frame: { x: 0.0952, y: 0.6619, w: 0.8095, h: 0.1476 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'cormorant',
size: 0.3333,
weight: 300,
italic: true,
align: 'center',
leading: 0.95,
tracking: 0,
color: '#222222',
token: 'ink',
fontRole: 'display',
binding: null,
overridden: false,
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.3333,
},
{
id: 'l6',
tint: { stroke: 'accent' },
frame: { x: 0.4048, y: 0.819, w: 0.1905, h: 0.0286 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'line',
points: [
{ x: 0, y: 0.5 },
{ x: 1, y: 0.5 },
],
curve: 'straight',
stroke: '#857d72',
strokeWidth: 0.0012,
dash: 'solid',
squiggle: 'none',
startCap: 'none',
endCap: 'none',
wobble: 0,
ornament: 'diamond',
},
{
id: 't7',
role: 'subtitle',
frame: { x: 0.1429, y: 0.8571, w: 0.7143, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'center',
leading: 1.4,
tracking: 0.32,
color: '#646464',
token: 'muted',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0381,
},
],
},
{
id: 'minimal-serif-essay',
labelKey: 'studio.templates.name.minimalSerifEssay',
kind: 'inner',
archetype: 'minimal-serif',
tags: ['minimal', 'serif', 'words-only', 'essay', 'story', 'ornament', 'calm'],
descr:
'Words only: on the left page a fleuron, the date in spaced capitals, a large thin serif title, a diamond rule and the subtitle, centred; on the right a hairline, the long story in one column and a star rule.',
background: null,
backgroundToken: 'paper',
elements: [
{
id: 's1',
tint: { colors: ['accent'] },
frame: { x: 0.4286, y: 0.1714, w: 0.1429, h: 0.1429 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'sticker',
stickerId: 'fleuron',
colors: ['#857d72'],
flipX: false,
},
{
id: 't2',
role: 'date',
frame: { x: 0.1429, y: 0.3524, w: 0.7143, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'center',
leading: 1.4,
tracking: 0.42,
color: '#222222',
token: 'ink',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 't3',
role: 'title',
frame: { x: 0.0762, y: 0.4048, w: 0.8476, h: 0.2667 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'cormorant',
size: 0.2857,
weight: 300,
italic: false,
align: 'center',
leading: 0.95,
tracking: 0,
color: '#222222',
token: 'ink',
fontRole: 'display',
binding: null,
overridden: false,
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.2857,
},
{
id: 'l4',
tint: { stroke: 'accent' },
frame: { x: 0.381, y: 0.6952, w: 0.2381, h: 0.0286 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'line',
points: [
{ x: 0, y: 0.5 },
{ x: 1, y: 0.5 },
],
curve: 'straight',
stroke: '#857d72',
strokeWidth: 0.0012,
dash: 'solid',
squiggle: 'none',
startCap: 'none',
endCap: 'none',
wobble: 0,
ornament: 'diamond',
},
{
id: 't5',
role: 'subtitle',
frame: { x: 0.1429, y: 0.7429, w: 0.7143, h: 0.0667 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'center',
leading: 1.4,
tracking: 0.32,
color: '#646464',
token: 'muted',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'top',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 'l6',
tint: { stroke: 'ink' },
frame: { x: 1.1429, y: 0.0857, w: 0.7143, h: 0.0286 },
rotation: 0,
opacity: 1,
locked: false,
group: 'column',
kind: 'line',
points: [
{ x: 0, y: 0.5 },
{ x: 1, y: 0.5 },
],
curve: 'straight',
stroke: '#222222',
strokeWidth: 0.0012,
dash: 'solid',
squiggle: 'none',
startCap: 'none',
endCap: 'none',
wobble: 0,
ornament: 'none',
},
{
id: 't7',
role: 'body',
frame: { x: 1.1429, y: 0.1524, w: 0.7143, h: 0.7143 },
rotation: 0,
opacity: 1,
locked: false,
group: 'column',
kind: 'text',
text: '',
font: 'inter',
size: 0.0476,
weight: 400,
italic: false,
align: 'left',
leading: 1.6,
tracking: 0,
color: '#222222',
token: 'ink',
fontRole: 'body',
binding: null,
overridden: false,
vAlign: 'top',
},
{
id: 's8',
tint: { colors: ['accent'] },
frame: { x: 1.381, y: 0.9, w: 0.2381, h: 0.0408 },
rotation: 0,
opacity: 1,
locked: false,
group: 'column',
kind: 'sticker',
stickerId: 'starRule',
colors: ['#857d72'],
flipX: false,
},
],
},
{
id: 'minimal-serif-triptych',
labelKey: 'studio.templates.name.minimalSerifTriptych',
kind: 'inner',
archetype: 'minimal-serif',
tags: ['minimal', 'serif', 'triptych', 'three-photos', 'portrait', 'hairline', 'calm'],
descr:
'A thin serif title, a sun rule, the subtitle, the date and the story in a narrow left column; three tall photos with hairline borders on one line across the spread, each numbered by one, two or three accent dots.',
background: null,
backgroundToken: 'paper',
elements: [
{
id: 't1',
role: 'title',
frame: { x: 0.0762, y: 0.1619, w: 0.4381, h: 0.2857 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'cormorant',
size: 0.1905,
weight: 300,
italic: false,
align: 'left',
leading: 0.95,
tracking: 0,
color: '#222222',
token: 'ink',
fontRole: 'display',
binding: null,
overridden: false,
vAlign: 'bottom',
fit: 'shrink',
fitMax: 0.1905,
},
{
id: 'l2',
tint: { stroke: 'accent' },
frame: { x: 0.0762, y: 0.4667, w: 0.1905, h: 0.0286 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'line',
points: [
{ x: 0, y: 0.5 },
{ x: 1, y: 0.5 },
],
curve: 'straight',
stroke: '#857d72',
strokeWidth: 0.0012,
dash: 'solid',
squiggle: 'none',
startCap: 'none',
endCap: 'none',
wobble: 0,
ornament: 'sun',
},
{
id: 't3',
role: 'subtitle',
frame: { x: 0.0762, y: 0.5143, w: 0.4381, h: 0.0619 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'left',
leading: 1.4,
tracking: 0.32,
color: '#646464',
token: 'muted',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'top',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 't4',
role: 'date',
frame: { x: 0.0762, y: 0.5857, w: 0.4381, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'left',
leading: 1.4,
tracking: 0.4,
color: '#222222',
token: 'ink',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 't5',
role: 'body',
frame: { x: 0.0762, y: 0.6571, w: 0.4381, h: 0.2667 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'text',
text: '',
font: 'inter',
size: 0.0429,
weight: 400,
italic: false,
align: 'left',
leading: 1.55,
tracking: 0,
color: '#222222',
token: 'ink',
fontRole: 'body',
binding: null,
overridden: false,
vAlign: 'top',
},
{
id: 'p6',
role: 'photo:1',
tint: { border: 'ink' },
frame: { x: 0.5762, y: 0.1619, w: 0.3476, h: 0.5714 },
rotation: 0,
opacity: 1,
locked: false,
group: 'plates',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0014, color: '#222222' },
},
{
id: 't7',
frame: { x: 0.5762, y: 0.7619, w: 0.3476, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'plates',
kind: 'text',
text: '•',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'left',
leading: 1.4,
tracking: 0.6,
color: '#857d72',
token: 'accent',
binding: null,
overridden: false,
vAlign: 'middle',
},
{
id: 'p8',
role: 'photo:2',
tint: { border: 'ink' },
frame: { x: 1.0762, y: 0.1619, w: 0.3476, h: 0.5714 },
rotation: 0,
opacity: 1,
locked: false,
group: 'plates',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0014, color: '#222222' },
},
{
id: 't9',
frame: { x: 1.0762, y: 0.7619, w: 0.3476, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'plates',
kind: 'text',
text: '••',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'left',
leading: 1.4,
tracking: 0.6,
color: '#857d72',
token: 'accent',
binding: null,
overridden: false,
vAlign: 'middle',
},
{
id: 'p10',
role: 'photo:3',
tint: { border: 'ink' },
frame: { x: 1.5762, y: 0.1619, w: 0.3476, h: 0.5714 },
rotation: 0,
opacity: 1,
locked: false,
group: 'plates',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0014, color: '#222222' },
},
{
id: 't11',
frame: { x: 1.5762, y: 0.7619, w: 0.3476, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'plates',
kind: 'text',
text: '•••',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'left',
leading: 1.4,
tracking: 0.6,
color: '#857d72',
token: 'accent',
binding: null,
overridden: false,
vAlign: 'middle',
},
],
},
];
@@ -0,0 +1,793 @@
import type { BookDecoratedTemplate } from '../template-format';
/**
* More illustrated postcard pages: a large letter cover whose title is
* filled with the photo, an airmail letter spread of words only and a rack
* of seven picture postcards. Drawn in postcard.
*
* Drawn on a 210 mm page and kept as fractions of it (template-format.ts),
* in the colours and faces of the theme named above; their links let any
* other theme recolour them.
*/
export const MORE_POSTCARD: readonly BookDecoratedTemplate[] = [
{
id: 'postcard-greetings',
labelKey: 'studio.templates.name.postcardGreetings',
kind: 'single',
archetype: 'postcard',
tags: ['postcard', 'large-letter', 'word-art', 'photo-filled', 'sunburst', 'one-photo', 'lively'],
descr:
'A large letter postcard: a soft sunburst behind a dashed border, the year, the title in huge display serif capitals filled with the photo and shadowed in coral, a script line, a star rule, the photo with a wavy edge, lemons and a postmark.',
background: '#e3f1e8',
backgroundToken: 'paper',
elements: [
{
id: 's1',
keep: true,
frame: { x: -0.05, y: -0.0714, w: 1.1, h: 1.1 },
rotation: 0,
opacity: 0.4,
locked: false,
kind: 'shape',
shape: 'sunburst-24',
fill: '#f3d58a',
gradient: 'none',
stroke: null,
token: 'accent3',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 's2',
frame: { x: 0.0476, y: 0.0476, w: 0.9048, h: 0.9048 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'shape',
shape: 'rect',
fill: null,
gradient: 'none',
stroke: '#1f2d4d',
strokeToken: 'ink',
strokeWidth: 0.0024,
strokeStyle: 'dashed',
radius: 0.0071,
},
{
id: 't3',
role: 'date',
frame: { x: 0.2619, y: 0.0905, w: 0.4762, h: 0.0381 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 600,
italic: false,
align: 'center',
leading: 1,
tracking: 0.45,
color: '#1f2d4d',
token: 'ink',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 't4',
role: 'title',
fillPhoto: 'photo:1',
tint: { outline: 'ink', shadow: 'accent' },
frame: { x: 0.0762, y: 0.1429, w: 0.8476, h: 0.2857 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'dmserif',
size: 0.3333,
weight: 400,
italic: false,
align: 'center',
leading: 0.9,
tracking: 0,
color: '#1f2d4d',
token: 'ink',
fontRole: 'display',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
outline: { color: '#1f2d4d', width: 0.0033 },
shadow: { dx: 0.0095, dy: 0.0095, color: '#c8453d', alpha: 1 },
fit: 'shrink',
fitMax: 0.3333,
},
{
id: 't5',
role: 'subtitle',
frame: { x: 0.1143, y: 0.4381, w: 0.7714, h: 0.0857 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'dancing',
size: 0.1238,
weight: 400,
italic: false,
align: 'center',
leading: 1.1,
tracking: 0,
color: '#c8453d',
token: 'accent',
fontRole: 'script',
binding: null,
overridden: false,
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.1238,
},
{
id: 's6',
tint: { colors: ['ink'] },
frame: { x: 0.3095, y: 0.5238, w: 0.381, h: 0.0653 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'sticker',
stickerId: 'starRule',
colors: ['#1f2d4d'],
flipX: false,
},
{
id: 'p7',
role: 'photo:1',
tint: { shadow: 'ink' },
frame: { x: 0.0952, y: 0.6, w: 0.8095, h: 0.3048 },
rotation: 0,
opacity: 1,
locked: false,
group: 'picture',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0105, color: '#ffffff' },
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
edge: 'wavy',
edgeSize: 0.0095,
},
{
id: 's8',
tint: { colors: ['accent3', null, 'ink'] },
frame: { x: 0.7476, y: 0.5143, w: 0.1905, h: 0.1905 },
rotation: 18,
opacity: 1,
locked: false,
group: 'picture',
kind: 'sticker',
stickerId: 'lemonBranch',
colors: ['#f3d58a', '#3f8f4f', '#1f2d4d'],
flipX: false,
},
{
id: 's9',
tint: { colors: ['ink'] },
frame: { x: 0.0762, y: 0.8381, w: 0.181, h: 0.1052 },
rotation: -8,
opacity: 0.8,
locked: false,
group: 'picture',
kind: 'sticker',
stickerId: 'postmark',
colors: ['#1f2d4d'],
flipX: false,
},
],
},
{
id: 'postcard-airmail',
labelKey: 'studio.templates.name.postcardAirmail',
kind: 'inner',
archetype: 'postcard',
tags: ['postcard', 'airmail', 'letter', 'illustrated', 'stamp', 'words-only', 'story', 'calm'],
descr:
'Airmail stationery without photos: striped coral bands at head and foot, the date, a large display serif title, a script line and a wave rule over a drawn church and a passport stamp; on the right a stamp, a postmark and the long story.',
background: '#e3f1e8',
backgroundToken: 'paper',
elements: [
{
id: 's1',
tint: { fg: 'accent', bg: 'paper' },
frame: { x: -0.0143, y: -0.0143, w: 2.0286, h: 0.0572 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'shape',
shape: 'rect',
fill: '#c8453d',
gradient: 'none',
paint: { type: 'pattern', pattern: 'stripes', fg: '#c8453d', bg: '#e3f1e8', scale: 0.6, angle: 45 },
stroke: null,
token: 'accent',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 's2',
tint: { fg: 'accent', bg: 'paper' },
frame: { x: -0.0143, y: 0.9571, w: 2.0286, h: 0.0572 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'shape',
shape: 'rect',
fill: '#c8453d',
gradient: 'none',
paint: { type: 'pattern', pattern: 'stripes', fg: '#c8453d', bg: '#e3f1e8', scale: 0.6, angle: 45 },
stroke: null,
token: 'accent',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 's3',
keep: true,
frame: { x: 0.5333, y: 0.5524, w: 0.4095, h: 0.4095 },
rotation: 0,
opacity: 0.35,
locked: false,
kind: 'shape',
shape: 'ellipse',
fill: '#ec8f6a',
gradient: 'none',
stroke: null,
token: 'accent2',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 's4',
keep: true,
tint: { colors: [null, 'ink', 'accent2', 'accent3'] },
frame: { x: 0.5714, y: 0.5905, w: 0.3333, h: 0.3333 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'sticker',
stickerId: 'santoriniChurch',
colors: ['#fbfaf5', '#1f2d4d', '#ec8f6a', '#f3d58a'],
flipX: false,
},
{
id: 's5',
tint: { colors: ['ink'] },
frame: { x: 0.0857, y: 0.7143, w: 0.2857, h: 0.2 },
rotation: -12,
opacity: 0.7,
locked: false,
kind: 'sticker',
stickerId: 'passportStamp',
colors: ['#1f2d4d'],
flipX: false,
},
{
id: 't6',
role: 'date',
frame: { x: 0.0762, y: 0.1143, w: 0.8476, h: 0.0381 },
rotation: 0,
opacity: 1,
locked: false,
group: 'head',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 600,
italic: false,
align: 'left',
leading: 1,
tracking: 0.42,
color: '#1f2d4d',
token: 'ink',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 't7',
role: 'title',
frame: { x: 0.0762, y: 0.1619, w: 0.8476, h: 0.3238 },
rotation: 0,
opacity: 1,
locked: false,
group: 'head',
kind: 'text',
text: '',
font: 'dmserif',
size: 0.381,
weight: 400,
italic: false,
align: 'left',
leading: 0.92,
tracking: 0,
color: '#1f2d4d',
token: 'ink',
fontRole: 'display',
binding: null,
overridden: false,
vAlign: 'top',
fit: 'shrink',
fitMax: 0.381,
},
{
id: 't8',
role: 'subtitle',
frame: { x: 0.0762, y: 0.4952, w: 0.8476, h: 0.0952 },
rotation: 0,
opacity: 1,
locked: false,
group: 'head',
kind: 'text',
text: '',
font: 'dancing',
size: 0.1333,
weight: 400,
italic: false,
align: 'left',
leading: 1.1,
tracking: 0,
color: '#c8453d',
token: 'accent',
fontRole: 'script',
binding: null,
overridden: false,
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.1333,
},
{
id: 's9',
tint: { colors: ['ink'] },
frame: { x: 0.0762, y: 0.6048, w: 0.3, h: 0.0429 },
rotation: 0,
opacity: 1,
locked: false,
group: 'head',
kind: 'sticker',
stickerId: 'waveRule',
colors: ['#1f2d4d'],
flipX: false,
},
{
id: 's10',
tint: { colors: ['accent2', 'ink', 'accent3', null] },
frame: { x: 1.7524, y: 0.0952, w: 0.1524, h: 0.1905 },
rotation: 4,
opacity: 1,
locked: false,
group: 'stamp',
kind: 'sticker',
stickerId: 'postageStamp',
colors: ['#ec8f6a', '#1f2d4d', '#f3d58a', '#f7edd5'],
flipX: false,
},
{
id: 's11',
tint: { colors: ['ink'] },
frame: { x: 1.6, y: 0.1238, w: 0.2, h: 0.1163 },
rotation: -10,
opacity: 0.8,
locked: false,
group: 'stamp',
kind: 'sticker',
stickerId: 'postmark',
colors: ['#1f2d4d'],
flipX: false,
},
{
id: 'l12',
tint: { stroke: 'accent' },
frame: { x: 1.0762, y: 0.2524, w: 0.4571, h: 0.0095 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'line',
points: [
{
x: 0,
y: 0.5,
},
{
x: 1,
y: 0.5,
},
],
curve: 'straight',
stroke: '#c8453d',
strokeWidth: 0.0012,
dash: 'solid',
squiggle: 'none',
startCap: 'none',
endCap: 'none',
wobble: 0,
ornament: 'star',
},
{
id: 't13',
role: 'body',
frame: { x: 1.0762, y: 0.3048, w: 0.8476, h: 0.6095 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'text',
text: '',
font: 'inter',
size: 0.0476,
weight: 400,
italic: false,
align: 'left',
leading: 1.55,
tracking: 0,
color: '#1f2d4d',
token: 'ink',
fontRole: 'body',
binding: null,
overridden: false,
vAlign: 'top',
},
],
},
{
id: 'postcard-rack',
labelKey: 'studio.templates.name.postcardRack',
kind: 'inner',
archetype: 'postcard',
tags: ['postcard', 'collage', 'pinboard', 'tape', 'tilted', 'seven-photos', 'lively'],
descr:
'Seven photos as picture postcards in white borders, tilted and overlapping, held by tape, one cancelled by a postmark; a pale sun behind the date, the display serif title and the script line, the story at the foot.',
background: '#e3f1e8',
backgroundToken: 'paper',
elements: [
{
id: 's1',
keep: true,
frame: { x: -0.12, y: -0.14, w: 0.5, h: 0.5102 },
rotation: 0,
opacity: 0.55,
locked: false,
kind: 'shape',
shape: 'sun-cutout',
fill: '#f3d58a',
gradient: 'none',
stroke: null,
token: 'accent3',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 't2',
role: 'date',
frame: { x: 0.0762, y: 0.0857, w: 0.8476, h: 0.0381 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 600,
italic: false,
align: 'left',
leading: 1,
tracking: 0.42,
color: '#1f2d4d',
token: 'ink',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 't3',
role: 'title',
frame: { x: 0.0762, y: 0.1333, w: 0.8476, h: 0.1238 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'dmserif',
size: 0.2286,
weight: 400,
italic: false,
align: 'left',
leading: 1,
tracking: 0,
color: '#1f2d4d',
token: 'ink',
fontRole: 'display',
binding: null,
overridden: false,
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.2286,
},
{
id: 't4',
role: 'subtitle',
frame: { x: 0.0762, y: 0.2571, w: 0.8476, h: 0.0762 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'dancing',
size: 0.1048,
weight: 400,
italic: false,
align: 'left',
leading: 1.1,
tracking: 0,
color: '#c8453d',
token: 'accent',
fontRole: 'script',
binding: null,
overridden: false,
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.1048,
},
{
id: 'p5',
role: 'photo:1',
tint: { shadow: 'ink' },
frame: { x: 0.0762, y: 0.381, w: 0.5238, h: 0.3714 },
rotation: -3,
opacity: 1,
locked: false,
group: 'print',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0105, color: '#ffffff' },
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
},
{
id: 's6',
tint: { colors: ['accent3'] },
frame: { x: 0.2619, y: 0.3429, w: 0.1524, h: 0.0762 },
rotation: -8,
opacity: 0.85,
locked: false,
group: 'print',
kind: 'sticker',
stickerId: 'maskingTape',
colors: ['#f3d58a'],
flipX: false,
},
{
id: 'p7',
role: 'photo:2',
tint: { shadow: 'ink' },
frame: { x: 0.6, y: 0.3333, w: 0.3048, h: 0.381 },
rotation: 4,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0105, color: '#ffffff' },
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
},
{
id: 'p8',
role: 'photo:3',
tint: { shadow: 'ink' },
frame: { x: 0.4, y: 0.6857, w: 0.3333, h: 0.2381 },
rotation: 2,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0105, color: '#ffffff' },
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
},
{
id: 'p9',
role: 'photo:4',
tint: { shadow: 'ink' },
frame: { x: 1.0762, y: 0.0762, w: 0.4, h: 0.2857 },
rotation: 3,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0105, color: '#ffffff' },
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
},
{
id: 'p10',
role: 'photo:5',
tint: { shadow: 'ink' },
frame: { x: 1.5048, y: 0.1048, w: 0.4, h: 0.2857 },
rotation: -4,
opacity: 1,
locked: false,
group: 'tape',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0105, color: '#ffffff' },
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
},
{
id: 's11',
tint: { colors: ['accent3'] },
frame: { x: 1.6286, y: 0.0667, w: 0.1524, h: 0.0762 },
rotation: 6,
opacity: 0.85,
locked: false,
group: 'tape',
kind: 'sticker',
stickerId: 'maskingTape',
colors: ['#f3d58a'],
flipX: false,
},
{
id: 'p12',
role: 'photo:6',
tint: { shadow: 'ink' },
frame: { x: 1.0952, y: 0.4095, w: 0.3048, h: 0.381 },
rotation: -2,
opacity: 1,
locked: false,
group: 'picture',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0105, color: '#ffffff' },
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
},
{
id: 's13',
tint: { colors: ['accent2', 'paper'] },
frame: { x: 1.0571, y: 0.3905, w: 0.1905, h: 0.054 },
rotation: -30,
opacity: 0.9,
locked: false,
group: 'picture',
kind: 'sticker',
stickerId: 'washiStripes',
colors: ['#ec8f6a', '#e3f1e8'],
flipX: false,
},
{
id: 'p14',
role: 'photo:7',
tint: { shadow: 'ink' },
frame: { x: 1.4381, y: 0.4286, w: 0.4667, h: 0.3143 },
rotation: 5,
opacity: 1,
locked: false,
group: 'postmark',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0105, color: '#ffffff' },
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
},
{
id: 's15',
tint: { colors: ['ink'] },
frame: { x: 1.7238, y: 0.6762, w: 0.181, h: 0.1052 },
rotation: -12,
opacity: 0.8,
locked: false,
group: 'postmark',
kind: 'sticker',
stickerId: 'postmark',
colors: ['#1f2d4d'],
flipX: false,
},
{
id: 't16',
role: 'body',
frame: { x: 1.0762, y: 0.8286, w: 0.8476, h: 0.1143 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'text',
text: '',
font: 'inter',
size: 0.0429,
weight: 400,
italic: false,
align: 'left',
leading: 1.55,
tracking: 0,
color: '#1f2d4d',
token: 'ink',
fontRole: 'body',
binding: null,
overridden: false,
vAlign: 'top',
},
],
},
];
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -24,7 +24,13 @@ import {
templatePage,
type LaidElement,
} from './template-lay';
import { assignTemplateTexts, textRolesOf, type BookTemplateWord, type BookTemplateWords } from './template-words';
import {
assignTemplateTexts,
isTemplateFigureText,
textRolesOf,
type BookTemplateWord,
type BookTemplateWords,
} from './template-words';
/**
* Laying a page out on a decorated template.
@@ -146,7 +152,9 @@ function poolOf(spread: BookSpread): Pool {
const all = [...spread.elements.filter((e) => !e.locked), ...spread.parked];
return {
photos: all.filter((e): e is BookPhotoElement => e.kind === 'photo' && e.photoId != null),
texts: all.filter((e): e is TextEl => e.kind === 'text' && !!e.text.trim() && !isBookOrnamentText(e)),
texts: all.filter(
(e): e is TextEl => e.kind === 'text' && !!e.text.trim() && !isBookOrnamentText(e) && !isTemplateFigureText(e),
),
map: all.find((e): e is BookMapElement => e.kind === 'map'),
marks: all.filter((e): e is BookBadgeElement => e.kind === 'badge'),
};
@@ -55,12 +55,12 @@ function box(el: BookElement) {
const camel = (id: string) => id.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
describe('the decorated template set', () => {
it('TPLCAT-001: holds 21 covers, three in each archetype, and 16 double spreads', () => {
expect(covers).toHaveLength(21);
expect(spreads).toHaveLength(16);
it('TPLCAT-001: holds 48 covers and 60 double spreads, four covers and five spreads in each archetype', () => {
expect(covers).toHaveLength(48);
expect(spreads).toHaveLength(60);
for (const archetype of BOOK_COVER_ARCHETYPES) {
expect(decoratedTemplatesFor('single', archetype), archetype).toHaveLength(3);
expect(decoratedTemplatesFor('inner', archetype).length, archetype).toBeGreaterThanOrEqual(1);
expect(decoratedTemplatesFor('single', archetype), archetype).toHaveLength(4);
expect(decoratedTemplatesFor('inner', archetype), archetype).toHaveLength(5);
}
});
@@ -190,7 +190,7 @@ describe('finding the templates', () => {
expect(findDecoratedTemplate('no-such-template')).toBeUndefined();
expect(decoratedTemplatesFor('single').every((t) => t.kind === 'single')).toBe(true);
expect(decoratedTemplatesFor('inner', 'postcard').every((t) => t.archetype === 'postcard')).toBe(true);
expect(decoratedTemplatesFor('single', null)).toHaveLength(21);
expect(decoratedTemplatesFor('single', null)).toHaveLength(48);
expect(isBookTemplateId('grid-badge-circle')).toBe(true);
expect(isBookTemplateId('two-up')).toBe(true);
expect(isBookTemplateId('cover-full')).toBe(true);
@@ -203,10 +203,10 @@ describe('finding the templates', () => {
expect(row.roles).toEqual(['title', 'date', 'body']);
expect(row.tags).toContain('word-art');
const all = listBookLayouts(null, square);
expect(all.decorated.single).toHaveLength(21);
expect(all.decorated.inner).toHaveLength(16);
expect(all.decorated.single).toHaveLength(48);
expect(all.decorated.inner).toHaveLength(60);
expect(all.notes.decorated).toMatch(/whole designed page/);
const inner = listBookLayouts(null, square, 'inner');
expect([inner.decorated.single, inner.decorated.inner.length]).toEqual([[], 16]);
expect([inner.decorated.single, inner.decorated.inner.length]).toEqual([[], 60]);
});
});
@@ -12,6 +12,18 @@ import { INNER_COLLAGE } from './data/inner-collage.data';
import { INNER_EPHEMERA } from './data/inner-ephemera.data';
import { INNER_GRIDS } from './data/inner-grids.data';
import { INNER_STORY } from './data/inner-story.data';
import { MORE_CUTOUT } from './data/more-cutout.data';
import { MORE_GRID_BADGE } from './data/more-grid-badge.data';
import { MORE_MINIMAL_SERIF } from './data/more-minimal-serif.data';
import { MORE_POSTCARD } from './data/more-postcard.data';
import { MORE_POSTER_POP } from './data/more-poster-pop.data';
import { MORE_SPINE_BAND } from './data/more-spine-band.data';
import { MORE_STAMP_COLLAGE } from './data/more-stamp-collage.data';
import { NEW_EDITORIAL } from './data/new-editorial.data';
import { NEW_FIELD_NOTES } from './data/new-field-notes.data';
import { NEW_NIGHT_FILM } from './data/new-night-film.data';
import { NEW_RISO } from './data/new-riso.data';
import { NEW_VINTAGE_POSTER } from './data/new-vintage-poster.data';
import {
templatePhotoSlots,
templateRoles,
@@ -19,26 +31,56 @@ import {
type BookTemplateKind,
} from './template-format';
/** The covers or the spreads of a data file that holds both. */
function ofKind(set: readonly BookDecoratedTemplate[], kind: BookTemplateKind): BookDecoratedTemplate[] {
return set.filter((t) => t.kind === kind);
}
/**
* The decorated templates the Studio offers and the assistant lays pages
* on: 21 covers, three in each of the seven archetypes, and 16 double
* spreads, each set in the manner of one of them. The data files hold them
* as drawn (template-format.ts); this is the one list the Studio's panel,
* the page operations and `list_book_layouts` read.
* on: 48 covers, four in each of the twelve archetypes, and 60 double
* spreads, five in the manner of each. The data files hold them as drawn
* (template-format.ts); this is the one list the Studio's panel, the page
* operations and `list_book_layouts` read. Its order is the panel's: the
* covers archetype by archetype, then the spreads.
*/
export const BOOK_DECORATED_TEMPLATES: readonly BookDecoratedTemplate[] = [
...COVERS_SPINE_BAND,
...ofKind(MORE_SPINE_BAND, 'single'),
...COVERS_POSTER_POP,
...ofKind(MORE_POSTER_POP, 'single'),
...COVERS_CUTOUT,
...ofKind(MORE_CUTOUT, 'single'),
...COVERS_POSTCARD,
...ofKind(MORE_POSTCARD, 'single'),
...COVERS_GRID_BADGE,
...ofKind(MORE_GRID_BADGE, 'single'),
...COVERS_MINIMAL_SERIF,
...ofKind(MORE_MINIMAL_SERIF, 'single'),
...COVERS_STAMP_COLLAGE,
...ofKind(MORE_STAMP_COLLAGE, 'single'),
...ofKind(NEW_RISO, 'single'),
...ofKind(NEW_EDITORIAL, 'single'),
...ofKind(NEW_VINTAGE_POSTER, 'single'),
...ofKind(NEW_NIGHT_FILM, 'single'),
...ofKind(NEW_FIELD_NOTES, 'single'),
...INNER_STORY,
...INNER_CHAPTERS,
...INNER_COLLAGE,
...INNER_EPHEMERA,
...INNER_GRIDS,
...ofKind(MORE_SPINE_BAND, 'inner'),
...ofKind(MORE_POSTER_POP, 'inner'),
...ofKind(MORE_CUTOUT, 'inner'),
...ofKind(MORE_POSTCARD, 'inner'),
...ofKind(MORE_GRID_BADGE, 'inner'),
...ofKind(MORE_MINIMAL_SERIF, 'inner'),
...ofKind(MORE_STAMP_COLLAGE, 'inner'),
...ofKind(NEW_RISO, 'inner'),
...ofKind(NEW_EDITORIAL, 'inner'),
...ofKind(NEW_VINTAGE_POSTER, 'inner'),
...ofKind(NEW_NIGHT_FILM, 'inner'),
...ofKind(NEW_FIELD_NOTES, 'inner'),
];
const BY_ID = new Map(BOOK_DECORATED_TEMPLATES.map((t) => [t.id, t]));
@@ -0,0 +1,79 @@
import { bookPageSetupForPreset } from '../book-pages';
import { bookPhotoElementSchema, type BookSpread } from '../book.schema';
import { bookThemeOf } from '../theme/book-theme';
import { applyDecoratedTemplate } from './template-apply';
import { BOOK_DECORATED_TEMPLATES } from './template-catalogue';
import { matchDecoratedTemplate } from './template-match';
import { describe, expect, it } from 'vitest';
/** A laid page knows which decorated template it is. */
const words = {
title: { text: 'Portugal' },
subtitle: { text: 'Lisbon to the Algarve' },
date: { text: '2026', binding: { source: 'entry.date' as const, entryId: 7, value: '2026-05-14' } },
body: { text: 'We took the early train out of Lisbon and walked up through the fog to the palace.' },
caption: { text: 'Pena Palace in the fog' },
};
const photo = (id: string, photoId: number) =>
bookPhotoElementSchema.parse({ id, kind: 'photo', frame: { x: 0, y: 0, w: 10, h: 10 }, photoId });
const page = (role: BookSpread['role'], photos: number): BookSpread => ({
id: 'sp',
role,
background: null,
elements: Array.from({ length: photos }, (_, i) => photo(`p${i}`, i + 1)),
parked: [],
entryId: null,
});
let n = 0;
const newId = (p: string) => `${p}-${++n}`;
describe('matchDecoratedTemplate', () => {
it('TPLMATCH-001: recognises every shipped template laid on every page format', () => {
for (const preset of ['square-210', 'a4-landscape', 'a4-portrait', 'a5-landscape'] as const) {
const format = bookPageSetupForPreset(preset);
for (const tpl of BOOK_DECORATED_TEMPLATES) {
const laid = applyDecoratedTemplate(page(tpl.kind === 'single' ? 'cover' : 'inner', 8), tpl, format, {
words,
newId,
});
expect(matchDecoratedTemplate(laid, format)?.id, `${tpl.id} on ${preset}`).toBe(tpl.id);
}
}
});
it("TPLMATCH-002: recognises it in a theme's colours and with fewer words and photos than it takes", () => {
const format = bookPageSetupForPreset('square-210');
const theme = bookThemeOf('midnight');
for (const tpl of BOOK_DECORATED_TEMPLATES) {
const laid = applyDecoratedTemplate(page(tpl.kind === 'single' ? 'cover' : 'inner', 1), tpl, format, {
theme,
words: { title: words.title },
newId,
});
expect(matchDecoratedTemplate(laid, format)?.id, tpl.id).toBe(tpl.id);
}
});
it('TPLMATCH-003: a page rearranged by hand is no template', () => {
const format = bookPageSetupForPreset('square-210');
const tpl = BOOK_DECORATED_TEMPLATES.find((t) => t.id === 'stamp-row');
if (!tpl) throw new Error('stamp-row is shipped');
const laid = applyDecoratedTemplate(page('inner', 4), tpl, format, { words, newId });
const moved = { ...laid, elements: laid.elements.map((e) => ({ ...e, frame: { ...e.frame, x: e.frame.x + 9 } })) };
expect(matchDecoratedTemplate(moved, format)).toBeUndefined();
expect(matchDecoratedTemplate({ ...laid, elements: [] }, format)).toBeUndefined();
});
it('TPLMATCH-004: a cover is matched among the covers, a spread among the spreads', () => {
const format = bookPageSetupForPreset('square-210');
for (const tpl of BOOK_DECORATED_TEMPLATES) {
const laid = applyDecoratedTemplate(page(tpl.kind === 'single' ? 'back' : 'inner', 3), tpl, format, { newId });
expect(matchDecoratedTemplate(laid, format)?.kind ?? tpl.kind).toBe(tpl.kind);
}
});
});
@@ -0,0 +1,91 @@
import type { BookElement, BookPageSetup, BookSpread } from '../book.schema';
import { layDecoratedTemplate } from './template-apply';
import { BOOK_DECORATED_TEMPLATES } from './template-catalogue';
import type { BookDecoratedTemplate } from './template-format';
/**
* Which decorated template a laid page is, read from the page alone.
*
* A template keeps its frames where it pours: the photo frames, the role
* texts and the design (panels, shapes, lines, stickers) all land where the
* template put them on this page's format. So a page is fingerprinted by its
* elements' kinds and frames, rounded to the millimetre, and compared with
* every template laid on the same format: a template is the page's when
* nearly all of its design is there, and of those the one that explains the
* most of the page wins. A page somebody rearranged by hand matches none.
*
* Nothing depends on the answer being right: the auto layout's "this spread"
* uses it to offer another template than the one on screen, and at worst
* offers that one again.
*/
/** The share of a template's design elements a page must hold to be that template. */
const DESIGN_SHARE = 0.8;
/** The share of the page's own elements the template must explain: a template of little design is no excuse. */
const PAGE_SHARE = 0.6;
/** A photo frame, and a map laid as one when it had nothing to draw, read alike. */
const kindKey = (kind: BookElement['kind']) => (kind === 'map' ? 'photo' : kind);
const keyOf = (el: BookElement) => {
const f = el.frame;
return [kindKey(el.kind), Math.round(f.x), Math.round(f.y), Math.round(f.w), Math.round(f.h)].join(':');
};
interface Fingerprint {
/** Every laid element, its role's ones included. */
all: string[];
/** The design alone: what no page's content changes. */
design: string[];
}
const laidPrints = new Map<string, Fingerprint>();
/** A template's fingerprint on a page format, laid once per format. */
function printOf(tpl: BookDecoratedTemplate, page: BookPageSetup): Fingerprint {
const key = `${tpl.id}|${page.pageWidth}x${page.pageHeight}|${page.bleed}`;
const known = laidPrints.get(key);
if (known) return known;
let n = 0;
const laid = layDecoratedTemplate(tpl, page, null, (p) => `${p}${++n}`);
const print = { all: laid.map((l) => keyOf(l.el)), design: laid.filter((l) => !l.role).map((l) => keyOf(l.el)) };
laidPrints.set(key, print);
return print;
}
/** How many of `want` the multiset `have` holds. */
function held(want: readonly string[], have: ReadonlyMap<string, number>): number {
const left = new Map(have);
let found = 0;
for (const k of want) {
const n = left.get(k) ?? 0;
if (n > 0) {
left.set(k, n - 1);
found++;
}
}
return found;
}
/** The decorated template a page was laid on, or undefined when it reads as none. */
export function matchDecoratedTemplate(spread: BookSpread, page: BookPageSetup): BookDecoratedTemplate | undefined {
const own = spread.elements.filter((e) => !e.locked);
if (!own.length) return undefined;
const have = new Map<string, number>();
for (const el of own) have.set(keyOf(el), (have.get(keyOf(el)) ?? 0) + 1);
const kind = spread.role === 'inner' ? 'inner' : 'single';
let best: { tpl: BookDecoratedTemplate; explained: number; found: number } | undefined;
for (const tpl of BOOK_DECORATED_TEMPLATES) {
if (tpl.kind !== kind) continue;
const print = printOf(tpl, page);
const design = held(print.design, have);
if (design < print.design.length * DESIGN_SHARE) continue;
const found = held(print.all, have);
const explained = found / own.length;
if (explained < PAGE_SHARE) continue;
if (!best || explained > best.explained || (explained === best.explained && found > best.found)) {
best = { tpl, explained, found };
}
}
return best?.tpl;
}
@@ -1,10 +1,10 @@
import { bookSpreadSchema, bookTextElementSchema, type BookSpread, type BookTextElement } from '../book.schema';
import type { BookTemplateTextRole } from './template-format';
import { assignTemplateTexts, bookTemplateWords } from './template-words';
import { assignTemplateTexts, bookTemplateWords, isTemplateFigureText } from './template-words';
import { describe, expect, it } from 'vitest';
// TPLWORDS-001 to TPLWORDS-006
// TPLWORDS-001 to TPLWORDS-007
/**
* Which of a page's words a template's roles take: the journal's bindings
@@ -57,6 +57,15 @@ describe('the page words in the roles', () => {
const spare = assignTemplateTexts([text('a', 'One'), text('b', 'Two'), text('c', 'Three')], new Set(['title']));
expect(ids(spare)).toEqual({ title: 'a' });
});
it('TPLWORDS-007: takes the figures a template drew for its design, never a date or a bound text', () => {
expect(isTemplateFigureText(text('tc', '00:12:48:03'))).toBe(true);
expect(isTemplateFigureText(text('frame', '21'))).toBe(true);
expect(isTemplateFigureText(text('day', '12.08.2025'))).toBe(false);
expect(isTemplateFigureText(text('year', '2027'))).toBe(false);
expect(isTemplateFigureText(text('word', 'REC'))).toBe(false);
expect(isTemplateFigureText(text('bound', '14', { binding: { source: 'entry.title', entryId: 1 } }))).toBe(false);
});
});
describe('the book words for the roles a page has none for', () => {
@@ -59,6 +59,15 @@ const SPARE_ORDER: readonly BookTemplateTextRole[] = ['subtitle', 'date', 'capti
const DATE_LIKE = /\b(1[89]|20)\d{2}\b|\b\d{1,2}[./]\d{1,2}\b/;
const isText = (e: BookElement | null | undefined): e is TextEl => !!e && e.kind === 'text' && !!e.text.trim();
/**
* Figures a template drew as its design (a film's timecode, frame numbers, a
* plate's number): no letters and not a date, and bound to nothing. They are
* not the page's words, so a template laid next never takes one for a title
* or a date line.
*/
export const isTemplateFigureText = (e: BookElement): boolean =>
e.kind === 'text' && !e.binding && !/\p{L}/u.test(e.text) && !DATE_LIKE.test(e.text);
const isLong = (t: TextEl) => t.text.trim().length > SHORT;
/** The role a short text reads as on its own: a date line when it says a date, else a subtitle. */

Some files were not shown because too many files have changed in this diff Show More