Compare commits

..

10 Commits

Author SHA1 Message Date
Maurice 186dd94b61 fix(assistant): chat with an own server entered without /v1 at /v1
The assistant sent an own server's chat to the address as entered plus
/chat/completions. Ollama, entered as http://host:11434 the way its own docs
write it, answers that with a 404, so every message ended in "something went
wrong" while connecting had worked: the model list is found under the root
at /api/tags. A bare server address now chats under /v1, where Ollama, LM
Studio, vLLM, llama.cpp and LiteLLM all answer; an address with a path, such
as https://api.groq.com/openai/v1, is used as entered.
2026-10-28 12:17:54 +01:00
Maurice ad721a1ac6 test(studio): the auto layout lays out a long journey whole 2026-10-28 12:26:06 +01:00
Maurice 87441f269d fix(studio): lay out a book as long as its journey, with no page limit
A book held at most 150 spreads. The auto layout cut everything past that
without a word, so a long journey's book ended about three quarters of the
way through, with its last page and back cover gone, and adding, copying or
applying a designed page to a full book was refused.

The spread cap is gone from the book schema, the page operations, the
design composer and its apply, the assistant's book card and the design tool
contracts. A design's own page budget (pages.max) still bounds what it lays
out. The save route's 8 MB body still bounds a book's size, which at two to
five kilobytes an auto laid page is a few thousand pages.

The templated design digests move because their counter ids shift by two;
the pages, their order and their elements are unchanged.
2026-10-28 12:06:50 +01:00
github-actions[bot] a0943f8226 chore: bump version to 5.0.1 [skip ci] 2026-10-28 10:43:52 +00:00
Maurice 6741e8ca0d fix(help): give the close button a row of its own
A wider gutter left the button floating in the corner over the first card and the scrollbar. It now sits in its own row above the scrolling content, level with the Help title, and the content keeps symmetric padding.
2026-10-28 10:24:08 +01:00
Maurice c14f179e39 fix(help): keep the help center's content clear of its close button
The close button floats over the scrolling content, which only kept a 40px gutter on the end side while the button reaches 52px in, so the first card ran right up to it. The content now keeps a wider gutter on that side.
2026-10-28 10:24:08 +01:00
Maurice 3bfef73a46 refactor(maps): move the place clusters out of MapViewGL
The cluster source, its layers, the expansion zoom and the DOM bubbles
now live in placeClusterLayers.ts with their own tests, which brings
MapViewGL back under its size baseline instead of growing it. Lowers the
size and eslint baselines that this change made smaller.
2026-10-28 10:11:56 +01:00
Maurice 7e790c3fb7 fix(maps): draw the GL cluster bubbles like the Leaflet ones
The MapLibre and Mapbox maps painted their place clusters as a canvas
circle plus a symbol layer, so the count used whatever glyphs the map
style ships and the bubble had no hover. They are now DOM pins built by
the same markup and CSS as the Leaflet bubbles, with the same sizes,
font and hover scale. The canvas layers stay, invisible, as the hit
area the track and route layers check a click against.
2026-10-28 10:11:56 +01:00
Maurice 228db8541c chore(notices): show 5.0.1 in the release popup
The release popup still named 5.0.0 and linked the 5.0.0 release notes. It now names 5.0.1 and links its tag.
2026-10-28 09:52:18 +01:00
Maurice a729a6935f fix(journey): keep the photo picker's calendar inside the screen
The date picker of the external photo picker hung absolutely under its
trigger, inside a dialog that clips its content. The last week of a
six-week month fell below the dialog, and for the end date in the
right-hand column the calendar opened past the dialog's right edge, so
the last weekday and the next-month arrow could not be reached.

The calendar now opens in a portal, pinned to its trigger through
useAnchoredPosition: it flips above the trigger when below is too short,
scrolls when neither side holds it, and is kept inside the viewport
sideways.

Closes #2858
2026-10-28 09:25:53 +01:00
47 changed files with 654 additions and 417 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 5.0.0
version: 5.0.1
description: Minimal Helm chart for TREK app
appVersion: "5.0.0"
appVersion: "5.0.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "5.0.0",
"version": "5.0.1",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
+1 -1
View File
@@ -30,7 +30,7 @@
"no-useless-escape": 1
},
"suppressed": {
"@typescript-eslint/no-explicit-any": 40,
"@typescript-eslint/no-explicit-any": 38,
"react-hooks/exhaustive-deps": 46
},
"tests": {
+1 -1
View File
@@ -7,7 +7,7 @@
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/MapViewGL.tsx": 2218,
"src/components/Map/RouteCalculator.ts": 1063,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
+13 -9
View File
@@ -102,16 +102,20 @@ export default function HelpPanel(): React.ReactElement | null {
<HelpNav context={context} currentId={contextId} activeGuideId={!searching && guide ? guide.id : null} />
<div className="relative flex flex-col min-h-0 bg-surface" role="main">
<button
type="button"
onClick={closeHelp}
aria-label={t('help.center.close')}
className="absolute top-4 end-4 z-10 w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
>
<X className="w-4 h-4" />
</button>
{/* The close button sits in a row of its own above the scrolling content rather
than floating over it, so no card ever runs up against it. */}
<div className="flex shrink-0 justify-end px-4 pt-4">
<button
type="button"
onClick={closeHelp}
aria-label={t('help.center.close')}
className="w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div ref={scrollRef} className="trek-help-scroll flex-1 min-h-0 overflow-y-auto overscroll-contain">
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 py-8">
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 pt-4 pb-8">
{searching ? (
<HelpSearchResults key="search" />
) : guide ? (
@@ -1,4 +1,4 @@
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-007
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-008
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
@@ -119,3 +119,28 @@ describe('DatePicker week start (#2029)', () => {
}
})
})
describe('DatePicker inside a clipping dialog (#2858)', () => {
it('FE-COMP-JOURNEYDATEPICKER-008: the calendar floats over the page, stays inside the viewport and closes on a click away', async () => {
const user = userEvent.setup()
const { container } = render(
<div style={{ overflow: 'hidden' }}>
<DatePicker value="2026-03-15" onChange={vi.fn()} />
</div>,
)
const trigger = screen.getByText(/Mar 15, 2026/).closest('button') as HTMLButtonElement
// A trigger at the right edge, where the end date of a range sits.
trigger.getBoundingClientRect = () => ({ top: 100, bottom: 136, left: window.innerWidth - 120, right: window.innerWidth - 8, width: 112, height: 36, x: 0, y: 0, toJSON: () => ({}) })
await user.click(trigger)
const day31 = screen.getByRole('button', { name: '31' })
// Rendered outside the dialog that would clip it.
expect(container.contains(day31)).toBe(false)
const panel = day31.closest('.fixed') as HTMLElement
expect(panel.style.top).toBe('140px')
expect(parseFloat(panel.style.left)).toBe(window.innerWidth - 280 - 8)
await user.click(document.querySelector('[role="presentation"]') as HTMLElement)
expect(screen.queryByRole('button', { name: '31' })).toBeNull()
})
})
@@ -1,9 +1,11 @@
import { useState } from 'react'
import { useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { localIsoDate } from '../../utils/localDate'
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useWeekStartDay } from '../../hooks/useWeekStartDay'
import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
/**
* The header steps up a level on each click, days → months → years, and a
@@ -14,6 +16,9 @@ import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
*/
type CalendarView = 'days' | 'months' | 'years'
const YEAR_PAGE_SIZE = 12
/** The calendar's width; `w-[280px]` below. */
const PANEL_WIDTH = 280
const VIEWPORT_PAD = 8
export function DatePicker({ value, onChange, tripDates }: {
value: string
@@ -23,6 +28,13 @@ export function DatePicker({ value, onChange, tripDates }: {
const { t, locale } = useTranslation()
const weekStart = useWeekStartDay()
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
// The calendar used to hang absolutely under its trigger, inside dialogs that clip
// their content: the photo picker cut off the last week and, for the end date in
// the right-hand column, the last weekday (#2858). It now floats over the page,
// pinned to the trigger, flipped above it when below is too short and kept inside
// the viewport sideways.
const anchored = useAnchoredPosition(triggerRef, open, { estimatedHeight: 340, matchWidth: false })
const [view, setView] = useState<CalendarView>('days')
const [yearPageStart, setYearPageStart] = useState(0)
const [viewMonth, setViewMonth] = useState(() => {
@@ -107,6 +119,7 @@ export function DatePicker({ value, onChange, tripDates }: {
return (
<div className="relative">
<button
ref={triggerRef}
type="button"
onClick={toggleOpen}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-start flex items-center justify-between"
@@ -122,12 +135,20 @@ export function DatePicker({ value, onChange, tripDates }: {
<Calendar size={13} className="text-zinc-400" />
</button>
{open && (
{open && anchored && createPortal(
<>
{/* Click-away catcher — no semantics of its own; the trigger button
above closes the popover again from the keyboard. */}
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
<div className="absolute top-full start-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
<div role="presentation" className="fixed inset-0 z-[var(--z-toast)]" onClick={() => setOpen(false)} />
<div
className="fixed z-[var(--z-toast)] overflow-y-auto bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]"
style={{
top: anchored.top,
bottom: anchored.bottom,
left: Math.max(VIEWPORT_PAD, Math.min(anchored.left, window.innerWidth - PANEL_WIDTH - VIEWPORT_PAD)),
maxHeight: anchored.maxHeight,
}}
>
{/* Header: arrows step the current view, the label climbs to the next one up */}
<div className="flex items-center justify-between mb-2">
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
@@ -200,7 +221,7 @@ export function DatePicker({ value, onChange, tripDates }: {
>
{day}
{isTrip && !isSelected && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
<span className="absolute bottom-1 inset-x-0 mx-auto w-1 h-1 rounded-full bg-indigo-500" />
)}
</button>
)
@@ -209,7 +230,8 @@ export function DatePicker({ value, onChange, tripDates }: {
</>
)}
</div>
</>
</>,
document.body,
)}
</div>
)
@@ -375,11 +375,12 @@ describe('ProviderPicker', () => {
await user.click(screen.getByRole('button', { name: 'Search' }))
expect(ranges).toHaveLength(0)
// The calendar opens in a portal, so its days are looked up on the page, not under the trigger.
const [fromPicker, toPicker] = Array.from(baseElement.querySelectorAll('.flex-1 > .relative')) as HTMLElement[]
await user.click(within(fromPicker).getByRole('button'))
await user.click(within(fromPicker).getByRole('button', { name: '3' }))
await user.click(screen.getByRole('button', { name: '3' }))
await user.click(within(toPicker).getByRole('button'))
await user.click(within(toPicker).getByRole('button', { name: '9' }))
await user.click(screen.getByRole('button', { name: '9' }))
await user.click(screen.getByRole('button', { name: 'Search' }))
await waitFor(() => expect(ranges).toHaveLength(1))
+18 -91
View File
@@ -50,16 +50,10 @@ import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers'
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
// HTML photo markers *and* cluster them natively, so we feed the place points
// into a clustered GeoJSON source. The cluster bubbles render as GL circles +
// a count label; the individual rich HTML markers are then only drawn for the
// points the source reports as currently unclustered. Grouping is always on,
// matching the Leaflet map's MarkerClusterGroup.
const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
import {
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
addPlaceClusterLayers, drawClusterBubbles, expandCluster,
} from './placeClusterLayers'
type PlaceWithCoords = Place & { lat: number; lng: number }
@@ -112,72 +106,6 @@ interface Props extends MapViewProps {
gl: any
}
/**
* How eagerly place markers merge into a cluster.
*
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
* kept as plain numbers rather than shared constants so the GL bundle does not have to
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
* own, and the stops that share a coordinate are folded together at the pin level
* instead — see the reconcile below.
*/
const CLUSTER_RADIUS = 20
const CLUSTER_MAX_ZOOM = 8
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
* change and the initial build cannot drift apart. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function addPlaceClusterLayers(map: any): void {
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: CLUSTER_RADIUS,
clusterMaxZoom: CLUSTER_MAX_ZOOM,
})
map.addLayer({
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-color': '#111827',
'circle-opacity': 0.97,
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
'circle-stroke-width': 2.5,
'circle-stroke-color': 'rgba(255,255,255,0.9)',
},
})
map.addLayer({
id: PLACE_CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: {
'text-color': '#ffffff',
'text-halo-color': 'rgba(17,24,39,0.35)',
'text-halo-width': 1,
},
})
map.addLayer({
id: PLACE_UNCLUSTERED_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 24,
'circle-opacity': 0,
'circle-stroke-opacity': 0,
},
})
}
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
// A stop that interrupts the drive gets its own small disc, decided before the photo
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
@@ -612,6 +540,8 @@ export function MapViewGL({
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<number, PlacePin>>(new Map())
/** The count bubbles, one per cluster the source currently reports. */
const clusterPinsRef = useRef<PlacePin[]>([])
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
const pinLayerRef = useRef<HTMLDivElement | null>(null)
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
@@ -642,6 +572,7 @@ export function MapViewGL({
// in the same frame the canvas draws, or the ones left out swim against the ones in.
const repositionPins = useCallback(() => {
markersRef.current.forEach(pin => pin.reposition())
clusterPinsRef.current.forEach(pin => pin.reposition())
poiMarkersRef.current.forEach(pin => pin.reposition())
pluginMarkersRef.current.forEach(pin => pin.reposition())
routeViaMarkersRef.current.forEach(pin => pin.reposition())
@@ -1304,21 +1235,7 @@ export function MapViewGL({
? map.queryRenderedFeatures(e.point, { layers: [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID] })
: []
const feature = features?.[0]
const clusterId = feature?.properties?.cluster_id
const coordinates = feature?.geometry?.coordinates
if (clusterId == null || !Array.isArray(coordinates)) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as any
const easeToZoom = (nextZoom: number) => {
try { map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 }) } catch { /* noop */ }
}
try {
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err: Error | null, nextZoom: number) => {
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom)
})
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom)
else if (maybeZoom && typeof maybeZoom.then === 'function') maybeZoom.then(easeToZoom).catch(() => {})
} catch { /* noop */ }
expandCluster(map, feature?.properties?.cluster_id, feature?.geometry?.coordinates)
}
const setClusterCursor = () => {
const canvas = typeof map.getCanvas === 'function' ? map.getCanvas() : null
@@ -1704,8 +1621,17 @@ export function MapViewGL({
source.setData(buildPlaceClusterData(places) as any)
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
let raf: number | null = null
const dropClusterPins = () => {
clusterPinsRef.current.forEach(pin => pin.remove())
clusterPinsRef.current = []
}
const reconcileClusterPins = () => {
dropClusterPins()
clusterPinsRef.current = drawClusterBubbles(map, (el, lng, lat) => attachPin(map, gl, pinLayerRef.current, el, lng, lat))
}
const runReconcile = () => {
raf = null
reconcileClusterPins()
const features = map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['!', ['has', 'point_count']] }) || []
const seen = new Set<number>()
const visiblePlaces: PlaceWithCoords[] = []
@@ -1752,6 +1678,7 @@ export function MapViewGL({
return () => {
if (raf !== null) cancelAnimationFrame(raf)
dropClusterPins()
map.off('moveend', scheduleReconcile)
map.off('zoomend', scheduleReconcile)
map.off('idle', scheduleReconcile)
+2 -1
View File
@@ -1,5 +1,6 @@
import L from 'leaflet'
import { STACK_RADIUS_PX } from './coincidentPlaces'
import { clusterBubbleSize } from './placeClusterLayers'
/** Up to here the map is an overview, where stops merge to keep the world readable. */
export const CLUSTER_UNTIL_ZOOM = 9
@@ -66,7 +67,7 @@ export interface ClusterLike {
/** A count bubble sized by its count, styled by `.marker-cluster-custom` in index.css. */
export function createClusterIcon(cluster: ClusterLike) {
const count = cluster.getChildCount()
const size = count < 10 ? 36 : count < 50 ? 42 : 48
const size = clusterBubbleSize(count)
return L.divIcon({
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
className: 'marker-cluster-wrapper',
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest';
import {
type ClusterMap,
PLACE_CLUSTER_SOURCE_ID,
addPlaceClusterLayers,
clusterBubbleSize,
createClusterBubble,
drawClusterBubbles,
} from './placeClusterLayers';
function fakeMap(features: unknown[], expansionZoom: unknown = 11) {
const source = { getClusterExpansionZoom: vi.fn(() => expansionZoom) };
const map = {
addSource: vi.fn(),
addLayer: vi.fn(),
getSource: vi.fn((id: string) => (id === PLACE_CLUSTER_SOURCE_ID ? source : undefined)),
easeTo: vi.fn(),
querySourceFeatures: vi.fn((_id: string, query: { filter: unknown[] }) =>
query.filter[0] === 'has' ? features : []
),
};
return { map: map as unknown as ClusterMap & typeof map, source };
}
describe('cluster bubbles', () => {
it('FE-COMP-PLACECLUSTER-001: a bubble grows in three steps with its count', () => {
expect([1, 9, 10, 49, 50, 500].map(clusterBubbleSize)).toEqual([36, 36, 42, 42, 48, 48]);
});
it('FE-COMP-PLACECLUSTER-002: the GL bubble is the Leaflet markup inside a bare wrapper the pin can move', () => {
const wrapper = createClusterBubble(38);
expect(wrapper.className).toBe('');
const bubble = wrapper.firstElementChild as HTMLElement;
expect(bubble.className).toBe('marker-cluster-custom');
expect(bubble.style.width).toBe('42px');
expect(bubble.style.height).toBe('42px');
expect(bubble.textContent).toBe('38');
});
it('FE-COMP-PLACECLUSTER-003: the canvas circle and count stay as an invisible hit area', () => {
const { map } = fakeMap([]);
addPlaceClusterLayers(map);
expect(map.addSource).toHaveBeenCalledWith(PLACE_CLUSTER_SOURCE_ID, expect.objectContaining({ cluster: true }));
const layers = map.addLayer.mock.calls.map(([layer]) => layer as { id: string; paint: Record<string, unknown> });
expect(layers.find((l) => l.id === 'trip-place-clusters-circle')?.paint['circle-opacity']).toBe(0);
expect(layers.find((l) => l.id === 'trip-place-clusters-count')?.paint['text-opacity']).toBe(0);
});
it('FE-COMP-PLACECLUSTER-004: one bubble per cluster, placed at its centre, that zooms in on a click', () => {
const cluster = { properties: { cluster_id: 5, point_count: 12 }, geometry: { coordinates: [139.7, 35.6] } };
const { map, source } = fakeMap([
cluster,
cluster, // the same cluster crossing a tile edge
{ properties: { cluster_id: 6 }, geometry: { coordinates: [1, 1] } }, // no count
{ properties: { cluster_id: 7, point_count: 3 } }, // no geometry
]);
const placed: { el: HTMLElement; lng: number; lat: number }[] = [];
const pins = drawClusterBubbles(map, (el, lng, lat) => {
placed.push({ el, lng, lat });
return lng;
});
expect(pins).toEqual([139.7]);
expect(placed[0].el.textContent).toBe('12');
expect(placed[0].lat).toBe(35.6);
const onMapClick = vi.fn();
document.body.appendChild(placed[0].el);
document.body.addEventListener('click', onMapClick);
placed[0].el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.body.removeEventListener('click', onMapClick);
placed[0].el.remove();
// The click is the bubble's: the map underneath never sees it.
expect(onMapClick).not.toHaveBeenCalled();
expect(source.getClusterExpansionZoom).toHaveBeenCalledWith(5, expect.any(Function));
expect(map.easeTo).toHaveBeenCalledWith({ center: [139.7, 35.6], zoom: 11, duration: 350 });
});
it('FE-COMP-PLACECLUSTER-005: a source that reports nothing draws nothing', () => {
const { map } = fakeMap([]);
map.querySourceFeatures.mockReturnValue(null as never);
expect(drawClusterBubbles(map, () => 1)).toEqual([]);
});
});
@@ -5,3 +5,169 @@
*/
export const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle';
export const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count';
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
// HTML photo markers *and* cluster them natively, so we feed the place points
// into a clustered GeoJSON source. The source decides what is clustered; the
// bubbles are drawn as DOM (`drawClusterBubbles`) and the individual rich HTML
// markers only for the points the source reports as currently unclustered.
// Grouping is always on, matching the Leaflet map's MarkerClusterGroup.
export const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters';
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit';
/**
* How eagerly place markers merge into a cluster.
*
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
* kept as plain numbers rather than shared constants so the GL bundle does not have to
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
* own, and the stops that share a coordinate are folded together at the pin level
* instead — see the reconcile in MapViewGL.
*/
const CLUSTER_RADIUS = 20;
const CLUSTER_MAX_ZOOM = 8;
/** The part of either SDK's map the cluster code calls. */
export interface ClusterMap {
addSource: (id: string, source: object) => void;
addLayer: (layer: object) => void;
getSource: (id: string) => unknown;
easeTo: (options: { center: unknown[]; zoom: number; duration: number }) => void;
querySourceFeatures: (id: string, options: { filter: unknown[] }) => ClusterFeature[] | null | undefined;
}
interface ClusterFeature {
properties?: { cluster_id?: unknown; point_count?: unknown } | null;
geometry?: { coordinates?: unknown };
}
interface ClusterSource {
getClusterExpansionZoom?: (id: unknown, done: (err: Error | null, zoom: number) => void) => unknown;
}
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
* change and the initial build cannot drift apart. */
export function addPlaceClusterLayers(map: Pick<ClusterMap, 'addSource' | 'addLayer'>): void {
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: CLUSTER_RADIUS,
clusterMaxZoom: CLUSTER_MAX_ZOOM,
});
// Invisible: the bubble the user sees is DOM (createClusterBubble), these two
// layers stay as the hit area the track and route layers check a click against.
map.addLayer({
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-opacity': 0,
'circle-stroke-opacity': 0,
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
},
});
map.addLayer({
id: PLACE_CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: { 'text-opacity': 0 },
});
map.addLayer({
id: PLACE_UNCLUSTERED_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 24,
'circle-opacity': 0,
'circle-stroke-opacity': 0,
},
});
}
/** Eases the map in to the zoom a cluster falls apart at. Both SDKs answer that
* question, one through a callback and one through a promise. */
export function expandCluster(
map: Pick<ClusterMap, 'getSource' | 'easeTo'>,
clusterId: unknown,
coordinates: unknown
): void {
if (clusterId == null || !Array.isArray(coordinates)) return;
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as ClusterSource | undefined;
const easeToZoom = (nextZoom: number) => {
try {
map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 });
} catch {
/* noop */
}
};
try {
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err, nextZoom) => {
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom);
});
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom);
else if (maybeZoom instanceof Promise) maybeZoom.then(easeToZoom).catch(() => {});
} catch {
/* noop */
}
}
/**
* Puts one count bubble on the map per cluster the source currently reports, through
* `place` (the caller's pin factory), and returns what `place` handed back. A cluster
* crossing a tile edge comes back once per tile, so bubbles are keyed by cluster id.
*/
export function drawClusterBubbles<T>(map: ClusterMap, place: (el: HTMLElement, lng: number, lat: number) => T): T[] {
const pins: T[] = [];
const seen = new Set<unknown>();
for (const feature of map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['has', 'point_count'] }) ?? []) {
const clusterId = feature?.properties?.cluster_id;
const count = feature?.properties?.point_count;
const coordinates = feature?.geometry?.coordinates;
if (typeof count !== 'number' || !Array.isArray(coordinates) || seen.has(clusterId)) continue;
seen.add(clusterId);
const el = createClusterBubble(count);
el.addEventListener('click', (ev) => {
ev.stopPropagation();
expandCluster(map, clusterId, coordinates);
});
pins.push(place(el, coordinates[0], coordinates[1]));
}
return pins;
}
/** A count bubble's diameter in pixels: both renderers size their bubbles by it. */
export function clusterBubbleSize(count: number): number {
return count < 10 ? 36 : count < 50 ? 42 : 48;
}
/**
* A count bubble as DOM, styled by `.marker-cluster-custom` in index.css.
*
* The GL map draws its bubbles with this element rather than as canvas text, so they
* read in the app's own font and get the same hover the Leaflet map has. A canvas
* symbol layer can only use the glyphs the map style ships and knows no `:hover`.
* The bubble sits in a bare wrapper because the pin positions its element through
* `transform`, which would otherwise override the hover's scale.
*/
export function createClusterBubble(count: number): HTMLDivElement {
const size = clusterBubbleSize(count);
const bubble = document.createElement('div');
bubble.className = 'marker-cluster-custom';
bubble.style.width = `${size}px`;
bubble.style.height = `${size}px`;
const label = document.createElement('span');
label.textContent = String(count);
bubble.appendChild(label);
const wrapper = document.createElement('div');
wrapper.appendChild(bubble);
return wrapper;
}
+3 -3
View File
@@ -13,9 +13,9 @@ import { studioLayerActions, type StudioLayerActions } from './studioLayers'
* The book being edited.
*
* History is snapshot-based rather than patch-based, and deliberately so: a
* document is a few hundred kilobytes of plain JSON at most (the schema caps it
* at 150 spreads of 60 elements), and a snapshot stack that cannot possibly
* disagree with the document beats a patch stack that can. It is capped so a
* snapshot shares every page it did not change with the one before it, so even
* a book of hundreds of pages costs one page per step, and a snapshot stack
* that cannot possibly disagree with the document beats a patch stack that can. It is capped so a
* long session does not grow without limit.
*
* Granularity is the part that matters for how it *feels*: a drag is one undo
@@ -268,9 +268,11 @@ describe('the entry spreads', () => {
})
describe('the document as a whole', () => {
it('stays within the schema cap on spreads', () => {
it('lays out a long journey whole, its last page and back cover after the entries', () => {
const many = Array.from({ length: 200 }, (_, i) => entry({ id: i + 1 }))
expect(build({ entries: many }).spreads.length).toBeLessThanOrEqual(150)
const doc = build({ entries: many })
expect(doc.spreads.length).toBeGreaterThan(150)
expect(doc.spreads.slice(-2).map(sp => sp.role)).toEqual(['last', 'back'])
})
it('gives every spread and every element an id of its own', () => {
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "@trek/root",
"version": "5.0.0",
"version": "5.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@trek/root",
"version": "5.0.0",
"version": "5.0.1",
"workspaces": [
"client",
"server",
@@ -29,7 +29,7 @@
},
"client": {
"name": "@trek/client",
"version": "5.0.0",
"version": "5.0.1",
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/abril-fatface": "^5.3.0",
@@ -25005,7 +25005,7 @@
},
"server": {
"name": "@trek/server",
"version": "5.0.0",
"version": "5.0.1",
"dependencies": {
"@aws-lite/client": "^0.23.7",
"@aws-lite/s3": "^0.2.7",
@@ -25384,7 +25384,7 @@
},
"shared": {
"name": "@trek/shared",
"version": "5.0.0",
"version": "5.0.1",
"dependencies": {
"isomorphic-dompurify": "^3.23.0",
"zod": "^4.6.7"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@trek/root",
"private": true,
"version": "5.0.0",
"version": "5.0.1",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/server",
"version": "5.0.0",
"version": "5.0.1",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
+4 -2
View File
@@ -177,13 +177,15 @@ export async function buildApp(): Promise<INestApplication> {
* it is the wrong one for a photo book. A Studio document is sent WHOLE on
* every autosave — deliberately, see book-store.schema.ts, because a patch
* protocol would need an ordering guarantee and a merge rule per field — and
* the contract already caps it at 150 spreads of 90 elements. A real book of
* a fortnight's journey is well past a hundred kilobytes before anybody asks
* a book has as many pages as its journey needs. A real book of a
* fortnight's journey is well past a hundred kilobytes before anybody asks
* for road geometry, and what the ceiling produces is not an error message
* but a save that quietly fails and an editor that says "not saved" without
* saying why.
*
* So the book route is measured against the size a book can actually be.
* An auto laid page is two to five kilobytes, so eight megabytes hold a
* book of a few thousand pages: the page count has no limit of its own.
*
* A list file is the same story at a smaller scale. Its contract allows a
* megabyte and the import posts it whole, yet a list of a few hundred places
@@ -1,6 +1,6 @@
import { safeFetch, safeFetchLlm } from '../../utils/ssrfGuard';
import type { AiChatClient } from './ai-chat-client.interface';
import { AI_BASE_URLS } from './ai-connections.constants';
import { AI_BASE_URLS, ownServerChatBase } from './ai-connections.constants';
import type { ResolvedAiConnection } from './ai-connections.types';
import { ChatgptAuthService } from './chatgpt-auth.service';
import { AnthropicMessagesClient } from './providers/anthropic-messages.client';
@@ -67,7 +67,7 @@ export class AiChatClientService {
return new OpenAiChatClient(
kind,
model,
(connection.baseUrl ?? '').replace(/(?<!\/)\/+$/, ''),
ownServerChatBase(connection.baseUrl),
credential.apiKey,
adminServerFetch,
);
@@ -22,6 +22,26 @@ export function aiHostOf(kind: AiConnectionKind, baseUrl: string | null): string
}
}
/**
* Where an own server's Chat Completions live. An address with a path, such as
* `https://api.groq.com/openai/v1` or `http://ollama.lan:11434/v1`, is used as
* entered. A bare server address gets `/v1`: Ollama, LM Studio, vLLM,
* llama.cpp and LiteLLM all answer there, while Ollama answers
* `/chat/completions` at its root with a 404. Connecting already found the
* model list of such an address under the root, so the chat failed where the
* connection had worked.
*/
export function ownServerChatBase(baseUrl: string | null): string {
const entered = (baseUrl ?? '').trim().replace(/(?<!\/)\/+$/, '');
try {
const url = new URL(entered);
if (url.pathname === '/' && !url.search && !url.hash) return `${entered}/v1`;
} catch {
// An address that does not parse fails on the request, as it did before.
}
return entered;
}
/** Whether OpenRouter's OAuth can send the browser back here: it only returns to an https address. */
export function openrouterOAuthAvailable(): boolean {
return getAppUrl().startsWith('https://');
+1 -2
View File
@@ -1,7 +1,6 @@
import {
bookSpreadPhotoIds,
compareCodeUnits,
MAX_SPREADS,
type BookDesignPlanRow,
type BookDesignSkipped,
type BookDocument,
@@ -176,7 +175,7 @@ export function applyDesignDraft(
if (at < 0 || !samePage(spreads[at], base.get(row.spreadId))) return skip(row.spreadId, 'changed_since');
spreads[at] = page;
} else {
if (at >= 0 || spreads.length >= MAX_SPREADS) return skip(row.spreadId, 'changed_since');
if (at >= 0) return skip(row.spreadId, 'changed_since');
spreads.splice(insertAt(spreads, draft.plan, i, page), 0, page);
}
applied.push(row.spreadId);
@@ -178,7 +178,7 @@ export class JourneyBookDesignService {
baseVersion: draft.baseVersion,
expiresAt: new Date(draft.expiresAt).toISOString(),
document: draft.doc,
plan: draft.plan.slice(0, BOOK_DESIGN_LIMITS.selection).map(planRowOut),
plan: draft.plan.map(planRowOut),
selection: draft.selection,
};
}
@@ -376,7 +376,7 @@ function answerOf(draft: BookDesignDraft): BookDesignAnswer {
expires_at: new Date(draft.expiresAt).toISOString(),
plan: rows,
more_rows: draft.plan.length - rows.length,
selected: draft.selection.slice(0, BOOK_DESIGN_LIMITS.selection),
selected: draft.selection,
style: draft.style,
photos: {
used: draft.usedPhotos,
+2 -2
View File
@@ -59,7 +59,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
titleKey: 'system_notice.release_notes.headline',
bodyKey: 'system_notice.release_notes.intro',
release: {
version: '5.0.0',
version: '5.0.1',
eyebrowKey: 'system_notice.release_notes.eyebrow',
headlineKey: 'system_notice.release_notes.headline',
introKey: 'system_notice.release_notes.intro',
@@ -95,7 +95,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
footnoteKey: 'system_notice.release_notes.footnote',
notes: {
labelKey: 'system_notice.release_notes.notes_label',
href: 'https://github.com/liketrek/TREK/releases/tag/v5.0.0',
href: 'https://github.com/liketrek/TREK/releases/tag/v5.0.1',
},
note: {
eyebrowKey: 'system_notice.release_notes.note_eyebrow',
+31 -7
View File
@@ -100,6 +100,16 @@ async function answer(req: IncomingMessage, res: ServerResponse): Promise<void>
let nestApp: INestApplication;
let orm: MikroORM;
let fake: HttpServer;
let adminId: number;
/** The instance connection, pointed at the fake under `path`: `/v1`, or nothing as an Ollama address is often entered. */
function connectAt(path: string) {
const { port } = fake.address() as AddressInfo;
return request(nestApp.getHttpServer())
.put('/api/admin/ai/connection')
.set('Cookie', sessionCookie(adminId))
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}${path}`, model: 'fake-model' });
}
beforeAll(async () => {
fake = createServer((req, res) => {
@@ -110,13 +120,8 @@ beforeAll(async () => {
orm = nestApp.get(MikroORM);
await setAddonEnabled(orm, 'ai_assistant', true);
await setAddonEnabled(orm, 'atlas', true);
const adminId = (await makeUser(orm, { role: 'admin' })).user.id;
const { port } = fake.address() as AddressInfo;
const res = await request(nestApp.getHttpServer())
.put('/api/admin/ai/connection')
.set('Cookie', sessionCookie(adminId))
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}/v1`, model: 'fake-model' });
expect(res.status).toBe(200);
adminId = (await makeUser(orm, { role: 'admin' })).user.id;
expect((await connectAt('/v1')).status).toBe(200);
});
beforeEach(async () => {
@@ -342,4 +347,23 @@ describe('Home assistant e2e', () => {
expect(await countRows(orm, AssistantThreads, { id: homeThread })).toBe(0);
expect(await countRows(orm, AssistantThreads, { id: tripThread })).toBe(1);
});
it('ASTHE2E-007: an Ollama address entered without /v1 chats at /v1/chat/completions, where Ollama answers', async () => {
const h = await ownerWithTrips();
expect((await connectAt('')).status).toBe(200);
try {
provider.script = [say('Hello from Ollama.')];
const res = await post(`${BASE}/chat`, h.userId, { ...CTX, threadId: null, text: 'hi' });
expect(res.status).toBe(200);
expect(provider.requests).toHaveLength(1);
expect(
ofType(res.chunks, 'text-delta')
.map((c) => c.delta)
.join(''),
).toBe('Hello from Ollama.');
expect(ofType(res.chunks, 'error')).toEqual([]);
} finally {
expect((await connectAt('/v1')).status).toBe(200);
}
});
});
@@ -7,6 +7,7 @@ import {
aiHostOf,
aiRefusal,
openrouterOAuthAvailable,
ownServerChatBase,
providerRefusal,
} from '../../../../src/nest/ai-connections/ai-connections.constants';
import { InMemoryAiRefreshLock } from '../../../../src/nest/ai-connections/ai-refresh-lock';
@@ -145,6 +146,16 @@ describe('constants', () => {
expect(aiHostOf('custom', null)).toBe('');
});
it('AIHLP-032b: a bare own server address chats under /v1, an address with a path as entered', () => {
expect(ownServerChatBase('http://ollama.lan:11434')).toBe('http://ollama.lan:11434/v1');
expect(ownServerChatBase(' http://ollama.lan:11434/ ')).toBe('http://ollama.lan:11434/v1');
expect(ownServerChatBase('http://ollama.lan:11434/v1/')).toBe('http://ollama.lan:11434/v1');
expect(ownServerChatBase('https://api.groq.com/openai/v1')).toBe('https://api.groq.com/openai/v1');
expect(ownServerChatBase('https://llm.example.org/proxy')).toBe('https://llm.example.org/proxy');
expect(ownServerChatBase('not a url')).toBe('not a url');
expect(ownServerChatBase(null)).toBe('');
});
it('AIHLP-033: OpenRouter sign-in is offered only on an https app URL', () => {
vi.stubEnv('APP_URL', 'https://trip.example.org');
expect(openrouterOAuthAvailable()).toBe(true);
@@ -19,7 +19,6 @@ import {
bookPhotoElementSchema,
bookShapeElementSchema,
bookTextElementSchema,
MAX_SPREADS,
type BookDesignPlanRow,
type BookDocument,
type BookSpread,
@@ -310,15 +309,15 @@ describe('applyDesignDraft', () => {
expect(out.doc.spreads).toEqual(base.spreads);
});
it('JBAPPLY-014: a book at the page limit takes no new page, the page named as changed since', () => {
const inner = Array.from({ length: MAX_SPREADS - 1 }, (_, i) => page(`i${i}`, 'inner'));
const full = book([page('c', 'cover'), ...inner]);
const draft: DraftToApply = { base: full, doc: book(pages('n')), plan: [row('n', 'insert')], selection: ['n'] };
it('JBAPPLY-014: a book of hundreds of pages still takes a new page', () => {
const inner = Array.from({ length: 400 }, (_, i) => page(`i${i}`, 'inner'));
const long = book([page('c', 'cover'), ...inner]);
const draft: DraftToApply = { base: long, doc: book(pages('n')), plan: [row('n', 'insert')], selection: ['n'] };
const out = applyDesignDraft(full, draft, new Set());
const out = applyDesignDraft(long, draft, new Set());
expect(out.skipped).toEqual([{ spreadId: 'n', reason: 'changed_since' }]);
expect(out.doc.spreads).toHaveLength(MAX_SPREADS);
expect([out.applied, out.skipped]).toEqual([['n'], []]);
expect(out.doc.spreads).toHaveLength(402);
});
it('JBAPPLY-015: a page whose letters are filled with a photo gone since stays out, as a frame of it would', () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/shared",
"version": "5.0.0",
"version": "5.0.1",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
+4 -8
View File
@@ -5,7 +5,7 @@ import {
bookPageOpSchema,
bookSpreadIdSchema,
} from '../../book/book-ops.schema';
import { bookSpreadSchema, MAX_BOOK_TITLE, MAX_SPREADS } from '../../book/book.schema';
import { bookSpreadSchema, MAX_BOOK_TITLE } from '../../book/book.schema';
import { idSchema } from '../../common/primitives.schema';
import { cardCountSchema, cardText } from './card-parts';
@@ -33,14 +33,10 @@ export const BOOK_PAGE_CARD_CHANGES = ['new', 'changed', 'moved', 'willDelete']
export const bookCardPageSchema = z.object({
spreadId: bookSpreadIdSchema,
/** Where it stands in the book, the cover at 0. */
index: z
.number()
.int()
.min(0)
.max(MAX_SPREADS - 1),
index: z.number().int().min(0),
role: bookSpreadSchema.shape.role.unwrap(),
/** Its place among the double pages, from 1; null for a single page (the covers, the first and the last). */
number: z.number().int().min(1).max(MAX_SPREADS).nullable(),
number: z.number().int().min(1).nullable(),
/** Photos placed on it. */
photos: cardCountSchema,
});
@@ -85,7 +81,7 @@ export const bookPageCardSchema = z.object({
/** How many pages the change adds or deletes; for a new book, its pages. */
count: cardCountSchema,
/** Where a moved page lands among the double pages, from 1. */
movedTo: z.number().int().min(1).max(MAX_SPREADS).nullable(),
movedTo: z.number().int().min(1).nullable(),
/**
* `edit_book_page`'s operations, to draw the page after where the card has
* no `after` or the Studio's book moved on from `baseVersion`; empty for
+2 -2
View File
@@ -1,6 +1,6 @@
import { bookElementId } from '../book-ids';
import { blankBook, blankSpread } from '../book-pages';
import { MAX_BOOK_TITLE, MAX_SPREADS, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
import { MAX_BOOK_TITLE, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
import type { JourneyStats } from '../journey-stats.schema';
import {
STATIONS_PER_PAGE,
@@ -155,7 +155,7 @@ export function buildBook(input: AutoInput): BookDocument {
version: 1,
title: input.title.slice(0, MAX_BOOK_TITLE),
page: withFirstPage(input.page),
spreads: spreads.slice(0, MAX_SPREADS),
spreads,
};
}
@@ -108,6 +108,21 @@ describe('the auto layout moved from the Studio', () => {
expect(new Set(ids).size).toBe(ids.length);
});
it('AUTOPAR-005: lays out every entry of a long journey, the last page and the back cover after them', () => {
const fx = fixtures.A as Fixture;
const entries = Array.from({ length: 40 }, (_, round) =>
fx.input.entries.map((e) => ({ ...e, id: e.id + 100000 * (round + 1) })),
).flat();
let n = 0;
const doc = buildBook({ ...fx.input, entries, newId: (p) => `${p}-c${++n}` });
expect(doc.spreads.length).toBeGreaterThan(300);
expect(doc.spreads.slice(-2).map((s) => s.role)).toEqual(['last', 'back']);
const laid = new Set(doc.spreads.map((s) => s.entryId));
const withPages = new Set(buildBook({ ...fx.input, newId: (p) => `${p}-d${++n}` }).spreads.map((s) => s.entryId));
const expected = entries.filter((e) => withPages.has(e.id % 100000));
expect(expected.every((e) => laid.has(e.id))).toBe(true);
});
it('AUTOPAR-004: counts the day of an entry in calendar days, a change to summer time between', () => {
const stats: JourneyStats = {
journeyId: 1,
@@ -1,5 +1,4 @@
import {
BOOK_DESIGN_LIMITS,
applyBookDesignRequestSchema,
bookDesignAnswerSchema,
bookDesignApplyResultSchema,
@@ -70,10 +69,9 @@ describe('the design tools', () => {
expect(removeBookLayoutRequestSchema.safeParse({ layoutId: 'ly-1', expectedVersion: 4 }).success).toBe(true);
});
it('BKDTOOL-004: the selection is a closed list of page ids, as long as a book and its drops', () => {
const ids = Array.from({ length: BOOK_DESIGN_LIMITS.selection }, (_, i) => `sp-${i}`);
it('BKDTOOL-004: the selection is a closed list of page ids, as long as the book it picks from', () => {
const ids = Array.from({ length: 1200 }, (_, i) => `sp-${i}`);
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: ids }).success).toBe(true);
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: [...ids, 'sp-x'] }).success).toBe(false);
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: ['sp 1'] }).success).toBe(false);
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: [], all: true }).success).toBe(false);
});
+9 -15
View File
@@ -7,7 +7,7 @@ import {
bookWriteResultSchema,
editBookPageArgs,
} from './book-ops.schema';
import { BOOK_FONTS_IDS, MAX_SPREADS, MAX_SPREAD_ELEMENTS, bookDocumentSchema } from './book.schema';
import { BOOK_FONTS_IDS, MAX_SPREAD_ELEMENTS, bookDocumentSchema } from './book.schema';
import { BOOK_DESIGN_STYLES, bookDesignDirectivesSchema } from './design/design-directives.schema';
import { BOOK_DESIGN_ACTIONS } from './design/design-merge';
import { BOOK_DESIGN_BEATS } from './design/design-pacing';
@@ -34,8 +34,6 @@ export const BOOK_DESIGN_LIMITS = {
notes: 20,
/** Pages of the book an apply answers with; every applied id is still named. */
pages: 24,
/** Pages a selection names at most: every page of a book and every page it drops. */
selection: MAX_SPREADS * 2,
/** Revisions a journey keeps, the newest first. */
revisions: 5,
} as const;
@@ -82,11 +80,7 @@ export type RestoreBookLayoutRequest = z.infer<typeof restoreBookLayoutRequestSc
/** One page of a design's plan, as an answer and the draft route carry it. */
export const bookDesignPlanRowSchema = z.object({
spreadId: bookSpreadIdSchema,
index: z
.number()
.int()
.min(0)
.max(MAX_SPREADS * 2),
index: z.number().int().min(0),
action: z.enum(BOOK_DESIGN_ACTIONS),
beat: z.enum(BOOK_DESIGN_BEATS),
chapter: z.string().max(80).nullable(),
@@ -121,7 +115,7 @@ export const bookDesignAnswerSchema = z.object({
/** Plan rows past those listed. */
more_rows: z.number().int().nonnegative(),
/** The pages apply_book_design writes unless the person takes some out. */
selected: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
selected: z.array(bookSpreadIdSchema),
style: bookDesignStyleSchema,
photos: z.object({
used: z.number().int().nonnegative(),
@@ -159,19 +153,19 @@ export const bookDesignApplyResultSchema = bookWriteResultSchema.extend({
design_id: bookDesignIdSchema,
/** The revision that takes the apply back; null when the journey had no book before it. */
revision_id: idSchema.nullable(),
applied: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
skipped: z.array(bookDesignSkippedSchema).max(BOOK_DESIGN_LIMITS.selection),
applied: z.array(bookSpreadIdSchema),
skipped: z.array(bookDesignSkippedSchema),
});
export type BookDesignApplyResult = z.infer<typeof bookDesignApplyResultSchema>;
/** PUT /api/journeys/:journeyId/book-designs/:designId/selection: the pages the apply writes. */
export const bookDesignSelectionRequestSchema = z.strictObject({
spreadIds: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
spreadIds: z.array(bookSpreadIdSchema),
});
export type BookDesignSelectionRequest = z.infer<typeof bookDesignSelectionRequestSchema>;
export const bookDesignSelectionResponseSchema = z.object({
selection: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
selection: z.array(bookSpreadIdSchema),
});
export type BookDesignSelectionResponse = z.infer<typeof bookDesignSelectionResponseSchema>;
@@ -181,7 +175,7 @@ export const bookDesignDraftResponseSchema = z.object({
baseVersion: version.nullable(),
expiresAt: z.string(),
document: bookDocumentSchema,
plan: z.array(bookDesignPlanRowSchema).max(BOOK_DESIGN_LIMITS.selection),
selection: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
plan: z.array(bookDesignPlanRowSchema),
selection: z.array(bookSpreadIdSchema),
});
export type BookDesignDraftResponse = z.infer<typeof bookDesignDraftResponseSchema>;
-4
View File
@@ -21,7 +21,6 @@ import { bookElementPhotoIds, checkRestorablePhotos } from './book-photo-refs';
import { bookLayoutFromSpread, nextBookLayoutName } from './book-saved-layouts';
import {
MAX_BOOK_LAYOUTS,
MAX_SPREADS,
bookLayoutSchema,
type BookDocument,
type BookLayout,
@@ -107,9 +106,6 @@ export function duplicateBookPage(doc: BookDocument, args: unknown, ctx: BookOps
if (!canEditSpread(source)) {
throw failOp('protected_spread', `${source.id} is the ${source.role} page and cannot be copied`, null);
}
if (doc.spreads.length + 1 > MAX_SPREADS) {
throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null);
}
const fresh = <E extends { id: string; kind: string }>(e: E): E => ({ ...e, id: newId(e.kind.charAt(0)) });
const copy = checkedSpread(
{ ...source, id: newId('sp'), elements: source.elements.map(fresh), parked: source.parked.map(fresh) },
-1
View File
@@ -32,7 +32,6 @@ export const BOOK_OP_ERROR_CODES = [
'layout_not_found',
'layout_role',
'too_many_elements',
'too_many_spreads',
'parked_full',
'empty_frame',
'caption_missing',
+3 -12
View File
@@ -20,7 +20,6 @@ import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './boo
import { bookPaintInSchema } from './book-paint.schema';
import {
MAX_BOOK_TITLE,
MAX_SPREADS,
MAX_SPREAD_ELEMENTS,
bookBadgeElementSchema,
bookCountriesElementSchema,
@@ -273,11 +272,7 @@ export const addBookPagesArgs = {
};
export const moveBookPageArgs = {
spreadId: bookSpreadIdSchema,
toIndex: z
.number()
.int()
.min(0)
.max(MAX_SPREADS - 1),
toIndex: z.number().int().min(0),
expectedVersion,
};
export const deleteBookPagesArgs = {
@@ -302,11 +297,7 @@ export const createBookArgs = {
* it goes back when it is gone, a page still in the book is replaced where it is.
*/
export const bookPreviousSpreadSchema = z.object({
index: z
.number()
.int()
.min(0)
.max(MAX_SPREADS - 1),
index: z.number().int().min(0),
spread: bookSpreadSchema,
});
export type BookPreviousSpread = z.infer<typeof bookPreviousSpreadSchema>;
@@ -625,7 +616,7 @@ export const bookWriteResultSchema = z.object({
dropped: z.array(z.string()).max(MAX_SPREAD_ELEMENTS * 2),
previous_spreads: z.array(bookPreviousSpreadSchema).optional(),
/** The pages the write touched as it left them, for an undo that checks only those (`expectedPages`). */
page_digests: z.array(bookPageDigestSchema).max(MAX_SPREADS).optional(),
page_digests: z.array(bookPageDigestSchema).optional(),
from_index: z.number().int().nonnegative().optional(),
/** From `set_book_setup`: the setup as it was, to set it back. */
previous_setup: bookPageSetupSchema.optional(),
+5 -3
View File
@@ -2,7 +2,6 @@ import type { BookEditResult } from './book-ops-core';
import { addBookPages, deleteBookPages, moveBookPage, restoreBookPage, setBookCover } from './book-page-ops';
import { blankBook, blankSpread, bookPageSetupForPreset, canEditSpread, innerSpreadBounds } from './book-pages';
import {
MAX_SPREADS,
bookDocumentSchema,
bookPageSetupSchema,
bookPhotoElementSchema,
@@ -129,10 +128,13 @@ describe('add, move and delete', () => {
expect(refused(stray)).toBe('entry_not_found');
});
it('BOOKPG-007: refuses an unknown page, more than ten pages, and a book at its page cap', () => {
it('BOOKPG-007: refuses an unknown page and more than ten pages, and grows a book of any length', () => {
expect(refused(addBookPages(book(1), { afterSpreadId: 'sp-nope' }))).toBe('spread_not_found');
expect(refused(addBookPages(book(1), { afterSpreadId: 'sp-in1', count: 11 }))).toBe('invalid_op');
expect(refused(addBookPages(book(MAX_SPREADS - 4), { afterSpreadId: 'sp-in1' }))).toBe('too_many_spreads');
const long = book(400);
const grown = ok(addBookPages(long, { afterSpreadId: 'sp-in400', count: 10 }, { newId }));
expect(grown.spreads).toHaveLength(long.spreads.length + 10);
expect(grown.spreads.slice(-2).map((s) => s.role)).toEqual(['last', 'back']);
});
it('BOOKPG-008: moves an inner spread within the inner range', () => {
-7
View File
@@ -23,7 +23,6 @@ import {
import { blankSpread, canEditSpread, innerSpreadBounds } from './book-pages';
import { bookSpreadPhotoIds, checkRestorablePhotos } from './book-photo-refs';
import {
MAX_SPREADS,
bookSpreadSchema,
type BookDocument,
type BookElement,
@@ -57,9 +56,6 @@ export function addBookPages(doc: BookDocument, args: unknown, ctx: BookOpsConte
return caught(() => {
const after = indexOf(doc, afterSpreadId);
if (entryId !== undefined) journeyEntry(entryId, ctx, null);
if (doc.spreads.length + count > MAX_SPREADS) {
throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null);
}
const { first, limit } = innerSpreadBounds(doc.spreads);
const at = Math.min(Math.max(after + 1, first), limit);
const added = Array.from({ length: count }, () =>
@@ -184,9 +180,6 @@ function putBack(doc: BookDocument, spread: BookSpread, index: number): BookDocu
if (spread.role !== 'inner' && doc.spreads.some((s) => s.role === spread.role)) {
throw failOp('protected_spread', `the book already has its ${spread.role} page`, null);
}
if (doc.spreads.length >= MAX_SPREADS) {
throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null);
}
const { first, limit } = innerSpreadBounds(doc.spreads);
const to = spread.role === 'inner' ? Math.min(Math.max(index, first), limit) : Math.min(index, doc.spreads.length);
return { ...doc, spreads: [...doc.spreads.slice(0, to), spread, ...doc.spreads.slice(to)] };
+4 -4
View File
@@ -20,10 +20,10 @@ import { z } from 'zod';
*
* ── Why the whole document ───────────────────────────────────────────────
*
* Not a patch. A book is a few hundred kilobytes of JSON at most — the schema
* caps it at 150 spreads of 60 elements — and a patch protocol would need an
* ordering guarantee, a merge rule per field, and a way to recover when a
* client misses one. Sending the document costs bandwidth that autosave already
* Not a patch. A book is a few kilobytes of JSON a page, a few megabytes for
* the longest journey's, and a patch protocol would need an ordering
* guarantee, a merge rule per field, and a way to recover when a client
* misses one. Sending the document costs bandwidth that autosave already
* amortises and removes an entire class of bug.
*/
+5 -2
View File
@@ -65,7 +65,6 @@ const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
* `MAX_SPREAD_ELEMENTS` belongs to this set and is declared with the spread it
* bounds, where the salvage pass reads it too.
*/
export const MAX_SPREADS = 150;
export const MAX_BOOK_TITLE = 200;
export const MAX_TEXT_LENGTH = 8000;
/** Codes on a map or a country page, and the names printed beside them. */
@@ -1122,7 +1121,11 @@ const bookDocumentObject = z.object({
schemaRev: z.number().int().min(1).optional().catch(undefined),
title: z.string().max(MAX_BOOK_TITLE).default(''),
page: bookPageSetupSchema.default(() => bookPageSetupSchema.parse({})),
spreads: z.array(bookSpreadSchema).max(MAX_SPREADS).default([]),
/**
* As many as the journey needs: a long trip's book runs to hundreds of pages,
* and the save route's body limit is the only bound on its size.
*/
spreads: z.array(bookSpreadSchema).default([]),
/** The book's own layouts (#2316). Optional so a document from before still reads. */
layouts: z.array(bookLayoutSchema).max(MAX_BOOK_LAYOUTS).optional(),
/** Palette, typefaces and motifs; elements link to it through the fields of theme/book-theme.schema.ts. */
+5 -6
View File
@@ -5,7 +5,6 @@ import type { BookPhotoInfo } from '../book-ops-core';
import { blankBook } from '../book-pages';
import { isStale } from '../book-travel';
import {
MAX_SPREADS,
MAX_SPREAD_ELEMENTS,
bookDocumentSchema,
bookPhotoElementSchema,
@@ -367,10 +366,10 @@ describe('design_book', () => {
expect(spread.notes.some((n) => n.includes('hero-script-word'))).toBe(true);
});
it('BOOKDSN-014: stays within the spread cap beside many kept pages, the closing pages kept', () => {
it('BOOKDSN-014: designs every page beside hundreds of kept pages, the closing pages kept', () => {
const stored = storedBook();
const lockedPage = stored.spreads[2] as BookSpread;
const many = Array.from({ length: MAX_SPREADS - 6 }, (_, i): BookSpread => ({
const many = Array.from({ length: 300 }, (_, i): BookSpread => ({
...lockedPage,
id: `sp-l${i}`,
elements: lockedPage.elements.map((e) => ({ ...e, id: `t-l${i}` })),
@@ -383,10 +382,10 @@ describe('design_book', () => {
for (const style of ['minimal', 'classic'] as const) {
const result = designBook(full, input('rich'), { style });
checkStructure(result);
expect(result.doc.spreads.length).toBeLessThanOrEqual(MAX_SPREADS);
expect(inner(result.doc).length).toBeLessThanOrEqual(MAX_SPREADS - 4);
expect(inner(result.doc).length).toBeGreaterThan(many.length);
expect(many.every((p) => result.doc.spreads.some((s) => s.id === p.id))).toBe(true);
expect(result.notes.length).toBeGreaterThan(0);
expect(result.doc.spreads.slice(-2).map((s) => s.role)).toEqual(['last', 'back']);
expect(result.notes.some((n) => n.includes('left out'))).toBe(false);
}
});
+8 -28
View File
@@ -9,7 +9,7 @@ import type { BookPhotoInfo } from '../book-ops-core';
import type { BookFinding } from '../book-ops.schema';
import { bookPageSetupForPreset } from '../book-pages';
import type { BookTravelContext } from '../book-travel';
import { MAX_BOOK_TITLE, MAX_SPREADS, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
import { MAX_BOOK_TITLE, 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';
@@ -25,7 +25,7 @@ import {
import { composeBack, composeCover, composeTitlePage, stationsPerSpread } from './design-framing';
import { composeDesignedInner, type FramingNeeds } from './design-inner';
import { isLivelyBeat } from './design-lively-pacing';
import { keepsStored, mergeDesign, type BookDesignPlanRow, type DesignedSpread } from './design-merge';
import { mergeDesign, type BookDesignPlanRow, type DesignedSpread } from './design-merge';
import { applyPageOverrides } from './design-overrides';
import type { BookDesignBeat } from './design-pacing';
import {
@@ -140,23 +140,6 @@ function classicOnly(d: BookDesignDirectives): boolean {
return d.chapters === 'entry' && d.density === 'balanced';
}
/**
* The designed pages within the spread cap: inner pages past `room` are left
* off the end (the framing pages always stay), and the note says how many.
*/
function withinRoom(designed: readonly DesignedSpread[], room: number, notes: string[]): DesignedSpread[] {
const inner = designed.filter((d) => d.spread.role === 'inner');
const over = inner.length - Math.max(0, room);
if (over <= 0) return [...designed];
const cut = new Set(inner.slice(inner.length - over).map((d) => d.spread.id));
notes.push(`the book holds ${MAX_SPREADS} spreads, so the last ${over} designed pages are left out`);
return designed.filter((d) => !cut.has(d.spread.id));
}
/** Inner spreads the book has room for once the stored pages it keeps are counted. */
const roomFor = (doc: BookDocument | null, keep: BookDesignDirectives['keep']) =>
MAX_SPREADS - 4 - (doc?.spreads ?? []).filter((s) => s.role === 'inner' && keepsStored(s, keep)).length;
/** The travel context the travel elements read: the journey's figures, route, entries and labels. */
function travelOf(input: BookDesignInput, entries: readonly AutoEntry[]): BookTravelContext {
return {
@@ -205,14 +188,13 @@ function classicDesign(
chapter: entries.find((e) => e.id === spread.entryId)?.title ?? null,
findings: pageFindings(spread, page, input.photos),
}));
const designed = withinRoom(all, roomFor(doc, directives.keep), notes);
const merged = mergeDesign(doc, designed, directives.keep);
const merged = mergeDesign(doc, all, directives.keep);
const finalDoc: BookDocument = {
...built,
spreads: merged.spreads,
...(doc?.layouts ? { layouts: doc.layouts } : {}),
};
const residual = designed.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
const residual = all.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
return {
doc: finalDoc,
plan: merged.plan,
@@ -274,7 +256,7 @@ export function designBook(
notes: [],
art,
};
const needs: FramingNeeds = { room: roomFor(doc, directives.keep), perStations: stationsPerSpread(c) };
const needs: FramingNeeds = { perStations: stationsPerSpread(c) };
const inner = composeDesignedInner(c, placed, choice, input, needs);
const words = {
@@ -547,8 +529,7 @@ function templatedDesign(
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 inner = composeTemplatedInner(c, placed, choice, input, { 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) }));
@@ -569,8 +550,7 @@ interface TemplatedParts {
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 laid = mergeDesign(doc, parts.done, directives.keep);
const built: BookDocument = {
version: 1,
title: (directives.cover?.title ?? input.title).slice(0, MAX_BOOK_TITLE),
@@ -586,7 +566,7 @@ function templatedResult(doc: BookDocument | null, input: BookDesignInput, parts
: { 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'));
: parts.done.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)))];
@@ -1,7 +1,6 @@
import { BOOK_COVER_ARCHETYPES, type BookCoverArchetype } from '../book-archetypes';
import { bookPageOpSchema } from '../book-ops.schema';
import { bookLockupIdSchema } from '../book-text-ops.schema';
import { MAX_SPREADS } from '../book.schema';
import { BOOK_THEME_DECORATIONS, BOOK_THEME_IDS } from '../theme/book-theme.schema';
import { z } from 'zod';
@@ -97,12 +96,7 @@ const overrideTemplateSchema = z
export const bookDesignPageOverrideSchema = z
.strictObject({
index: z
.number()
.int()
.min(2)
.max(MAX_SPREADS - 1)
.describe('The page by its index in the plan design_book answered; the cover is 0.'),
index: z.number().int().min(2).describe('The page by its index in the plan design_book answered; the cover is 0.'),
beat: z
.enum(BOOK_DESIGN_OVERRIDE_BEATS)
.describe(
+3 -6
View File
@@ -41,8 +41,6 @@ import { decorateSpread } from './design-stickers';
*/
export interface FramingNeeds {
/** Inner spreads the book has room for, the kept stored pages taken off. */
room: number;
/** Bare stops one stations spread holds. */
perStations: number;
}
@@ -70,9 +68,8 @@ function framingInner(c: ComposeCtx): FramingKind[] {
return out;
}
/** The most inner spreads the design may make: `pages.max`, and never past the room the book has. */
const innerCap = (c: ComposeCtx, needs: FramingNeeds) =>
Math.max(0, Math.min(c.directives.pages?.max ?? needs.room, needs.room));
/** The most inner spreads the design may make: `pages.max` when the directives set one, otherwise as many as it needs. */
const innerCap = (c: ComposeCtx) => Math.max(0, c.directives.pages?.max ?? Number.POSITIVE_INFINITY);
/**
* The framing pages that fit beside at least one chapter spread: past the cap
@@ -316,7 +313,7 @@ export function composeDesignedInner(
): InnerResult {
const notes: string[] = [];
const leftOut: BookDesignLeftOut[] = [];
const cap = innerCap(c, needs);
const cap = innerCap(c);
const wanted = framingInner(c);
const framing = fittingFraming(wanted, cap);
if (framing.length < wanted.length) {
+1 -1
View File
@@ -46,7 +46,7 @@ const hasLock = (spread: BookSpread) => spread.elements.some((e) => e.locked);
* inner page only while the person keeps locked pages); with `edited` also a
* page made by hand (no entry behind it, not blank).
*/
export function keepsStored(spread: BookSpread, keep: BookDesignDirectives['keep']): boolean {
function keepsStored(spread: BookSpread, keep: BookDesignDirectives['keep']): boolean {
if (spread.role !== 'inner') return hasLock(spread);
if (keep === 'nothing') return false;
if (hasLock(spread)) return true;
@@ -1,228 +1,228 @@
{
"A/fabric-classic/head": "74bb0439e4575ed7",
"A/fabric-classic/plan": "95b55b052b9ff07b",
"A/fabric-classic/spread/0": "a4d090166287cbe5",
"A/fabric-classic/spread/0": "36ce28aa969ee78e",
"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/10": "f6f5a5bfbb97c674",
"A/fabric-classic/spread/11": "c1e942bd73455a86",
"A/fabric-classic/spread/12": "c390827640a1bc65",
"A/fabric-classic/spread/13": "f49a2ec83099a556",
"A/fabric-classic/spread/14": "f13bb412f61a778c",
"A/fabric-classic/spread/15": "7ec17a9b9e9955f8",
"A/fabric-classic/spread/16": "029964006e41bbdb",
"A/fabric-classic/spread/17": "78ae60620ae287a3",
"A/fabric-classic/spread/18": "c73e813888a28cd1",
"A/fabric-classic/spread/19": "d98cc9a891bf2141",
"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/20": "e1dfa3eeb056189b",
"A/fabric-classic/spread/21": "b18be5f5e1696caf",
"A/fabric-classic/spread/22": "98a8cab370aeb8fa",
"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/fabric-classic/spread/4": "d0eba1475d8d9f04",
"A/fabric-classic/spread/5": "468dc734d5aae63f",
"A/fabric-classic/spread/6": "776a926951bfdbe5",
"A/fabric-classic/spread/7": "16f3ec7630e52f5d",
"A/fabric-classic/spread/8": "f64487735a6b5be1",
"A/fabric-classic/spread/9": "70dba74ad0223875",
"A/midnight/head": "ce0d82281cbc3e1f",
"A/midnight/plan": "7fde812131c9b570",
"A/midnight/spread/0": "409beb5fbb1accce",
"A/midnight/spread/0": "1ea6071685899f8f",
"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/10": "b42ae04363ff591c",
"A/midnight/spread/11": "a7271ec809fb43f3",
"A/midnight/spread/12": "e2a46e3248f9f7f2",
"A/midnight/spread/13": "674814c68bdfc64e",
"A/midnight/spread/14": "9131f08ae5ee964d",
"A/midnight/spread/15": "516667160a8ea844",
"A/midnight/spread/16": "7f572ace66e550eb",
"A/midnight/spread/17": "f75b6c80be848ac6",
"A/midnight/spread/18": "029d7d07ce5cb3c3",
"A/midnight/spread/19": "14c04aa51da497cd",
"A/midnight/spread/2": "b00839cd0dc1451b",
"A/midnight/spread/20": "dff6e523732c8481",
"A/midnight/spread/21": "26d666dde7be6641",
"A/midnight/spread/20": "ea8aa8da12cad00a",
"A/midnight/spread/21": "68057d81fd4903b4",
"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/midnight/spread/4": "6d89cdaeac29b8b6",
"A/midnight/spread/5": "25f6a66be27c2415",
"A/midnight/spread/6": "14c828e117eb72a4",
"A/midnight/spread/7": "8fac481cb6c1d93d",
"A/midnight/spread/8": "8730ae9ad717bbe5",
"A/midnight/spread/9": "9792ae63b36eff3e",
"A/riso/head": "7d43d0900fbe0f57",
"A/riso/plan": "f802ad819f1fb262",
"A/riso/spread/0": "1f4502d29eca9017",
"A/riso/spread/0": "50a4cc5fafbfad6e",
"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/10": "d468165710581556",
"A/riso/spread/11": "1997a6c3ae1c8570",
"A/riso/spread/12": "655767248f707020",
"A/riso/spread/13": "15e7ddcf0d397509",
"A/riso/spread/14": "88cf12b3fa6b6be9",
"A/riso/spread/15": "d2bb88a721cc8320",
"A/riso/spread/16": "d58f70c381d9f6ae",
"A/riso/spread/17": "00caa407cb2a0677",
"A/riso/spread/18": "ed41aca064456614",
"A/riso/spread/19": "36647f06c594e715",
"A/riso/spread/18": "def2a9dde53f3e26",
"A/riso/spread/19": "3cea577870cdc23d",
"A/riso/spread/2": "5c9a93de9e6467d6",
"A/riso/spread/20": "8518ca61c110fd8a",
"A/riso/spread/21": "d4ba78b2ffd5ae04",
"A/riso/spread/22": "de5116307e12ce61",
"A/riso/spread/20": "c851d704ccca068e",
"A/riso/spread/21": "be8c5194461daa01",
"A/riso/spread/22": "dcd4a1dfde54cef9",
"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",
"A/riso/spread/4": "1b46e4d88f0a0db5",
"A/riso/spread/5": "dd07d5451021f0f5",
"A/riso/spread/6": "ae0f0bbad2f029e3",
"A/riso/spread/7": "5029fc77bc6a7bca",
"A/riso/spread/8": "6eecfd5fd8319fe6",
"A/riso/spread/9": "3ba63e382035e668",
"B/fabric-classic/head": "6af0d11dd09eae78",
"B/fabric-classic/plan": "cae85517611abbca",
"B/fabric-classic/spread/0": "87325aa216fe9c6a",
"B/fabric-classic/spread/0": "9da4b641b0f81264",
"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/3": "bdb01dc38f94e0d0",
"B/fabric-classic/spread/4": "4e949ebeb4098598",
"B/fabric-classic/spread/5": "fa7289a87111f6a2",
"B/fabric-classic/spread/6": "e1367526818066fb",
"B/fabric-classic/spread/7": "a5c72a73d9305e41",
"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/0": "ee90988cd45ab2c4",
"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/3": "b9fb3f915cffb57d",
"B/midnight/spread/4": "9f0467b6921de638",
"B/midnight/spread/5": "7e3d7de6ef19180c",
"B/midnight/spread/6": "d42de16c6d174c6b",
"B/midnight/spread/7": "dba5f8de6c557d9e",
"B/midnight/spread/8": "835870aa7e88f095",
"B/midnight/spread/9": "eba329fd185958b3",
"B/riso/head": "69a6c3112de2a047",
"B/riso/plan": "d8906a53b3532db8",
"B/riso/spread/0": "2bbd6e6b4e6d1b4d",
"B/riso/spread/0": "867edf084f05a873",
"B/riso/spread/1": "8de92788de29277b",
"B/riso/spread/2": "40f8d2b093d0fc8d",
"B/riso/spread/3": "1fc8aedc7486cbeb",
"B/riso/spread/4": "311c9fcde888d578",
"B/riso/spread/4": "cedfcf22d3331d4a",
"B/riso/spread/5": "51562d8f08f46011",
"B/riso/spread/6": "c114a720b5c5beff",
"B/riso/spread/6": "68783b660e0ffa6a",
"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/0": "2ab15684c1a8df5a",
"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/2": "d0f1544330a8501f",
"C/fabric-classic/spread/3": "3600be0a98413583",
"C/fabric-classic/spread/4": "d551cdf96c385341",
"C/fabric-classic/spread/5": "177741ca8b5c1758",
"C/fabric-classic/spread/6": "2c42a8276092879e",
"C/fabric-classic/spread/7": "fd5861b762afd36b",
"C/fabric-classic/spread/8": "a7f0ed591793b5e1",
"C/fabric-classic/spread/9": "31665bce59abeafe",
"C/fabric-classic/spread/9": "2a5631a4b55fd15a",
"C/midnight/head": "f1e6077175cb2e20",
"C/midnight/plan": "14fb454d02caf6f9",
"C/midnight/spread/0": "79eae9202a85d430",
"C/midnight/spread/0": "10706dcfb990de4c",
"C/midnight/spread/1": "d9f5d78659332b45",
"C/midnight/spread/10": "6694684925255e94",
"C/midnight/spread/11": "247394453c45c63c",
"C/midnight/spread/12": "abe0197797669d57",
"C/midnight/spread/10": "29566f40839aa633",
"C/midnight/spread/11": "063bb3a68427c348",
"C/midnight/spread/12": "68223f7894a7f842",
"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/midnight/spread/2": "894a97de13c021ed",
"C/midnight/spread/3": "97fb835ff2105cdc",
"C/midnight/spread/4": "87961fc9d8aa3915",
"C/midnight/spread/5": "4c4a134559a3516a",
"C/midnight/spread/6": "3358ec65f7d7240a",
"C/midnight/spread/7": "b7abfc765f918ddb",
"C/midnight/spread/8": "52a02b23cbe1e01a",
"C/midnight/spread/9": "9ccf347142b626ca",
"C/riso/head": "d19997b07778d1dc",
"C/riso/plan": "175dadeb8bb730a6",
"C/riso/spread/0": "8453dd7b94afe68d",
"C/riso/spread/0": "11bb39722782ce9d",
"C/riso/spread/1": "8de92788de29277b",
"C/riso/spread/10": "6b5aa1dac73ab777",
"C/riso/spread/11": "c20262603c86d43e",
"C/riso/spread/12": "8e263c445c0418d2",
"C/riso/spread/10": "df3b209ca6a45afb",
"C/riso/spread/11": "48d8e38b39721c2b",
"C/riso/spread/12": "2326a58882d7c326",
"C/riso/spread/13": "a748ada7a028818e",
"C/riso/spread/14": "81e4b4a0dcc6961a",
"C/riso/spread/2": "5e43386b045d926c",
"C/riso/spread/3": "9587f2fe2fc9cd04",
"C/riso/spread/2": "b25ef24c052d72a3",
"C/riso/spread/3": "1edc86f0be5c9422",
"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",
"C/riso/spread/5": "1c86ab49df7921b5",
"C/riso/spread/6": "66fe84b4051bbb45",
"C/riso/spread/7": "05ed926557fdc48d",
"C/riso/spread/8": "1599830b16195a51",
"C/riso/spread/9": "dafb4c953a1c6693",
"D/fabric-classic/head": "54fa8987fea87ce4",
"D/fabric-classic/plan": "a2d0423833838dcc",
"D/fabric-classic/spread/0": "1136daea45c98c7d",
"D/fabric-classic/spread/0": "58ae71edfa473b8c",
"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/10": "f53e366c56f5026d",
"D/fabric-classic/spread/11": "36816b8e9c3e9745",
"D/fabric-classic/spread/12": "1f512d9c851fd489",
"D/fabric-classic/spread/13": "8e885c24ae9c4796",
"D/fabric-classic/spread/14": "8ed5b5e96e4f5696",
"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/fabric-classic/spread/4": "9fcb7ff2c09e7e05",
"D/fabric-classic/spread/5": "512f64afa749875d",
"D/fabric-classic/spread/6": "d909172ca9306881",
"D/fabric-classic/spread/7": "b267abc83d4faf93",
"D/fabric-classic/spread/8": "29c43d7551fdcedb",
"D/fabric-classic/spread/9": "932c06500ebaf54c",
"D/midnight/head": "da510cf5f95bad40",
"D/midnight/plan": "3bd0d52ed4e476c7",
"D/midnight/spread/0": "440b7d69daa18a7a",
"D/midnight/spread/0": "cc12862834fdbf96",
"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/10": "d25808fa155f157e",
"D/midnight/spread/11": "b984e665960f60b6",
"D/midnight/spread/12": "a0985fcab9c614a2",
"D/midnight/spread/13": "3f2670ca15c1797c",
"D/midnight/spread/14": "b49cc00919f7a621",
"D/midnight/spread/15": "67a512730030f450",
"D/midnight/spread/16": "90864ccfce090575",
"D/midnight/spread/17": "69924ede00c784f9",
"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/midnight/spread/4": "f332e826ccb518b8",
"D/midnight/spread/5": "545524d900a6d68e",
"D/midnight/spread/6": "ea5db0eab1087429",
"D/midnight/spread/7": "da860dcfdcffc997",
"D/midnight/spread/8": "d4a871126c6b324e",
"D/midnight/spread/9": "533ceb3478922532",
"D/riso/head": "1c8f99d99acdea28",
"D/riso/plan": "f2a8704019582093",
"D/riso/spread/0": "7b0d816ffda1dd01",
"D/riso/spread/0": "7cded5c557f2e1ee",
"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/10": "1d403f4e5dc74e1e",
"D/riso/spread/11": "7b563aff540a0a2c",
"D/riso/spread/12": "c6cf876fbc756d21",
"D/riso/spread/13": "735d4882d5230331",
"D/riso/spread/14": "8df18b93ab68235d",
"D/riso/spread/15": "4be16c00138d027d",
"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/4": "42d13c4cc22154a9",
"D/riso/spread/5": "c2dcc1ba31041587",
"D/riso/spread/6": "93846db9c48faa0f",
"D/riso/spread/7": "42f55e08792400e9",
"D/riso/spread/8": "4ff93cb4d24f729c",
"D/riso/spread/9": "988a20c009c9d562"
}
@@ -1,12 +1,6 @@
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 { 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';
@@ -101,13 +95,12 @@ function single(photos: number, story: boolean): BookDesignInput {
}
describe('the book on the designed templates', () => {
it('AUTODSN-001: is a valid book, as normalised, within the spread and element caps', () => {
it('AUTODSN-001: is a valid book, as normalised, within the element cap', () => {
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);
}
}
+1 -3
View File
@@ -9,7 +9,6 @@ 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';
@@ -438,12 +437,11 @@ export function composeTemplatedInner(
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 cap = Math.max(0, c.directives.pages?.max ?? Number.POSITIVE_INFINITY);
const head = framingPages(c, plan.archetype).slice(0, Math.max(0, cap - 1));
const base: PickBase = {
archetype: plan.archetype,