Compare commits

...

3 Commits

Author SHA1 Message Date
Maurice a70ff750a6 test(studio): the typeface and display checks assert what they are about
BOOKQA-009 looks at font_mix alone (three one-text pages also repeat their
layout), and BOOKSTY-004 measures the display tier against the title the
classic style already sets (34 pt on this fixture, as on dev) instead of a
guessed size.
2026-10-23 14:20:24 +02:00
Maurice 815a523d1c feat(assistant): Studio's assistant may design lively books, not only restrained ones
- The design prompt keeps the print rules and adds energy: calm keeps today's
  rules, lively and loud layer photos, tilt, set oversize display type and
  block colour. Type sizes come as numbers relative to the page, and the model
  answers eight silent questions and previews the cover twice before it
  proposes.
- The type scale reaches 2.5 and a display tier sets a cover title up to
  200 pt; existing books keep their sizes.
- An overlap is severe only where more than half of a photo or a text is
  covered by accident; a title over a photo, a framed inset or a turned photo
  is layering.
- The assistant may use every icon of the icon tab, checked by name, and every
  enum of the book tools says what its values mean.
2026-10-23 14:15:01 +02:00
Maurice 30382dc366 feat(studio): a book saved by a newer Studio opens read-only instead of losing what it cannot read
- A book carries schemaRev. The reader that meets a newer one opens the book
  read-only with a reload banner and saves nothing, where it used to drop the
  elements it could not read on the next save.
- The server refuses a save, a restore or a tool edit that would write an older
  revision over a newer book or one newer than itself (409 book_schema_newer),
  on REST and through every book tool alike.
- normalizeBookDocument moves to book-normalize.ts.
- Print waits for photos eagerly and up to 15 s for an export of more than 40
  photos, and skips images without a source.
2026-10-23 14:15:01 +02:00
79 changed files with 3922 additions and 259 deletions
+26 -1
View File
@@ -15,13 +15,15 @@ import type { SaveState } from './useBookStore'
* which is exactly the wrong habit for the one time it says something else.
*/
export function SaveIndicator({
state, t, onAcceptTheirs, onKeepMine, onRetry,
state, t, onAcceptTheirs, onKeepMine, onRetry, onReload = reloadStudio,
}: {
state: SaveState
t: (k: string) => string
onAcceptTheirs: (current: BookRecord) => void
onKeepMine: (current: BookRecord) => void
onRetry: () => void
/** Load this Studio again, for a book a newer one saved. A full reload by default. */
onReload?: () => void
}) {
if (state.status === 'conflict') {
return (
@@ -43,6 +45,24 @@ export function SaveIndicator({
)
}
if (state.status === 'newer') {
/*
* The one state with a way out the user has to take themselves: a newer
* Studio saved this book, this tab cannot write it without losing what
* that one added, and only a reload brings the newer Studio here. Loud
* like a conflict, because until then nothing done here is kept.
*/
return (
<div className="st-save is-conflict" role="alert">
<AlertTriangle size={13} />
<span>{t('journey.studio.saveNewer')}</span>
<button type="button" onClick={onReload}>
{t('journey.studio.saveReload')}
</button>
</div>
)
}
if (state.status === 'readonly') {
/*
* Not an error and not retryable: this person was invited to read the
@@ -86,3 +106,8 @@ export function SaveIndicator({
return null
}
/** The default way back to a current Studio: the page again, which loads the newest build. */
function reloadStudio() {
window.location.reload()
}
+4 -1
View File
@@ -260,7 +260,10 @@ function PhotoView({ el, big, print, dropLabel }: {
src={photoSrc(el.photoId!, big)}
alt=""
draggable={false}
loading="lazy"
// Lazy on the canvas, where a long book scrolls past; eager in print,
// where every sheet is laid out at once and a picture the browser put
// off is a picture missing from the PDF.
loading={print ? 'eager' : 'lazy'}
style={{
width: '100%',
height: '100%',
@@ -0,0 +1,29 @@
/**
* FE-STUDIO-ICON-001 to FE-STUDIO-ICON-002
*
* The icons the book engine takes (BOOK_ICON_NAMES in @trek/shared) are the
* drawings the Studio's icon tab offers, no more and no fewer: one the engine
* took that this lucide does not have would print as the fallback glyph, and
* one the tab offers that the engine refused would be a drawing a person can
* place and the assistant cannot. When lucide-react moves, run
* `node scripts/gen-book-icon-names.mjs` in shared/ and this passes again.
*/
import { BOOK_DESIGN_ICONS, BOOK_ICON_NAMES } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { FEATURED_ICONS, ICON_NAMES } from './iconLibrary';
describe('the icons the book engine takes', () => {
it('FE-STUDIO-ICON-001: are every drawing the icon tab offers, and nothing it does not', () => {
// `Icon` is the component every drawing is built on, not a drawing.
const offered = ICON_NAMES.filter((name) => name !== 'Icon');
const missing = offered.filter((name) => !BOOK_ICON_NAMES.has(name));
const extra = [...BOOK_ICON_NAMES].filter((name) => !offered.includes(name));
expect({ missing, extra }).toEqual({ missing: [], extra: [] });
});
it('FE-STUDIO-ICON-002: hold the whole travel shelf the tab opens on', () => {
expect(BOOK_DESIGN_ICONS.filter((name) => !BOOK_ICON_NAMES.has(name))).toEqual([]);
expect(FEATURED_ICONS).toEqual([...BOOK_DESIGN_ICONS]);
});
});
+4 -3
View File
@@ -60,9 +60,10 @@ export const ICON_NAMES: string[] = buildNames()
* of them quietly drops it instead of drawing a fallback in the shelf.
*/
/*
* The shelf itself lives in @trek/shared (book-icons.ts), because the book
* engine places icons from the same list: the assistant may only name a
* drawing a person could pick here.
* The shelf itself lives in @trek/shared (book-icons.ts), beside the whole
* list the book engine takes (BOOK_ICON_NAMES): the assistant may name any
* drawing a person could pick here, and iconLibrary.test.ts holds that list
* to ICON_NAMES.
*/
const FEATURED: readonly string[] = BOOK_DESIGN_ICONS
@@ -0,0 +1,75 @@
/**
* FE-STUDIO-PRINT-001 to FE-STUDIO-PRINT-004
*
* When the print view of a book may be printed: every picture in, the SVG
* ones too wherever they sit (a map marker, a photo behind a clip path), and
* a ceiling that gives a book of many photographs longer before the Save
* button gives up waiting.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { printReadyCeilingMs, svgImagesToWait, whenReady } from './printReady';
const SVG = 'http://www.w3.org/2000/svg';
const XLINK = 'http://www.w3.org/1999/xlink';
/** A print document as the frame holds it, built from markup. */
function printDoc(markup: string): Document {
const doc = document.implementation.createHTMLDocument('print');
doc.body.innerHTML = markup;
return doc;
}
/** An iframe whose document and window are the given ones, as the print view's frame is once it loaded. */
function frameOf(doc: Document, win: Partial<Window> = {}): HTMLIFrameElement {
const iframe = document.createElement('iframe');
Object.defineProperty(iframe, 'contentDocument', { value: doc, configurable: true });
Object.defineProperty(iframe, 'contentWindow', { value: win, configurable: true });
return iframe;
}
afterEach(() => {
vi.useRealTimers();
});
describe('the pictures to wait for', () => {
it('FE-STUDIO-PRINT-001: finds an SVG image inside a clip path and in the defs, not only a marker photo', () => {
const doc = printDoc(
`<svg xmlns="${SVG}"><defs><clipPath id="c"><image href="/a.jpg"/></clipPath>` +
`<image id="d" href="/b.jpg"/></defs><image href="/c.jpg"/></svg>`
);
expect(svgImagesToWait(doc).map((el) => el.getAttribute('href'))).toEqual(['/a.jpg', '/b.jpg', '/c.jpg']);
});
it('FE-STUDIO-PRINT-002: takes an xlink source, and leaves out an image that names none and would never load', () => {
const doc = printDoc(`<svg xmlns="${SVG}"><image/></svg>`);
const linked = doc.createElementNS(SVG, 'image');
linked.setAttributeNS(XLINK, 'xlink:href', '/x.jpg');
doc.querySelector('svg')?.appendChild(linked);
expect(svgImagesToWait(doc)).toEqual([linked]);
});
});
describe('the ceiling', () => {
it('FE-STUDIO-PRINT-003: waits eight seconds for an ordinary book and fifteen for one of more than 40 photos', () => {
expect(printReadyCeilingMs(0)).toBe(8000);
expect(printReadyCeilingMs(40)).toBe(8000);
expect(printReadyCeilingMs(41)).toBe(15000);
});
it('FE-STUDIO-PRINT-004: holds a large book past eight seconds while an image is still loading', async () => {
vi.useFakeTimers();
const many = Array.from({ length: 41 }, (_, i) => `<image href="/p${i}.jpg"/>`).join('');
const doc = printDoc(`<svg xmlns="${SVG}">${many}</svg>`);
let ready = false;
// A window without its own timers: the wait falls back to the global one, which the fake timers drive.
void whenReady(frameOf(doc)).then(() => {
ready = true;
});
await vi.advanceTimersByTimeAsync(8100);
expect(ready).toBe(false);
await vi.advanceTimersByTimeAsync(7000);
expect(ready).toBe(true);
});
});
@@ -0,0 +1,77 @@
/**
* When the print view of a book is actually printable.
*
* Both halves matter and neither is covered by the load event: a photograph
* still decoding prints as an empty box, and a face still loading prints in the
* fallback, the exact substitution the bundled typefaces exist to prevent.
*/
/** How long the print view waits at most, and the photo count past which a book gets the longer wait. */
const PRINT_READY = {
ceilingMs: 8000,
largeCeilingMs: 15000,
largeBookPhotos: 40,
} as const;
/**
* The longest the Save button stays off for a book of `photos` pictures. A
* book of a few dozen photographs from a slow provider can take longer than
* eight seconds to decode; one that has not finished must not go to the press.
*/
export function printReadyCeilingMs(photos: number): number {
return photos > PRINT_READY.largeBookPhotos ? PRINT_READY.largeCeilingMs : PRINT_READY.ceilingMs;
}
/** What `whenReady` reads off the print document: its `<img>` elements, its SVG and its fonts. */
interface PrintDocument {
images: ArrayLike<Element & { complete?: boolean }>;
querySelectorAll?: (selector: string) => ArrayLike<Element>;
fonts?: { ready: Promise<unknown> };
}
const XLINK = 'http://www.w3.org/1999/xlink';
/**
* The SVG pictures to wait for: every `<image>` that names a source, wherever
* it sits. `doc.images` does not collect them, and they are not only the
* photographs in a map's markers: a photo cut to a mask or set behind clipped
* type is an `<image>` inside a `<clipPath>` or `<defs>`, which the selector
* reaches just the same. One that names no source would never fire an event,
* so it is left out rather than left to hold the button until the ceiling.
*/
export function svgImagesToWait(doc: PrintDocument): Element[] {
const found = Array.from(doc.querySelectorAll?.('image') ?? []);
return found.filter((el) => !!(el.getAttribute('href') || el.getAttributeNS(XLINK, 'href')));
}
/** Resolves when the element has loaded or failed: a picture that will not load must not hold the button hostage. */
function settle(el: Element): Promise<void> {
return new Promise<void>((resolve) => {
el.addEventListener('load', () => resolve(), { once: true });
el.addEventListener('error', () => resolve(), { once: true });
});
}
/**
* Wait until the print view's pictures and faces are in, or until the
* ceiling, which rises for a book with many photographs. Without `complete`
* on an SVG image there is no way to tell one that is still coming from one
* whose events fired before this ran, and a Save button that never enables is
* worse than a marker that prints blank.
*/
export async function whenReady(iframe: HTMLIFrameElement): Promise<void> {
const win = iframe.contentWindow;
const doc = iframe.contentDocument as PrintDocument | null;
if (!win || !doc) return;
const imgs = Array.from(doc.images);
const svg = svgImagesToWait(doc);
const images = imgs.map((img) => (img.complete ? Promise.resolve() : settle(img)));
const wait = win.setTimeout?.bind(win) ?? setTimeout;
const ceiling = new Promise<void>((resolve) => {
wait(resolve, printReadyCeilingMs(imgs.length + svg.length));
});
await Promise.race([Promise.all([...images, ...svg.map(settle), doc.fonts?.ready ?? Promise.resolve()]), ceiling]);
}
+2 -44
View File
@@ -23,6 +23,8 @@
* standalone PWA. An overlay with a `srcdoc` iframe works everywhere.
*/
import { whenReady } from './printReady'
/*
* The only literal colours in Studio, and deliberately so.
*
@@ -254,47 +256,3 @@ ${singleRule(input)}
return close
}
/**
* When the document is actually printable.
*
* Both halves matter and neither is covered by the load event: a photograph
* still decoding prints as an empty box, and a face still loading prints in the
* fallback — the exact substitution the bundled typefaces exist to prevent.
*/
async function whenReady(iframe: HTMLIFrameElement): Promise<void> {
const win = iframe.contentWindow
const doc = iframe.contentDocument
if (!win || !doc) return
const settle = (el: Element) => new Promise<void>(resolve => {
// A picture that will not load must not hold the button hostage — better a
// book with one gap than a dialog that never finishes.
el.addEventListener('load', () => resolve(), { once: true })
el.addEventListener('error', () => resolve(), { once: true })
})
const images = Array.from(doc.images).map(img => (img.complete ? Promise.resolve() : settle(img)))
/*
* The photographs inside the map's markers are SVG `<image>`, which
* `doc.images` does not collect and which has no `complete` to ask, so there
* is nothing to do but wait on the events. Missing them meant a book could go
* to print with its route beads still blank.
*/
const svgImages = Array.from(doc.querySelectorAll?.('image') ?? []).map(settle)
/*
* And a ceiling over the lot. Without `complete` to fall back on there is no
* way to tell a picture that is still coming from one whose events already
* fired before this ran, and a Save button that never enables is worse than a
* marker that prints blank.
*/
const wait = win.setTimeout?.bind(win) ?? setTimeout
const ceiling = new Promise<void>(resolve => { wait(resolve, 8000) })
await Promise.race([
Promise.all([...images, ...svgImages, doc.fonts?.ready ?? Promise.resolve()]),
ceiling,
])
}
+36 -10
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { BookDocument, BookRecord } from '@trek/shared'
import { normalizeBookDocument } from '@trek/shared'
import { BOOK_SCHEMA_NEWER_CODE, readBookDocument, stampBookSchemaRev } from '@trek/shared'
import { journeyApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
@@ -54,6 +54,16 @@ import { addListener, removeListener } from '../../api/websocket'
* either: it is remembered and read once that work has reached the server,
* because the save response and the broadcast travel on different channels and
* either can arrive first.
*
* ── Why a book from a newer Studio is never written ───────────────────────
*
* A document says which revision of the format wrote it (book-schema-rev.ts).
* This build reads a newer one as far as it can, and what it cannot read is
* already gone from the copy it holds: saving that copy would delete the rest
* for everyone. So such a book is shown and never written, the same way a
* viewer's is, until a reload brings the Studio that wrote it. The server
* refuses the save as well, which covers a tab that loaded the book before the
* newer Studio saved it.
*/
/** How long the document has to stay still before it is written. */
@@ -69,6 +79,8 @@ export type SaveState =
| { status: 'conflict'; current: BookRecord }
/** The journey is open to this user, but they may not write it (role 'viewer'). */
| { status: 'readonly' }
/** The book was saved by a newer Studio than this one: shown, never written, until a reload. */
| { status: 'newer' }
| { status: 'error' }
/** What the editor needs to know about a version it is handed from elsewhere. */
@@ -176,7 +188,12 @@ export function useBookStore(
* agreed document by identity, so a consumer that normalised on its own
* would hand back a different object and turn every open into a save.
*/
const book = res.book ? { ...res.book, document: normalizeBookDocument(res.book.document) } : null
const read = res.book ? readBookDocument(res.book.document) : null
const book = res.book && read ? { ...res.book, document: read.document } : null
if (read?.newer) {
blocked.current = true
setState({ status: 'newer' })
}
setRecord(book)
version.current = book?.version ?? null
bookId.current = book?.id ?? null
@@ -201,7 +218,8 @@ export function useBookStore(
try {
const saved = await journeyApi.saveBook(journeyId, {
title: next.title,
document: next.document,
// Stamped on the way out only: `synced` below keeps the editor's own object.
document: stampBookSchemaRev(next.document),
baseVersion: version.current ?? undefined,
})
setRecord(saved)
@@ -217,8 +235,12 @@ export function useBookStore(
* not need a second request: the editor can offer to take their version
* or keep going from here, and either way it can say whose work it is.
*/
const res = (err as { response?: { status?: number; data?: { current?: BookRecord } } }).response
if (res?.status === 409 && res.data?.current) {
const res = (err as { response?: { status?: number; data?: { current?: BookRecord; code?: string } } }).response
if (res?.status === 409 && res.data?.code === BOOK_SCHEMA_NEWER_CODE) {
// A newer Studio saved the book since this tab read it: nothing here may be written over it.
blocked.current = true
setState({ status: 'newer' })
} else if (res?.status === 409 && res.data?.current) {
blocked.current = true
setState({ status: 'conflict', current: res.data.current })
} else if (res?.status === 403) {
@@ -305,15 +327,15 @@ export function useBookStore(
/** Take the other version, discarding the local one. */
const acceptTheirs = useCallback((current: BookRecord) => {
const document = normalizeBookDocument(current.document)
blocked.current = false
const { document, newer } = readBookDocument(current.document)
blocked.current = newer
setRecord({ ...current, document })
version.current = current.version
bookId.current = current.id
synced.current = document
latest.current = document
pending.current = null
setState({ status: 'idle' })
setState(newer ? { status: 'newer' } : { status: 'idle' })
// A save from elsewhere passed over while the conflict was open may be
// newer than the record it carried.
catchUp.current()
@@ -388,8 +410,12 @@ export function useBookStore(
if (sameBook && !isNewer(res.book.version)) return
// Normalised for the same reason the initial load is: a document
// written by an older build is missing whatever the contract has
// gained since.
const document = normalizeBookDocument(res.book.document)
// gained since. One written by a newer build is shown and held.
const { document, newer } = readBookDocument(res.book.document)
if (newer) {
blocked.current = true
setState({ status: 'newer' })
}
const held = version.current
const taken = res.book.version
// Undoable only when every save it folds in, past the version held,
@@ -68,7 +68,7 @@ function seedJourney() {
beforeEach(() => {
seedJourney()
buildBook.mockClear()
useStudioStore.setState({ doc: null, selection: [], activeSpread: 0, past: [], future: [] })
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)
vi.spyOn(journeyApi, 'stats').mockResolvedValue(stats() as never)
@@ -295,6 +295,31 @@ describe('pictures and stops', () => {
expect(result.current.canEdit).toBe(false)
})
/*
* A book a newer Studio saved has already lost, in this copy, what this build
* cannot read. It is shown and never changed, for every role, and let go once
* Studio closes so the next book opens as it should.
*/
it('FE-STUDIO-REV-001: holds a book a newer Studio saved read-only until Studio closes', async () => {
const document = { ...shared.blankBook({ title: 'Iceland' }), schemaRev: shared.BOOK_SCHEMA_REV + 1 }
vi.mocked(journeyApi.getBook).mockResolvedValue({
book: { id: 3, journeyId: 9, title: 'Iceland', version: 4, updatedAt: '', updatedBy: 1, document },
} as never)
const { result, unmount } = await mountStudio()
await waitFor(() => expect(result.current.saveState.status).toBe('newer'))
expect(result.current.canEdit).toBe(false)
expect(result.current.canUndo).toBe(false)
expect(useStudioStore.getState().readOnly).toBe(true)
const before = useStudioStore.getState().doc
act(() => { useStudioStore.getState().commit(d => ({ ...d, title: 'edited' })) })
expect(useStudioStore.getState().doc).toBe(before)
expect(journeyApi.saveBook).not.toHaveBeenCalled()
unmount()
expect(useStudioStore.getState().readOnly).toBe(false)
})
})
/**
@@ -355,7 +380,7 @@ describe('saves from elsewhere', () => {
const { ok } = await openAndFlush()
expect(ok).toBe(true)
const calls = vi.mocked(journeyApi.saveBook).mock.calls
expect(calls[calls.length - 1][1].document.title).toBe('edited')
expect((calls[calls.length - 1][1].document as { title?: string }).title).toBe('edited')
})
it('FE-STUDIO-WIRE-002: a save of this user keeps the page by its id and is one undo away', async () => {
@@ -78,6 +78,7 @@ export function useJourneyStudio() {
const past = useStudioStore(s => s.past)
const future = useStudioStore(s => s.future)
const commit = useStudioStore(s => s.commit)
const setReadOnly = useStudioStore(s => s.setReadOnly)
const journeyId = Number(id)
const backTo = `/journey/${id}`
@@ -117,6 +118,21 @@ export function useJourneyStudio() {
// every render — depending on that made the save effect fire per render.
const { queueSave, saveNow, loaded: bookLoaded } = book
/*
* A book a newer Studio saved is shown and never changed here.
*
* This build read it as far as it could, and what it could not read is gone
* from its copy, so an edit made now could only ever be saved by deleting
* the rest. The store refuses edits while this holds and the controls that
* add to the book go with `canEdit`; the save indicator asks for a reload.
* Released when Studio closes, so the next book opens as it should.
*/
const newerBook = book.state.status === 'newer'
useEffect(() => {
setReadOnly(newerBook)
return () => setReadOnly(false)
}, [newerBook, setReadOnly])
/*
* Who else is in here, and where their pointers are.
*
@@ -595,9 +611,10 @@ export function useJourneyStudio() {
/*
* A viewer may open the book and change nothing: not the pages, not the
* journey behind them. The server refuses either anyway; this keeps the
* controls that would only ever answer with a 403 off the screen.
* controls that would only ever answer with a 403 off the screen. A book a
* newer Studio saved is held the same way, for everyone (see `newerBook`).
*/
const canEdit = (journey?.my_role ?? 'owner') !== 'viewer'
const canEdit = (journey?.my_role ?? 'owner') !== 'viewer' && !newerBook
const toast = useToast()
/**
@@ -798,8 +815,8 @@ export function useJourneyStudio() {
undo,
redo,
canUndo: past.length > 0,
canRedo: future.length > 0,
canUndo: !newerBook && past.length > 0,
canRedo: !newerBook && future.length > 0,
pxPerMm: PX_PER_MM,
}
@@ -0,0 +1,59 @@
/**
* FE-STUDIO-READONLY-001 to FE-STUDIO-READONLY-002
*
* A book held as it is (one a newer Studio saved): every edit, undo and redo
* is refused while it holds, and a version from elsewhere is still taken, so
* the page on screen stays current.
*/
import { blankBook, type BookDocument } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { useStudioStore } from './studioStore';
const STUDIO = useStudioStore.getState();
function book(title = 'Lisbon'): BookDocument {
let seq = 0;
return blankBook({ title, newId: (prefix) => `${prefix}-${++seq}` });
}
beforeEach(() => {
useStudioStore.setState({ ...STUDIO, doc: null, past: [], future: [], readOnly: false });
});
afterEach(() => {
useStudioStore.getState().setReadOnly(false);
});
describe('a book held as it is', () => {
it('FE-STUDIO-READONLY-001: refuses edits, undo and redo while held, and takes them again once let go', () => {
const store = useStudioStore.getState();
store.load(book());
store.commit((d) => ({ ...d, title: 'one' }));
const edited = useStudioStore.getState().doc;
store.setReadOnly(true);
store.commit((d) => ({ ...d, title: 'two' }));
store.apply((d) => ({ ...d, title: 'three' }));
store.undo();
expect(useStudioStore.getState().doc).toBe(edited);
expect(useStudioStore.getState().canUndo()).toBe(false);
store.setReadOnly(false);
expect(useStudioStore.getState().canUndo()).toBe(true);
store.undo();
expect(useStudioStore.getState().doc?.title).toBe('Lisbon');
expect(useStudioStore.getState().canRedo()).toBe(true);
});
it('FE-STUDIO-READONLY-002: still takes a version saved elsewhere, so the page shown stays current', () => {
const store = useStudioStore.getState();
store.load(book());
store.setReadOnly(true);
const theirs = book('Porto');
store.adoptRemote(theirs, { undoable: false });
expect(useStudioStore.getState().doc).toBe(theirs);
store.load(book('Faro'));
expect(useStudioStore.getState().doc?.title).toBe('Faro');
});
});
+17 -6
View File
@@ -37,6 +37,15 @@ interface StudioState {
* 4.2). Not part of the book or its history; opening a book clears it.
*/
hintSpread: string | null
/**
* Whether the open book is held as it is. True for a book a newer Studio
* saved (useBookStore's `newer`): this build has already dropped what
* it cannot read from its copy, so every edit, undo and redo is refused
* rather than shown as work that can never be saved. Loading and taking a
* remote version still work, so the page stays current while it is shown.
*/
readOnly: boolean
setReadOnly: (readOnly: boolean) => void
load: (doc: BookDocument) => void
/**
@@ -135,7 +144,9 @@ export const useStudioStore = create<StudioState>((set, get) => ({
gestureBase: null,
clipboard: null,
hintSpread: null,
readOnly: false,
setReadOnly: readOnly => set({ readOnly, gestureBase: null }),
load: doc => set({ doc, selection: [], activeSpread: 0, past: [], future: [], gestureBase: null, hintSpread: null }),
adoptRemote: (doc, opts) => set(s => {
if (!s.doc) return { doc, selection: [], activeSpread: 0, past: [], future: [], gestureBase: null }
@@ -180,13 +191,13 @@ export const useStudioStore = create<StudioState>((set, get) => ({
}),
commit: fn => set(s => {
if (!s.doc) return {}
if (!s.doc || s.readOnly) return {}
const next = fn(s.doc)
if (next === s.doc) return {}
return { doc: next, past: [...s.past, s.doc].slice(-HISTORY_LIMIT), future: [] }
}),
apply: fn => set(s => (s.doc ? { doc: fn(s.doc) } : {})),
apply: fn => set(s => (s.doc && !s.readOnly ? { doc: fn(s.doc) } : {})),
updateElement: (spreadIndex, id, patch) => get().apply(doc =>
replaceSpread(doc, spreadIndex, sp => ({
@@ -420,16 +431,16 @@ export const useStudioStore = create<StudioState>((set, get) => ({
undo: () => set(s => {
const prev = s.past[s.past.length - 1]
if (!prev || !s.doc) return {}
if (!prev || !s.doc || s.readOnly) return {}
return { doc: prev, past: s.past.slice(0, -1), future: [s.doc, ...s.future].slice(0, HISTORY_LIMIT), selection: [] }
}),
redo: () => set(s => {
const next = s.future[0]
if (!next || !s.doc) return {}
if (!next || !s.doc || s.readOnly) return {}
return { doc: next, future: s.future.slice(1), past: [...s.past, s.doc].slice(-HISTORY_LIMIT), selection: [] }
}),
canUndo: () => get().past.length > 0,
canRedo: () => get().future.length > 0,
canUndo: () => !get().readOnly && get().past.length > 0,
canRedo: () => !get().readOnly && get().future.length > 0,
}))
@@ -99,3 +99,19 @@ describe('a conflict', () => {
expect(screen.getByRole('alert')).toBeTruthy()
})
})
describe('a book a newer Studio saved', () => {
/*
* Nothing done in this tab is kept until it reloads, so the state is loud
* like a conflict and offers the one way out there is.
*/
it('is announced, says why, and reloads on request', () => {
const onReload = vi.fn()
draw({ status: 'newer' }, { onReload })
expect(screen.getByRole('alert')).toBeTruthy()
expect(screen.getByText('journey.studio.saveNewer')).toBeTruthy()
fireEvent.click(screen.getByText('journey.studio.saveReload'))
expect(onReload).toHaveBeenCalledTimes(1)
})
})
@@ -121,6 +121,17 @@ describe('photo frames', () => {
expect(container.querySelector('img')).not.toBeNull()
})
/*
* The print view lays every sheet out at once; a picture the browser put off
* until it scrolled into view is a picture missing from the PDF.
*/
it('loads a photo lazily on the canvas and at once in print', () => {
const { container: canvas } = draw([photo()])
expect(canvas.querySelector('img')?.getAttribute('loading')).toBe('lazy')
const { container: printing } = draw([photo()], { print: true })
expect(printing.querySelector('img')?.getAttribute('loading')).toBe('eager')
})
it('cuts a masked photo with an objectBoundingBox clip, so it follows a resize', () => {
const { container } = draw([photo({ mask: 'heart' })])
const clip = container.querySelector('clipPath')!
@@ -776,3 +776,88 @@ describe('unmounting', () => {
expect(api.saveBook).not.toHaveBeenCalled()
})
})
// -- A book a newer Studio saved ----------------------------------------------
/*
* This build reads such a book as far as it can, and what it cannot read is
* already gone from its copy. Showing it is fine; writing it would delete the
* rest for everyone, so nothing is written until a reload.
*/
describe('a book a newer Studio saved', () => {
const newer = (title = 'newer') => ({ ...doc(title), schemaRev: 99 }) as unknown as BookDocument
/** A 409 for a book a newer Studio saved since this tab read it. */
function newerError() {
return Object.assign(new Error('Request failed'), {
response: { status: 409, data: { error: 'Book was saved by a newer version of TREK Studio', code: 'book_schema_newer', schemaRev: 2 } },
})
}
it('FE-STUDIO-REV-002: opens it, says so, and writes nothing, not even on flush', async () => {
api.getBook.mockResolvedValue({ book: record({ version: 5, document: newer() }) })
const { result } = await mount()
expect(result.current.state.status).toBe('newer')
expect(result.current.record?.document.title).toBe('newer')
act(() => { result.current.queueSave(doc('edited'), 'T') })
await tick()
let ok: boolean | undefined
await act(async () => { ok = await result.current.flush() })
expect(ok).toBe(false)
expect(api.saveBook).not.toHaveBeenCalled()
})
it('FE-STUDIO-REV-003: stops on the server refusing a save over a newer book, and does not try again', async () => {
api.saveBook.mockRejectedValue(newerError())
const { result } = await mount()
act(() => { result.current.queueSave(doc('a'), 'T') })
await tick()
expect(result.current.state.status).toBe('newer')
act(() => { result.current.queueSave(doc('b'), 'T') })
await tick()
expect(api.saveBook).toHaveBeenCalledTimes(1)
})
it('FE-STUDIO-REV-004: shows a newer version another editor saved, and holds it the same way', async () => {
api.getBook.mockResolvedValue({ book: record({ version: 4, document: doc('ours') }) })
const onRemote = vi.fn()
const { result } = renderHook(() => useBookStore(9, onRemote))
await tick(0)
api.getBook.mockResolvedValue({ book: record({ version: 6, document: newer('theirs') }) })
await remoteSave(6)
expect(onRemote.mock.calls[0][0].title).toBe('theirs')
expect(result.current.state.status).toBe('newer')
act(() => { result.current.queueSave(doc('mine'), 'T') })
await tick()
expect(api.saveBook).not.toHaveBeenCalled()
})
it('FE-STUDIO-REV-005: taking their side of a conflict holds the book when theirs is newer', async () => {
const theirs = record({ version: 6, document: newer('theirs') })
api.saveBook.mockRejectedValueOnce(conflictError(theirs))
const { result } = await mount()
act(() => { result.current.queueSave(doc('mine'), 'T') })
await tick()
expect(result.current.state.status).toBe('conflict')
act(() => { result.current.acceptTheirs(theirs) })
expect(result.current.state.status).toBe('newer')
})
/* A book of today goes out exactly as it went out before the revision existed. */
it('FE-STUDIO-REV-006: sends a book of today without a revision in it', async () => {
const { result } = await mount()
act(() => { result.current.queueSave(doc('a'), 'T') })
await tick()
expect(api.saveBook.mock.calls[0][1].document).not.toHaveProperty('schemaRev')
})
})
@@ -2,17 +2,37 @@
* How the Studio assistant designs a book (journey-studio-wp13.md 7): the
* designer's rules it works by, appended to the Studio's standing
* instructions (studioPrompt). Static, so the prompt cache stays stable.
*
* Restraint is one way to design a book, not the only one. The print rules
* (resolution, fold, trim, bleed, binding, legible words) hold for every
* book; how full, layered and loud a page is follows the energy the person
* asked for. The type scale is given in points on a 210 mm page, as
* design-style.ts sets it, so it scales with the page the same way. The
* questions under "Look, judge, revise" are the critique a designer runs on a
* proof, answered silently before every proposal.
*/
export const BOOK_DESIGN_PROMPT = `How you design:
- Act as a book designer, not a form filler: decide the story first (chapters, pacing), then the pages.
- Map the person's words to design_book's directives: "calm, lots of white" is style minimal and density airy; "like a magazine" is editorial; "bold" is bold and rich; "scrapbook" is scrapbook; a page count is pages.min and pages.max; "only Portugal" is a scope.
- Hierarchy: one dominant element per page; heading, then body, then captions; never two equal heroes on a spread.
- Whitespace is a beat: after a loud spread a quiet one; margins at least the safe area plus 6 mm.
- Rhythm: vary the beats, never three alike in a row; grids for bursts, a quiet page for a long story.
- Alignment: everything on the margin grid and the gutter; align and distribute instead of eyeballing.
- Colour from the photos: one accent, an ink and a paper; at most two typefaces in the whole book.
- Never upscale: under 200 dpi make the frame smaller; keep the focal point visible; no words on a busy part of a photo without a scrim.
- Act as a book designer, not a form filler: decide the story first (chapters, pacing), then the energy of the book, then the pages.
- Map the person's words to design_book's directives: "calm, lots of white" is style minimal and density airy; "like a magazine" is editorial; "bold", "lively" or "loud" is bold and rich; "scrapbook" is scrapbook; a page count is pages.min and pages.max; "only Portugal" is a scope.
- Before you call design_book, state the brief in one line: the energy (calm, lively or loud), the cover photo, the title words and the look.
Always, whatever the energy:
- Never upscale: under 200 dpi make the frame smaller; keep the focal point visible.
- Nothing that matters in the fold, past the trim or outside the safe area; a picture meant to bleed reaches the bleed; mind the binding on a double spread.
- Words on a photo sit on a quiet part of it, a scrim or a colour block; running text at 4.5:1 contrast, display type at 3:1.
- Bound texts keep the journal in sync: prefer bindings over copied words; never invent captions or facts.
- Check every changed page with describe_book_page and the designId, look at the ones that matter most with preview_book_page and the designId when you can see images, and fix their findings with revise_book_design before you propose.
The energy decides the rest:
- Calm: one dominant element per page; heading, then body, then captions; whitespace as a beat after a loud spread; margins at least the safe area plus 6 mm; everything on the margin grid and the gutter; one accent from the photos and two typefaces.
- Lively: still one hero per page, now layered: a photo over a colour block or a shape, the title over a photo, photos turned 2 to 6 degrees in a white or polaroid frame, oversize display type, colour blocking in the accent.
- Loud: lively pushed further: bigger type, stronger colour, more layers, a title that fills the cover; never two heroes on one page.
- A deliberate overlap is a design; an accidental one (two straight photos or texts of a size half over each other) is a fault.
- Vary the beats, never three alike in a row; a quiet page for a long story, grids for bursts.
Type scale, in points for a 210 mm page and scaled with the page width:
- Cover title in a display face, 72 to 180 pt, 12 to 30 % of the page height; title to subtitle at least 3:1. For a big cover raise type.display (up to 6.5, about 180 pt); keep type.scale between 0.9 and 1.1 so body stays 9 to 11 pt.
- Date lines in spaced caps, 7 to 9 pt, tracking 0.25 to 0.4; an accent word over a title in the contrasting face (playfair italic or display), 1.2 to 1.6 times its cap height, with contrast, an outline or a shadow.
- Body 9 to 11 pt; captions 6.5 to 8 pt with tracking 0.05 to 0.1; a display face and a body face, two typefaces at most.
Look, judge, revise:
- Check every changed page with describe_book_page and the designId, and look at the pages that matter most with preview_book_page and the designId when you can see images; preview the cover at least twice, the first draft and the final one.
- Before every proposal answer silently: Is there one dominant element? Does the title read at thumbnail size? Does the energy match the brief? Is the palette the book's own? Is anything touching or overlapping by accident? Is text on a busy area without contrast? Does the decoration suit the energy? Does the page differ from the one before? Fix what fails with revise_book_design.
- Fix every severe finding before you propose; an info finding is context, not a fault.
- One design, one proposal: apply_book_design for a whole book, never dozens of edit_book_page calls; a single page with edit_book_page.
- Keep what the person locked or made by hand unless they ask; say which photos you left out and why.`;
@@ -58,7 +58,7 @@ How you work:
- Read get_book and describe_book_page before you change a page. Page and element ids come from them and from the book card; never invent one. Photo ids come only from {photoSources}.
- To change the book, call the tool for the change: the app shows each call to the user as a change card, and nothing happens until they apply it. Do not describe a change in text and ask whether to make it. Make one edit_book_page call per page in a step, with all of that page's operations.
- Act, don't announce. Make the reads and changes you need in this reply; never end a reply with "I will now ..." and no calls. Never say a change was made unless its result says it was applied.
- Keep every text inside its frame and nothing important in the fold or past the trim. When a result lists severe findings (overlapping photos, words past their frame, empty frames, content in the fold), fix them with the next change.
- Keep every text inside its frame and nothing important in the fold or past the trim. When a result lists severe findings (photos or texts overlapping by accident, words past their frame, empty frames, content in the fold), fix them with the next change; a layered page (a title over a photo, a turned photo over a shape) is a design, not a finding to fix.
- Before you propose an edit_book_page change, look at the page once with preview_book_page and the edit's ops, fix what its findings show, then propose. When a result brings the page as an image, check your work on it too: photos of other contributors are empty frames there on purpose, not missing photos.
- Never delete pages unless the user asks for it. Never place a photo at a size its resolution cannot print.
- Picking photos works on what TREK knows about them: the day, the place, bursts and near duplicates, sharpness, resolution and whether a photo is in the book already. Nobody can tell from that who or what is in a photo or whether it is beautiful; only a page image shows the photos themselves. Say so when asked.
@@ -8,7 +8,14 @@ import { JourneyDomainService } from './journey-domain.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import type { BookDocument, BookRecord, BookSummary } from '@trek/shared';
import { BOOK_DESIGN_LIMITS, normalizeBookDocument } from '@trek/shared';
import {
BOOK_DESIGN_LIMITS,
BOOK_SCHEMA_NEWER_CODE,
BOOK_SCHEMA_REV,
bookSchemaRev,
normalizeBookDocument,
stampBookSchemaRev,
} from '@trek/shared';
/**
* What an edit of the book hands back to `editBook`: the record's title and
@@ -59,6 +66,15 @@ interface BookEditOptions extends BookWriteOrigin {
*
* The alternative — last write wins — is not a simpler version of this. It is
* the same thing with the failure moved somewhere nobody sees it.
*
* ── Format revisions ─────────────────────────────────────────────────────
*
* A book says which revision of the format wrote it (book-schema-rev.ts in
* @trek/shared). A save from a writer older than the stored book is refused,
* because that writer has already dropped whatever it could not read; so is
* one newer than this server, which would drop the same on the way in. The
* book tools write only books this server reads whole, and stamp what they
* write with its revision.
*/
@Injectable()
export class JourneyBookService {
@@ -166,6 +182,7 @@ export class JourneyBookService {
userId: number,
input: { title: string; document: unknown; baseVersion?: number },
): Promise<{ record: BookRecord } | { conflict: BookRecord }> {
refuseNewerThanServer(bookSchemaRev(input.document));
const document = JSON.stringify(normalizeBookDocument(input.document));
// M1 (rule 11/24) — JB3's existing-link read and JB4's insert are
@@ -187,6 +204,10 @@ export class JourneyBookService {
return { record: (await this.byId(id))! };
}
// An older writer has already lost what it could not read; it does not get to save that loss.
const stored = storedSchemaRev(existing.document);
if (stored > bookSchemaRev(input.document)) throw newerBook(stored);
/*
* The version goes in the WHERE clause rather than being checked first.
* Read-then-write leaves a window between the two in which another save can
@@ -249,11 +270,15 @@ export class JourneyBookService {
if (options.expectedVersion !== undefined && current?.version !== options.expectedVersion) {
throw bookMoved(current);
}
// `current` is the book as this server reads it, which for a newer book is the book minus what it dropped.
if (row) refuseNewerThanServer(storedSchemaRev(row.document));
const next = await edit(current);
// What the edit hands back is checked too: a kept revision from a newer server, put back after a downgrade, would land stripped yet still stamped newer.
refuseNewerThanServer(bookSchemaRev(next.document));
if (current && next.unchanged) {
return { record: current, result: next.result, wrote: false, revisionId: null };
}
const document = JSON.stringify(normalizeBookDocument(next.document));
const document = JSON.stringify(normalizeBookDocument(stampBookSchemaRev(next.document)));
if (!row) {
const id = await this.booksRepo.insertBook(journeyId, next.title, document, userId);
return { record: await this.written(id), result: next.result, wrote: true, revisionId: null };
@@ -341,6 +366,31 @@ function bookMoved(current: BookRecord | null): DomainError {
});
}
/**
* The book was written by a newer revision of the format than the writer (or
* this server) knows. 409 like a moved book, with the code the Studio reads to
* open the book read-only and ask for a reload; no `current`, because there is
* nothing here an older writer could safely take.
*/
function newerBook(schemaRev: number): DomainError {
return new DomainError(409, 'Book was saved by a newer version of TREK Studio', {
code: BOOK_SCHEMA_NEWER_CODE,
details: { code: BOOK_SCHEMA_NEWER_CODE, schemaRev },
mcpMessage:
'This book was saved by a newer version of TREK than this server runs. Changing it here would drop what that version added, so it stays as it is.',
});
}
/** Refuse a book written by a revision this server cannot read whole. */
function refuseNewerThanServer(schemaRev: number): void {
if (schemaRev > BOOK_SCHEMA_REV) throw newerBook(schemaRev);
}
/** The revision a stored row's document says it was written by; broken JSON reads as 1, like its document. */
function storedSchemaRev(raw: string): number {
return bookSchemaRev(safeParse(raw));
}
/** JSON that will not parse is an empty document, never an exception. */
function safeParse(raw: string): unknown {
try {
+63 -1
View File
@@ -9,14 +9,18 @@
* viewer's write is refused with the tool's own words, and an operation the
* engine refuses with the operation it names; a REST save and an MCP write
* send the same event to the same users, REST leaving out the tab that saved
* and MCP none; and the tools are gone while the Journey addon is off.
* and MCP none; the tools are gone while the Journey addon is off; and a book
* a newer Studio wrote is refused with 409 and its code over both, even when
* its document holds what this build cannot parse.
*/
import { ADDON_IDS } from '../../src/addons';
import { buildApp } from '../../src/bootstrap';
import { JourneyBooks } from '../../src/db/entities/JourneyBooks.entity';
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
import { withRequestContext } from '../../src/nest/database/request-context';
import { authCookie } from '../helpers/auth';
import { addJourneyContributor, makeJourney } from '../helpers/factories/journeys';
import { findRow, updateRows } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
import { makeUser } from '../helpers/factories/users';
import { spyOnRealtime, type RealtimeSpies } from '../helpers/fake-realtime';
@@ -198,4 +202,62 @@ describe('Studio book over MCP and REST', () => {
expect(names.filter((name) => BOOK_TOOLS.includes(name))).toEqual([]);
expect(names.length).toBeGreaterThan(0);
});
it('JBE2E-005: a book a newer Studio saved refuses a REST save with 409 and its code, and an MCP write in its words', async () => {
const { owner, journey, inner } = await journeyWithBook();
const before = await request(app.getHttpServer())
.get(`/api/journeys/${journey.id}/book`)
.set('Cookie', authCookie(owner.id));
const row = await findRow(orm, JourneyBooks, { journey: journey.id });
const newer = JSON.stringify({ ...JSON.parse(String(row?.document)), schemaRev: 2 });
await updateRows(orm, JourneyBooks, { journey: journey.id }, { document: newer });
const saved = await request(app.getHttpServer())
.put(`/api/journeys/${journey.id}/book`)
.set('Cookie', authCookie(owner.id))
.send({ title: before.body.book.title, document: before.body.book.document, baseVersion: 1 });
expect(saved.status).toBe(409);
expect(saved.body).toEqual({
error: 'Book was saved by a newer version of TREK Studio',
code: 'book_schema_newer',
schemaRev: 2,
});
const tool = await callTool(owner.id, 'edit_book_page', {
journeyId: journey.id,
spreadId: inner,
ops: [{ op: 'background', color: '#202020' }],
});
expect(tool).toEqual({
error:
'This book was saved by a newer version of TREK than this server runs. Changing it here would drop what that version added, so it stays as it is.',
});
expect(sent()).toEqual([]);
const after = await findRow(orm, JourneyBooks, { journey: journey.id });
expect([after?.version, after?.document]).toEqual([1, newer]);
});
it('JBE2E-006: a save of a book a newer Studio wrote, with a kind this one cannot read, is a 409 with its code, not a 400', async () => {
const { owner, journey } = await journeyWithBook();
const before = await request(app.getHttpServer())
.get(`/api/journeys/${journey.id}/book`)
.set('Cookie', authCookie(owner.id));
const document = before.body.book.document as { spreads: Array<{ elements: unknown[] }> };
const frame = { x: 10, y: 10, w: 40, h: 40 };
document.spreads[0]?.elements.push({ id: 'from-later', kind: 'sticker', frame, stickerId: 'lemon' });
const saved = await request(app.getHttpServer())
.put(`/api/journeys/${journey.id}/book`)
.set('Cookie', authCookie(owner.id))
.send({ title: before.body.book.title, document: { ...document, schemaRev: 2 }, baseVersion: 1 });
expect(saved.status).toBe(409);
expect(saved.body).toEqual({
error: 'Book was saved by a newer version of TREK Studio',
code: 'book_schema_newer',
schemaRev: 2,
});
expect(sent()).toEqual([]);
const after = await findRow(orm, JourneyBooks, { journey: journey.id });
expect(after?.version).toBe(1);
});
});
@@ -224,11 +224,42 @@ describe('the designer in the Studio', () => {
it('ASTBD-008: the Studio instructions end with the design rules, cache-stable and without dashes', () => {
const prompt = studioPrompt('German', true);
expect(prompt.endsWith(BOOK_DESIGN_PROMPT)).toBe(true);
expect(BOOK_DESIGN_PROMPT.split('\n').length).toBeLessThanOrEqual(14);
// The print rules, the energy, the type scale and the critique, on a budget the cache carries every turn.
expect(BOOK_DESIGN_PROMPT.split('\n').length).toBeLessThanOrEqual(30);
expect(BOOK_DESIGN_PROMPT.length).toBeLessThan(6000);
for (const tool of ['design_book', 'revise_book_design', 'apply_book_design', 'preview_book_page']) {
expect(BOOK_DESIGN_PROMPT).toContain(tool);
}
expect(BOOK_DESIGN_PROMPT).not.toMatch(/[\u2013\u2014]/);
expect(studioPrompt('German', true)).toBe(prompt);
});
it('ASTBD-009: restraint is one energy among three, beside print rules that hold for every book', () => {
for (const rule of ['200 dpi', 'fold', 'trim', 'bleed', 'binding', 'safe area']) {
expect(BOOK_DESIGN_PROMPT, rule).toContain(rule);
}
for (const energy of ['- Calm:', '- Lively:', '- Loud:']) expect(BOOK_DESIGN_PROMPT).toContain(energy);
expect(BOOK_DESIGN_PROMPT).toContain('A deliberate overlap is a design');
});
it('ASTBD-010: gives the type scale in numbers against the page, and the critique to answer before a proposal', () => {
for (const figure of ['72 to 180 pt', '12 to 30 % of the page height', 'at least 3:1', 'Body 9 to 11 pt']) {
expect(BOOK_DESIGN_PROMPT, figure).toContain(figure);
}
expect(BOOK_DESIGN_PROMPT).toContain('captions 6.5 to 8 pt');
// The cover tier carries a big title alone, and the faces named are faces the book has.
expect(BOOK_DESIGN_PROMPT).toContain('type.display (up to 6.5');
expect(BOOK_DESIGN_PROMPT).toContain('type.scale between 0.9 and 1.1');
expect(BOOK_DESIGN_PROMPT).not.toMatch(/script/i);
expect(BOOK_DESIGN_PROMPT).toContain('210 mm page and scaled with the page width');
const rubric = BOOK_DESIGN_PROMPT.slice(BOOK_DESIGN_PROMPT.indexOf('answer silently'));
expect(rubric.slice(0, rubric.indexOf('Fix what fails')).match(/\?/g)).toHaveLength(8);
expect(BOOK_DESIGN_PROMPT).toContain('preview the cover at least twice');
});
it('ASTBD-011: the Studio instructions take a layered page for a design and only an accidental overlap for a fault', () => {
const prompt = studioPrompt('German', true);
expect(prompt).toContain('photos or texts overlapping by accident');
expect(prompt).toContain('a layered page (a title over a photo, a turned photo over a shape) is a design');
});
});
@@ -620,6 +620,116 @@ describe('editBook', () => {
});
});
// -- Format revisions ---------------------------------------------------------
/*
* A book written by a newer revision of the format than the writer knows. The
* older writer has already dropped what it could not read, so its save, from
* an old tab or a replayed offline copy, must not land; nor may the book tools
* of a server that cannot read the book whole.
*/
describe('format revisions', () => {
/** A journey whose stored book says it was written by revision `schemaRev`. */
async function storedAt(schemaRev: number) {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
await insertRow(await sharedTestOrm(testDb), JourneyBooks, {
journey: journey.id,
title: 'T',
document: JSON.stringify({ ...doc('newer'), schemaRev }),
version: 1,
});
return { user, journey };
}
const refusalOf = (thrown: unknown) =>
thrown instanceof DomainError ? [thrown.getStatus(), thrown.code, thrown.toBody().code] : thrown;
it('JBOOK-011: refuses a save from a writer older than the stored book with 409 and the code, writing and sending nothing', async () => {
const { user, journey } = await storedAt(2);
const rec = await recordSends();
try {
const thrown = await catchDomainError(() =>
books.saveBook(journey.id, user.id, { title: 'T', document: doc('older'), baseVersion: 1 }),
);
expect(refusalOf(thrown)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
expect(thrown instanceof DomainError && thrown.toBody()).toMatchObject({
error: 'Book was saved by a newer version of TREK Studio',
schemaRev: 2,
});
expect(rec.sent).toEqual([]);
const stored = await books.getBook(journey.id, user.id);
expect([stored?.version, stored?.document.title, stored?.document.schemaRev]).toEqual([1, 'newer', 2]);
} finally {
rec.restore();
}
});
it('JBOOK-012: refuses a document newer than this server reads, first save and later save alike', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const ahead = { ...doc('ahead'), schemaRev: 9 };
const first = await catchDomainError(() => books.saveBook(journey.id, user.id, { title: 'T', document: ahead }));
expect(refusalOf(first)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
expect(await books.getBook(journey.id, user.id)).toBeNull();
await books.saveBook(journey.id, user.id, { title: 'T', document: doc('one') });
const later = await catchDomainError(() =>
books.saveBook(journey.id, user.id, { title: 'T', document: ahead, baseVersion: 1 }),
);
expect(refusalOf(later)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
expect((await books.getBook(journey.id, user.id))?.document.title).toBe('one');
});
it('JBOOK-013: a save at the stored revision or above it lands as before, and a book of today stores no revision', async () => {
const { user, journey } = await storedAt(1);
const same = await books.saveBook(journey.id, user.id, {
title: 'T',
document: { ...doc('same'), schemaRev: 1 },
baseVersion: 1,
});
expect(same && 'record' in same && same.record.version).toBe(2);
const plain = await books.saveBook(journey.id, user.id, { title: 'T', document: doc('plain'), baseVersion: 2 });
expect(plain && 'record' in plain && plain.record.version).toBe(3);
const row = await findRow(await sharedTestOrm(testDb), JourneyBooks, { journey: journey.id });
expect(JSON.parse(String(row?.document))).not.toHaveProperty('schemaRev');
});
it('JBOOK-014: the book tools leave a book this server cannot read whole alone, before any edit runs', async () => {
const { user, journey } = await storedAt(2);
const rec = await recordSends();
try {
const edit = vi.fn(() => ({ title: 'T', document: normalizeBookDocument(doc('tool')), result: null }));
const thrown = await catchDomainError(() => books.editBook(journey.id, user.id, edit));
expect(refusalOf(thrown)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
expect(thrown instanceof DomainError && thrown.mcpMessage).toMatch(/newer version of TREK/);
expect(edit).not.toHaveBeenCalled();
expect(rec.sent).toEqual([]);
expect((await books.getBook(journey.id, user.id))?.document.title).toBe('newer');
} finally {
rec.restore();
}
});
it('JBOOK-015: an edit that hands back a document newer than this server reads is refused, the stored book untouched', async () => {
const { user, journey } = await storedAt(1);
const rec = await recordSends();
try {
const ahead = normalizeBookDocument({ ...doc('ahead'), schemaRev: 2 });
const thrown = await catchDomainError(() =>
books.editBook(journey.id, user.id, () => ({ title: 'T', document: ahead, result: null })),
);
expect(refusalOf(thrown)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
expect(rec.sent).toEqual([]);
const stored = await books.getBook(journey.id, user.id);
expect([stored?.version, stored?.document.title, stored?.document.schemaRev]).toEqual([1, 'newer', 1]);
} finally {
rec.restore();
}
});
});
// -- Deleting -----------------------------------------------------------------
describe('deleting', () => {
@@ -15,7 +15,7 @@
* stale version, and forgets the draft; a journey without a book gets a new
* one and no revision; restoring the revision puts the book back only at the
* version the apply left, and never writes a revision that does not read
* back; five revisions are kept.
* back or that a newer TREK kept; five revisions are kept.
*/
import { JourneyBookRevisions } from '../../../../src/db/entities/JourneyBookRevisions.entity';
import { JourneyEntryPhotos } from '../../../../src/db/entities/JourneyEntryPhotos.entity';
@@ -321,6 +321,25 @@ describe('taking a design back', () => {
expect(told()).toEqual([]);
});
it('JBDSN-011: a revision kept by a newer TREK is refused, never put back stripped of what this one cannot read', async () => {
const { journey, owner } = await crew();
const answer = await designs.design(journey.id, owner.id);
const applied = await designs.apply(journey.id, owner.id, { designId: answer.design_id });
const revisionId = applied.revision_id ?? 0;
const kept = await findRow(orm, JourneyBookRevisions, { id: revisionId });
const newer = JSON.stringify({ ...JSON.parse(String(kept?.document)), schemaRev: 2 });
await updateRows(orm, JourneyBookRevisions, { id: revisionId }, { document: newer });
t.realtime.reset();
const back = { revisionId, expectedVersion: applied.version };
expect(await refusal(() => designs.restoreRevision(journey.id, owner.id, back))).toEqual({
status: 409,
code: 'book_schema_newer',
});
expect(await version(journey.id, owner.id)).toBe(applied.version);
expect(told()).toEqual([]);
});
it('JBDSN-009: a journey keeps its newest five revisions', async () => {
const { journey, owner } = await crew();
const books = t.get(JourneyBookService);
+68
View File
@@ -0,0 +1,68 @@
// Regenerates src/book/book-icon-names-*.data.ts, the lucide drawings the
// Studio's icon tab offers, from the lucide-react installed at the repo root.
// Run from shared/: `node scripts/gen-book-icon-names.mjs`.
//
// Read from the package's type declarations rather than imported: the shared
// package has no lucide dependency of its own and must not grow one, the
// contract layer stays zod only. The names are taken the way the client's
// iconLibrary.ts takes them from the module: every PascalCase export, less the
// `Lucide*` and `*Icon` aliases of a name that exists bare, and less `Icon`,
// the component every drawing is built on. client/src/components/Studio/
// iconLibrary.test.ts fails when the files and the shipped library drift apart.
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const lucide = join(here, '..', '..', 'node_modules', 'lucide-react');
const { version } = JSON.parse(readFileSync(join(lucide, 'package.json'), 'utf8'));
const declarations = readFileSync(join(lucide, 'dist', 'lucide-react.d.ts'), 'utf8');
/** The last `export { ... };` of the declarations, which names every icon and its aliases. */
const exportLine = declarations
.split('\n')
.filter((line) => line.startsWith('export { '))
.pop();
if (!exportLine) throw new Error('no export list in lucide-react.d.ts');
const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/;
const exported = exportLine
.replace(/^export \{ /, '')
.replace(/ \};\s*$/, '')
.split(',')
.map((part) =>
part
.trim()
.split(/\s+as\s+/)
.pop(),
)
.filter((name) => PASCAL_CASE.test(name));
const present = new Set(exported);
const names = [...present]
.filter((name) => !(name.startsWith('Lucide') && present.has(name.slice(6))))
.filter((name) => !(name.endsWith('Icon') && present.has(name.slice(0, -4))))
.filter((name) => name !== 'Icon')
.sort();
/** The three files and the initials each holds, so none of them passes a thousand lines. */
const PARTS = [
{ suffix: 'a-e', constant: 'BOOK_ICON_NAMES_A_E', label: 'A to E', test: /^[A-E]/ },
{ suffix: 'f-p', constant: 'BOOK_ICON_NAMES_F_P', label: 'F to P', test: /^[F-P]/ },
{ suffix: 'q-z', constant: 'BOOK_ICON_NAMES_Q_Z', label: 'Q to Z', test: /^[Q-Z]/ },
];
for (const part of PARTS) {
const list = names.filter((name) => part.test.test(name));
const source = `/**
* The lucide drawings the Studio offers, ${part.label}: part of BOOK_ICON_NAMES
* (book-icons.ts). Generated from lucide-react ${version} by
* scripts/gen-book-icon-names.mjs, never edited by hand; the client's
* iconLibrary.test.ts fails when the list and the library it ships drift apart.
*/
export const ${part.constant}: readonly string[] = [
${list.map((name) => ` '${name}',`).join('\n')}
];
`;
writeFileSync(join(here, '..', 'src', 'book', `book-icon-names-${part.suffix}.data.ts`), source);
}
console.log(`wrote ${names.length} icon names from lucide-react ${version}`);
+85 -43
View File
@@ -1,17 +1,39 @@
import { idSchema } from '../common/primitives.schema';
import { BOOK_DESIGN_ICONS } from './book-icons';
import {
BOOK_BINDING_HINT,
bookBadgeStyleSchema,
bookBadgeVariantSchema,
bookCountriesLayoutSchema,
bookFontIdSchema,
bookFrameStyleSchema,
bookIconNameSchema,
bookListLayoutSchema,
bookMapClipSchema,
bookMapSourceSchema,
bookMapStyleSchema,
bookMetricSchema,
bookPhotoFilterSchema,
bookPhotoFitSchema,
bookPhotoMaskSchema,
bookPinStyleSchema,
bookRouteArcSchema,
bookRouteDashSchema,
bookRouteStyleSchema,
bookShapeGradientSchema,
bookShapeIdSchema,
bookStatsLayoutSchema,
bookStrokeStyleSchema,
bookTextAlignSchema,
bookTypesetFontSchema,
} from './book-op-enums';
import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './book-op-ids';
import {
BOOK_BADGES,
BOOK_METRICS,
BOOK_SHAPES,
MAX_BOOK_COUNTRIES,
bookBadgeElementSchema,
bookCountriesElementSchema,
bookIconElementSchema,
bookListElementSchema,
bookMapElementSchema,
bookPhotoElementSchema,
bookShapeElementSchema,
bookStatsElementSchema,
bookTextElementSchema,
@@ -28,7 +50,9 @@ import { z } from 'zod';
*
* Same rules as the other operations: strict objects, the fields taken from the
* document's own shapes with their defaults removed (an omitted field leaves
* the element as it is), every string, array and number bounded.
* the element as it is), every string, array and number bounded. The enums
* carry a line each on what their values mean (book-op-enums.ts), since the
* tool schema is all a model reads of them.
*/
/** A position in millimetres, inside the bounds the document takes. */
@@ -40,7 +64,6 @@ const ids30 = z.array(bookElementIdSchema).min(1).max(30);
export const bookFrameInSchema = z.strictObject({ x: mmIn, y: mmIn, w: sizeIn, h: sizeIn });
const photo = bookPhotoElementSchema.shape;
const text = bookTextElementSchema.shape;
const shape = bookShapeElementSchema.shape;
const icon = bookIconElementSchema.shape;
@@ -55,7 +78,7 @@ const COMMON = { opacity: unit01.optional() };
/** What the travel kinds share with text: they are typeset. */
const TYPESET = {
font: map.font.unwrap().optional(),
font: bookTypesetFontSchema.optional(),
color: map.color.unwrap().optional(),
accent: map.accent.unwrap().optional(),
textScale: map.textScale.unwrap().optional(),
@@ -64,21 +87,21 @@ const TYPESET = {
const PHOTO_PROPS = {
...COMMON,
fit: photo.fit.unwrap().optional(),
fit: bookPhotoFitSchema.optional(),
focalX: unit01.optional(),
focalY: unit01.optional(),
mask: photo.mask.unwrap().optional(),
frameStyle: photo.frameStyle.unwrap().optional(),
filter: photo.filter.unwrap().optional(),
mask: bookPhotoMaskSchema.optional(),
frameStyle: bookFrameStyleSchema.optional(),
filter: bookPhotoFilterSchema.optional(),
radius: z.number().finite().min(0).max(500).optional(),
};
/** How a text is set, each field left as it is when not named: the style op's and the page edit's text op's. */
export const TEXT_STYLE_FIELDS = {
font: text.font.unwrap().optional(),
font: bookFontIdSchema.optional(),
size: text.size.unwrap().optional(),
weight: text.weight.unwrap().optional(),
italic: text.italic.unwrap().optional(),
align: text.align.unwrap().optional(),
align: bookTextAlignSchema.optional(),
leading: text.leading.unwrap().optional(),
tracking: text.tracking.unwrap().optional(),
color: text.color.unwrap().optional(),
@@ -87,10 +110,10 @@ const TEXT_PROPS = { ...COMMON, ...TEXT_STYLE_FIELDS };
const SHAPE_PROPS = {
...COMMON,
fill: shape.fill.unwrap().optional(),
gradient: shape.gradient.unwrap().optional(),
gradient: bookShapeGradientSchema.optional(),
stroke: shape.stroke.unwrap().optional(),
strokeWidth: z.number().finite().min(0).max(20).optional(),
strokeStyle: shape.strokeStyle.unwrap().optional(),
strokeStyle: bookStrokeStyleSchema.optional(),
radius: z.number().finite().min(0).max(500).optional(),
};
const ICON_PROPS = {
@@ -101,39 +124,39 @@ const ICON_PROPS = {
const MAP_PROPS = {
...COMMON,
...TYPESET,
style: map.style.unwrap().optional(),
style: bookMapStyleSchema.optional(),
showLand: map.showLand.unwrap().optional(),
showRoute: map.showRoute.unwrap().optional(),
showPins: map.showPins.unwrap().optional(),
showLabels: map.showLabels.unwrap().optional(),
routeStyle: map.routeStyle.unwrap().optional(),
routeArc: map.routeArc.unwrap().optional(),
routeDash: map.routeDash.unwrap().optional(),
pinStyle: map.pinStyle.unwrap().optional(),
clip: map.clip.unwrap().optional(),
routeStyle: bookRouteStyleSchema.optional(),
routeArc: bookRouteArcSchema.optional(),
routeDash: bookRouteDashSchema.optional(),
pinStyle: bookPinStyleSchema.optional(),
clip: bookMapClipSchema.optional(),
fitPadding: map.fitPadding.unwrap().optional(),
fitToCountries: map.fitToCountries.unwrap().optional(),
};
const STATS_PROPS = {
...COMMON,
...TYPESET,
layout: stats.layout.unwrap().optional(),
layout: bookStatsLayoutSchema.optional(),
showIcons: stats.showIcons.unwrap().optional(),
units: stats.units.unwrap().optional(),
units: stats.units.unwrap().describe('Kilometres (metric) or miles (imperial).').optional(),
};
const COUNTRIES_PROPS = {
...COMMON,
...TYPESET,
layout: countries.layout.unwrap().optional(),
layout: bookCountriesLayoutSchema.optional(),
showOutline: countries.showOutline.unwrap().optional(),
showFlag: countries.showFlag.unwrap().optional(),
showName: countries.showName.unwrap().optional(),
align: countries.align.unwrap().optional(),
align: countries.align.unwrap().describe('How the names sit in the frame.').optional(),
};
const BADGE_PROPS = {
...COMMON,
...TYPESET,
style: badge.style.unwrap().optional(),
style: bookBadgeStyleSchema.optional(),
showIcon: badge.showIcon.unwrap().optional(),
showLabel: badge.showLabel.unwrap().optional(),
autoColor: badge.autoColor.unwrap().optional(),
@@ -143,13 +166,12 @@ const BADGE_PROPS = {
const LIST_PROPS = {
...COMMON,
...TYPESET,
layout: list.layout.unwrap().optional(),
layout: bookListLayoutSchema.optional(),
showMarks: list.showMarks.unwrap().optional(),
proLabel: z.string().max(80).optional(),
conLabel: z.string().max(80).optional(),
};
const metric = z.enum(BOOK_METRICS);
const METRICS = z.array(metric).min(1).max(7);
const METRICS = z.array(bookMetricSchema).min(1).max(7);
/**
* The look of an element, per kind, for `style`. Values the journey owns (a
@@ -159,7 +181,7 @@ const METRICS = z.array(metric).min(1).max(7);
export const bookElementPropsSchema = z.discriminatedUnion('kind', [
z.strictObject({ kind: z.literal('photo'), ...PHOTO_PROPS }),
z.strictObject({ kind: z.literal('text'), ...TEXT_PROPS }),
z.strictObject({ kind: z.literal('shape'), shape: z.enum(BOOK_SHAPES).optional(), ...SHAPE_PROPS }),
z.strictObject({ kind: z.literal('shape'), shape: bookShapeIdSchema.optional(), ...SHAPE_PROPS }),
z.strictObject({ kind: z.literal('icon'), ...ICON_PROPS }),
z.strictObject({ kind: z.literal('map'), ...MAP_PROPS }),
z.strictObject({ kind: z.literal('stats'), metrics: METRICS.optional(), ...STATS_PROPS }),
@@ -189,9 +211,15 @@ export function bindingNamesItsSource(
const bindingIn = text.binding
.unwrap()
.unwrap()
.refine(bindingNamesItsSource, { message: 'an entry binding needs its entryId, a photo binding its photoId' });
.refine(bindingNamesItsSource, { message: 'an entry binding needs its entryId, a photo binding its photoId' })
.describe(BOOK_BINDING_HINT);
const iso2 = z.string().regex(/^[A-Za-z]{2}$/);
const listItem = z.strictObject({ text: z.string().max(400), tone: z.enum(['pro', 'con', 'plain']) });
const listItem = z.strictObject({
text: z.string().max(400),
tone: z
.enum(['pro', 'con', 'plain'])
.describe('pro or con sets the line in that column with its mark; plain has none.'),
});
/**
* Where a map is of: the whole journey or one trip, the two scopes a stored map
@@ -210,18 +238,21 @@ export const bookNewElementSchema = z.discriminatedUnion('kind', [
z
.strictObject({
kind: z.literal('text'),
preset: z.enum(BOOK_TEXT_PRESETS).optional(),
preset: z
.enum(BOOK_TEXT_PRESETS)
.describe('A Studio text preset: heading, subheading, body, caption, or quote (the large quotation mark).')
.optional(),
text: z.string().max(2000).optional(),
binding: bindingIn.optional(),
...TEXT_PROPS,
})
.refine((v) => v.text !== undefined || v.binding !== undefined, { message: 'give the words or a binding' }),
z.strictObject({ kind: z.literal('shape'), shape: z.enum(BOOK_SHAPES), ...SHAPE_PROPS }),
z.strictObject({ kind: z.literal('icon'), name: z.enum(BOOK_DESIGN_ICONS), ...ICON_PROPS }),
z.strictObject({ kind: z.literal('shape'), shape: bookShapeIdSchema, ...SHAPE_PROPS }),
z.strictObject({ kind: z.literal('icon'), name: bookIconNameSchema, ...ICON_PROPS }),
z.strictObject({
kind: z.literal('map'),
scope: mapScope,
source: z.enum(['vector', 'relief', 'satellite']).optional(),
source: bookMapSourceSchema.optional(),
...MAP_PROPS,
}),
z.strictObject({ kind: z.literal('stats'), metrics: METRICS, ...STATS_PROPS }),
@@ -232,7 +263,7 @@ export const bookNewElementSchema = z.discriminatedUnion('kind', [
}),
z.strictObject({
kind: z.literal('badge'),
variant: z.enum(BOOK_BADGES),
variant: bookBadgeVariantSchema,
entryId: idSchema.optional(),
code: z.string().max(24).optional(),
text: z.string().max(200).optional(),
@@ -274,20 +305,27 @@ export const BOOK_ELEMENT_OP_SCHEMAS = [
z.strictObject({
op: z.literal('arrange'),
elementIds: ids30,
to: z.enum(['front', 'forward', 'backward', 'back']),
to: z
.enum(['front', 'forward', 'backward', 'back'])
.describe('front and back go to either end of the paint order; forward and backward one step.'),
}),
/** Line elements up on an edge or a centre line of the selection, their page, or its safe area. */
z.strictObject({
op: z.literal('align'),
elementIds: ids30,
edge: z.enum(['left', 'center', 'right', 'top', 'middle', 'bottom']),
to: z.enum(['selection', 'page', 'safe']).default('selection'),
edge: z
.enum(['left', 'center', 'right', 'top', 'middle', 'bottom'])
.describe('The edge or centre line the elements line up on.'),
to: z
.enum(['selection', 'page', 'safe'])
.describe('Line up within the selection, the page, or its safe area.')
.default('selection'),
}),
/** Space three or more elements evenly, or by a fixed gap in millimetres. */
z.strictObject({
op: z.literal('distribute'),
elementIds: z.array(bookElementIdSchema).min(3).max(30),
axis: z.enum(['horizontal', 'vertical']),
axis: z.enum(['horizontal', 'vertical']).describe('Space them out across the page or down it.'),
gap: z.number().finite().min(0).max(200).optional(),
}),
/** Lock or unlock elements: the one operation a locked element accepts. */
@@ -300,7 +338,11 @@ export const BOOK_ELEMENT_OP_SCHEMAS = [
dy: z.number().finite().min(-500).max(500).optional(),
}),
/** The Studio's fill: a photo to the bleed of its page or the spread, unturned, cover, at the back. */
z.strictObject({ op: z.literal('fill'), elementId: bookElementIdSchema, scope: z.enum(['page', 'spread']) }),
z.strictObject({
op: z.literal('fill'),
elementId: bookElementIdSchema,
scope: z.enum(['page', 'spread']).describe('Fill its own page to the bleed, or both pages of the spread.'),
}),
/** Copy elements of another page onto this one, with new ids; photos are checked against the gallery. */
z.strictObject({ op: z.literal('copy_from'), spreadId: bookSpreadIdSchema, elementIds: ids30 }),
/** Bring travel figures up to date with the journey: the named elements, or every one on the page. */
+7 -2
View File
@@ -156,13 +156,18 @@ describe('add', () => {
expect(addRefusal({ kind: 'text', binding: { source: 'photo.caption' }, text: 'x' })).toBe('invalid_op');
});
it('BOOKEL-003: adds a rule as a hairline, a banner wide, and an icon from the shelf only', () => {
it('BOOKEL-003: adds a rule as a hairline, a banner wide, and any icon the Studio offers, by lucide name only', () => {
expect(added({ kind: 'shape', shape: 'line' })).toMatchObject({ shape: 'rect', frame: { w: 105, h: 0.5 } });
const banner = added({ kind: 'shape', shape: 'banner-ribbon', fill: null });
expect(banner).toMatchObject({ fill: null, frame: { w: 107.1, h: 34.65 } });
const icon = added({ kind: 'icon', name: 'Compass', color: '#c2410c' });
expect(icon).toMatchObject({ name: 'Compass', color: '#c2410c', frame: { w: 37.8, h: 37.8 } });
expect(addRefusal({ kind: 'icon', name: 'NotAnIcon' } as unknown as NewElement)).toBe('invalid_op');
// Past the travel shelf: the whole library, its old names too, as the icon tab lists it.
expect(added({ kind: 'icon', name: 'Pizza' })).toMatchObject({ kind: 'icon', name: 'Pizza' });
expect(added({ kind: 'icon', name: 'AlertTriangle' })).toMatchObject({ name: 'AlertTriangle' });
for (const name of ['NotAnIcon', 'PizzaIcon', 'pizza', 'Icon']) {
expect(addRefusal({ kind: 'icon', name } as unknown as NewElement), name).toBe('invalid_op');
}
});
it('BOOKEL-004: adds the travel kinds from the journey, and refuses them without its figures', () => {
+7 -3
View File
@@ -129,13 +129,17 @@ describe('book level and the page in words', () => {
return { ...doc, spreads: [doc.spreads[0] as BookSpread, ...inner, doc.spreads[4] as BookSpread] };
}
it('BOOKQA-009: finds more than two typefaces in a book, and says so once', () => {
const fonts = ['sans', 'serif', 'bebas'].map((font, i) => [
it('BOOKQA-009: finds more than three typefaces in a book, and says so once', () => {
const fonts = ['sans', 'serif', 'bebas', 'playfair'].map((font, i) => [
text(`t-${i}`, 'Hi', { x: 230, y: 20, w: 60, h: 10 }, { font }),
]);
const list = bookLevelFindings(bookOf(fonts));
expect(list.filter((f) => f.code === 'font_mix')).toHaveLength(1);
expect(bookLevelFindings(bookOf(fonts.slice(0, 2)))).toEqual([]);
expect(list.find((f) => f.code === 'font_mix')?.message).toMatch(/4 typefaces .*keep to three/);
// A display face, a body face and one more is a book, not a sampler.
const mixOf = (pages: typeof fonts) => bookLevelFindings(bookOf(pages)).filter((f) => f.code === 'font_mix');
expect(mixOf(fonts.slice(0, 3))).toEqual([]);
expect(mixOf(fonts.slice(0, 2))).toEqual([]);
});
it('BOOKQA-010: notes colours outside the book own and the same page three times running', () => {
+11 -2
View File
@@ -340,16 +340,25 @@ function signatureOf(spread: BookSpread): string {
return spread.elements.map((e) => `${e.kind}:${cm(e.frame.x)},${cm(e.frame.y)},${cm(e.frame.w)}`).join('|');
}
/**
* How many typefaces a book may be set in before it reads as a type sampler:
* a display face, a body face and one more, a script laid over a title or a
* face for figures. Two was the rule while the book had seven faces and no
* scripts, and it told every lively book that set its title apart to stop.
* Once fonts carry roles, script and mono faces will not count towards it.
*/
const MAX_BOOK_FAMILIES = 3;
/** Findings about the book as a whole: typefaces, colours, and the same page three times running. */
export function bookLevelFindings(doc: BookDocument): BookFinding[] {
const out: BookFinding[] = [];
const families = familiesOf(doc);
if (families.length > 2) {
if (families.length > MAX_BOOK_FAMILIES) {
out.push({
code: 'font_mix',
severity: 'warn',
elementIds: [],
message: `the book is set in ${families.length} typefaces (${families.join(', ')}); keep to two`,
message: `the book is set in ${families.length} typefaces (${families.join(', ')}); keep to three: a display face, a body face and one more`,
});
}
const colours = [...coloursOf(doc)].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
+83 -10
View File
@@ -145,35 +145,106 @@ const placedPhotos = (spread: BookSpread) => spread.elements.filter((e): e is Bo
/**
* A photo under a quarter of the size of the one it lies on is an inset: the
* auto layout sets a small picture over the hero on purpose. Context, not a
* fault; two pictures of a size on top of each other are.
* fault; two pictures of a size on top of each other are. The same holds for
* a small line of type set over display type, never over running text.
*/
const INSET_RATIO = 0.25;
/**
* Under this many degrees off straight an element reads as unturned; past it,
* as laid down at an angle on purpose. Above the scrapbook style's own tilt of
* 2 degrees, so a page of that style is not layered by its look alone.
*/
const TURNED_DEG = 3;
/** Past this share of the smaller one covered, two photos or texts are an accident however they are turned or framed. */
const LAYERED_MAX_SHARE = 0.5;
/** Display type, which a small line may be set over: a few words at the heading floor of the styles or larger. */
const DISPLAY_MAX_CHARS = 40;
const DISPLAY_MIN_PT = 14;
/** The most overlap findings one page reports, the severe ones first. */
const MAX_OVERLAPS = 6;
/** How far an element is turned from straight, 0 to 180 degrees, whichever way and however many turns. */
function turnOf(el: BookElement): number {
const rest = el.rotation % 360;
const turned = rest < 0 ? rest + 360 : rest;
return Math.min(turned, 360 - turned);
}
/**
* Whether two elements on top of each other read as layered on purpose: either
* of them turned, or the upper one a photo in a frame (a polaroid, a border, a
* shadow), which is how a print laid on a page looks. `upper` is the one
* painted later.
*/
function layered(lower: BookElement, upper: BookElement): boolean {
if (turnOf(lower) >= TURNED_DEG || turnOf(upper) >= TURNED_DEG) return true;
return upper.kind === 'photo' && upper.frameStyle !== 'none';
}
/** Whether a text is display type: a few words, set at heading size or larger. */
function isDisplay(el: BookElement): boolean {
return el.kind === 'text' && el.text.trim().length <= DISPLAY_MAX_CHARS && el.size >= DISPLAY_MIN_PT;
}
/** Whether the smaller of two is an inset on the larger: any small photo, a small text only over display type. */
function isInset(small: BookElement, large: BookElement, smaller: number, larger: number): boolean {
if (smaller >= larger * INSET_RATIO) return false;
return small.kind === 'photo' || isDisplay(large);
}
/**
* Two photos or two texts on top of each other, `a` painted under `b`.
*
* Severe when it looks like an accident: both straight, of a size, the upper
* one unframed, or more than half of the smaller one covered whatever the
* look. A small one set over a large one, a turned or framed picture laid
* partly over another, is how a lively page is made, and is said as context.
* A title over a photo or a photo over a shape is never compared at all:
* different kinds layered are a design.
*/
function overlapFinding(a: BookElement, b: BookElement, ba: Box, bb: Box, shared: number): BookFinding {
const [small, large] = area(ba) <= area(bb) ? [a, b] : [b, a];
const smaller = Math.min(area(ba), area(bb));
const pct = Math.round((shared / Math.max(smaller, 1)) * 100);
if (a.kind === 'photo' && smaller < Math.max(area(ba), area(bb)) * INSET_RATIO) {
const share = shared / Math.max(smaller, 1);
const pct = Math.round(share * 100);
const what = a.kind === 'photo' ? 'photo' : 'text';
if (isInset(small, large, smaller, Math.max(area(ba), area(bb)))) {
return {
code: 'overlap',
severity: 'info',
elementIds: [small.id, large.id],
message: `photo ${small.id} is set as an inset over ${large.id} (${pct}% of it on the larger one)`,
message: `${what} ${small.id} is set as an inset over ${large.id} (${pct}% of it on the larger one)`,
};
}
if (share <= LAYERED_MAX_SHARE && layered(a, b)) {
return {
code: 'overlap',
severity: 'info',
elementIds: [a.id, b.id],
message: `${what}s ${a.id} and ${b.id} are layered on purpose, turned or framed (${pct}% of the smaller one covered); check nothing that matters is hidden`,
};
}
return {
code: 'overlap',
severity: 'severe',
elementIds: [a.id, b.id],
message: `${a.kind === 'photo' ? 'photos' : 'texts'} ${a.id} and ${b.id} overlap (${pct}% of the smaller one)`,
message: `${what}s ${a.id} and ${b.id} overlap (${pct}% of the smaller one)`,
};
}
/**
* Every pair of photos or texts on top of each other, reported severe first up
* to the cap, so pairs layered on purpose never crowd out an accident.
*/
function overlaps(ctx: Ctx): void {
const content = ctx.spread.elements.filter((e) => e.kind === 'photo' || e.kind === 'text');
let found = 0;
for (let i = 0; i < content.length && found < 6; i++) {
for (let j = i + 1; j < content.length && found < 6; j++) {
const found: BookFinding[] = [];
for (let i = 0; i < content.length; i++) {
for (let j = i + 1; j < content.length; j++) {
const a = content[i] as BookElement;
const b = content[j] as BookElement;
if (a.kind !== b.kind) continue;
@@ -181,10 +252,12 @@ function overlaps(ctx: Ctx): void {
const bb = elementBox(b);
const shared = overlapArea(ba, bb);
if (shared < 4 || shared < Math.min(area(ba), area(bb)) * 0.05) continue;
found += 1;
add(ctx, overlapFinding(a, b, ba, bb, shared));
found.push(overlapFinding(a, b, ba, bb, shared));
}
}
const severe = found.filter((f) => f.severity === 'severe');
const context = found.filter((f) => f.severity !== 'severe');
for (const f of [...severe, ...context].slice(0, MAX_OVERLAPS)) add(ctx, f);
}
/**
+667
View File
@@ -0,0 +1,667 @@
/**
* The lucide drawings the Studio offers, A to E: part of BOOK_ICON_NAMES
* (book-icons.ts). Generated from lucide-react 0.577.0 by
* scripts/gen-book-icon-names.mjs, never edited by hand; the client's
* iconLibrary.test.ts fails when the list and the library it ships drift apart.
*/
export const BOOK_ICON_NAMES_A_E: readonly string[] = [
'AArrowDown',
'AArrowUp',
'ALargeSmall',
'Accessibility',
'Activity',
'ActivitySquare',
'AirVent',
'Airplay',
'AlarmCheck',
'AlarmClock',
'AlarmClockCheck',
'AlarmClockMinus',
'AlarmClockOff',
'AlarmClockPlus',
'AlarmMinus',
'AlarmPlus',
'AlarmSmoke',
'Album',
'AlertCircle',
'AlertOctagon',
'AlertTriangle',
'AlignCenter',
'AlignCenterHorizontal',
'AlignCenterVertical',
'AlignEndHorizontal',
'AlignEndVertical',
'AlignHorizontalDistributeCenter',
'AlignHorizontalDistributeEnd',
'AlignHorizontalDistributeStart',
'AlignHorizontalJustifyCenter',
'AlignHorizontalJustifyEnd',
'AlignHorizontalJustifyStart',
'AlignHorizontalSpaceAround',
'AlignHorizontalSpaceBetween',
'AlignJustify',
'AlignLeft',
'AlignRight',
'AlignStartHorizontal',
'AlignStartVertical',
'AlignVerticalDistributeCenter',
'AlignVerticalDistributeEnd',
'AlignVerticalDistributeStart',
'AlignVerticalJustifyCenter',
'AlignVerticalJustifyEnd',
'AlignVerticalJustifyStart',
'AlignVerticalSpaceAround',
'AlignVerticalSpaceBetween',
'Ambulance',
'Ampersand',
'Ampersands',
'Amphora',
'Anchor',
'Angry',
'Annoyed',
'Antenna',
'Anvil',
'Aperture',
'AppWindow',
'AppWindowMac',
'Apple',
'Archive',
'ArchiveRestore',
'ArchiveX',
'AreaChart',
'Armchair',
'ArrowBigDown',
'ArrowBigDownDash',
'ArrowBigLeft',
'ArrowBigLeftDash',
'ArrowBigRight',
'ArrowBigRightDash',
'ArrowBigUp',
'ArrowBigUpDash',
'ArrowDown',
'ArrowDown01',
'ArrowDown10',
'ArrowDownAZ',
'ArrowDownAz',
'ArrowDownCircle',
'ArrowDownFromLine',
'ArrowDownLeft',
'ArrowDownLeftFromCircle',
'ArrowDownLeftFromSquare',
'ArrowDownLeftSquare',
'ArrowDownNarrowWide',
'ArrowDownRight',
'ArrowDownRightFromCircle',
'ArrowDownRightFromSquare',
'ArrowDownRightSquare',
'ArrowDownSquare',
'ArrowDownToDot',
'ArrowDownToLine',
'ArrowDownUp',
'ArrowDownWideNarrow',
'ArrowDownZA',
'ArrowDownZa',
'ArrowLeft',
'ArrowLeftCircle',
'ArrowLeftFromLine',
'ArrowLeftRight',
'ArrowLeftSquare',
'ArrowLeftToLine',
'ArrowRight',
'ArrowRightCircle',
'ArrowRightFromLine',
'ArrowRightLeft',
'ArrowRightSquare',
'ArrowRightToLine',
'ArrowUp',
'ArrowUp01',
'ArrowUp10',
'ArrowUpAZ',
'ArrowUpAz',
'ArrowUpCircle',
'ArrowUpDown',
'ArrowUpFromDot',
'ArrowUpFromLine',
'ArrowUpLeft',
'ArrowUpLeftFromCircle',
'ArrowUpLeftFromSquare',
'ArrowUpLeftSquare',
'ArrowUpNarrowWide',
'ArrowUpRight',
'ArrowUpRightFromCircle',
'ArrowUpRightFromSquare',
'ArrowUpRightSquare',
'ArrowUpSquare',
'ArrowUpToLine',
'ArrowUpWideNarrow',
'ArrowUpZA',
'ArrowUpZa',
'ArrowsUpFromLine',
'Asterisk',
'AsteriskSquare',
'AtSign',
'Atom',
'AudioLines',
'AudioWaveform',
'Award',
'Axe',
'Axis3D',
'Axis3d',
'Baby',
'Backpack',
'Badge',
'BadgeAlert',
'BadgeCent',
'BadgeCheck',
'BadgeDollarSign',
'BadgeEuro',
'BadgeHelp',
'BadgeIndianRupee',
'BadgeInfo',
'BadgeJapaneseYen',
'BadgeMinus',
'BadgePercent',
'BadgePlus',
'BadgePoundSterling',
'BadgeQuestionMark',
'BadgeRussianRuble',
'BadgeSwissFranc',
'BadgeTurkishLira',
'BadgeX',
'BaggageClaim',
'Balloon',
'Ban',
'Banana',
'Bandage',
'Banknote',
'BanknoteArrowDown',
'BanknoteArrowUp',
'BanknoteX',
'BarChart',
'BarChart2',
'BarChart3',
'BarChart4',
'BarChartBig',
'BarChartHorizontal',
'BarChartHorizontalBig',
'Barcode',
'Barrel',
'Baseline',
'Bath',
'Battery',
'BatteryCharging',
'BatteryFull',
'BatteryLow',
'BatteryMedium',
'BatteryPlus',
'BatteryWarning',
'Beaker',
'Bean',
'BeanOff',
'Bed',
'BedDouble',
'BedSingle',
'Beef',
'Beer',
'BeerOff',
'Bell',
'BellDot',
'BellElectric',
'BellMinus',
'BellOff',
'BellPlus',
'BellRing',
'BetweenHorizonalEnd',
'BetweenHorizonalStart',
'BetweenHorizontalEnd',
'BetweenHorizontalStart',
'BetweenVerticalEnd',
'BetweenVerticalStart',
'BicepsFlexed',
'Bike',
'Binary',
'Binoculars',
'Biohazard',
'Bird',
'Birdhouse',
'Bitcoin',
'Blend',
'Blinds',
'Blocks',
'Bluetooth',
'BluetoothConnected',
'BluetoothOff',
'BluetoothSearching',
'Bold',
'Bolt',
'Bomb',
'Bone',
'Book',
'BookA',
'BookAlert',
'BookAudio',
'BookCheck',
'BookCopy',
'BookDashed',
'BookDown',
'BookHeadphones',
'BookHeart',
'BookImage',
'BookKey',
'BookLock',
'BookMarked',
'BookMinus',
'BookOpen',
'BookOpenCheck',
'BookOpenText',
'BookPlus',
'BookSearch',
'BookTemplate',
'BookText',
'BookType',
'BookUp',
'BookUp2',
'BookUser',
'BookX',
'Bookmark',
'BookmarkCheck',
'BookmarkMinus',
'BookmarkPlus',
'BookmarkX',
'BoomBox',
'Bot',
'BotMessageSquare',
'BotOff',
'BottleWine',
'BowArrow',
'Box',
'BoxSelect',
'Boxes',
'Braces',
'Brackets',
'Brain',
'BrainCircuit',
'BrainCog',
'BrickWall',
'BrickWallFire',
'BrickWallShield',
'Briefcase',
'BriefcaseBusiness',
'BriefcaseConveyorBelt',
'BriefcaseMedical',
'BringToFront',
'Brush',
'BrushCleaning',
'Bubbles',
'Bug',
'BugOff',
'BugPlay',
'Building',
'Building2',
'Bus',
'BusFront',
'Cable',
'CableCar',
'Cake',
'CakeSlice',
'Calculator',
'Calendar',
'Calendar1',
'CalendarArrowDown',
'CalendarArrowUp',
'CalendarCheck',
'CalendarCheck2',
'CalendarClock',
'CalendarCog',
'CalendarDays',
'CalendarFold',
'CalendarHeart',
'CalendarMinus',
'CalendarMinus2',
'CalendarOff',
'CalendarPlus',
'CalendarPlus2',
'CalendarRange',
'CalendarSearch',
'CalendarSync',
'CalendarX',
'CalendarX2',
'Calendars',
'Camera',
'CameraOff',
'CandlestickChart',
'Candy',
'CandyCane',
'CandyOff',
'Cannabis',
'CannabisOff',
'Captions',
'CaptionsOff',
'Car',
'CarFront',
'CarTaxiFront',
'Caravan',
'CardSim',
'Carrot',
'CaseLower',
'CaseSensitive',
'CaseUpper',
'CassetteTape',
'Cast',
'Castle',
'Cat',
'Cctv',
'ChartArea',
'ChartBar',
'ChartBarBig',
'ChartBarDecreasing',
'ChartBarIncreasing',
'ChartBarStacked',
'ChartCandlestick',
'ChartColumn',
'ChartColumnBig',
'ChartColumnDecreasing',
'ChartColumnIncreasing',
'ChartColumnStacked',
'ChartGantt',
'ChartLine',
'ChartNetwork',
'ChartNoAxesColumn',
'ChartNoAxesColumnDecreasing',
'ChartNoAxesColumnIncreasing',
'ChartNoAxesCombined',
'ChartNoAxesGantt',
'ChartPie',
'ChartScatter',
'ChartSpline',
'Check',
'CheckCheck',
'CheckCircle',
'CheckCircle2',
'CheckLine',
'CheckSquare',
'CheckSquare2',
'ChefHat',
'Cherry',
'ChessBishop',
'ChessKing',
'ChessKnight',
'ChessPawn',
'ChessQueen',
'ChessRook',
'ChevronDown',
'ChevronDownCircle',
'ChevronDownSquare',
'ChevronFirst',
'ChevronLast',
'ChevronLeft',
'ChevronLeftCircle',
'ChevronLeftSquare',
'ChevronRight',
'ChevronRightCircle',
'ChevronRightSquare',
'ChevronUp',
'ChevronUpCircle',
'ChevronUpSquare',
'ChevronsDown',
'ChevronsDownUp',
'ChevronsLeft',
'ChevronsLeftRight',
'ChevronsLeftRightEllipsis',
'ChevronsRight',
'ChevronsRightLeft',
'ChevronsUp',
'ChevronsUpDown',
'Chrome',
'Chromium',
'Church',
'Cigarette',
'CigaretteOff',
'Circle',
'CircleAlert',
'CircleArrowDown',
'CircleArrowLeft',
'CircleArrowOutDownLeft',
'CircleArrowOutDownRight',
'CircleArrowOutUpLeft',
'CircleArrowOutUpRight',
'CircleArrowRight',
'CircleArrowUp',
'CircleCheck',
'CircleCheckBig',
'CircleChevronDown',
'CircleChevronLeft',
'CircleChevronRight',
'CircleChevronUp',
'CircleDashed',
'CircleDivide',
'CircleDollarSign',
'CircleDot',
'CircleDotDashed',
'CircleEllipsis',
'CircleEqual',
'CircleFadingArrowUp',
'CircleFadingPlus',
'CircleGauge',
'CircleHelp',
'CircleMinus',
'CircleOff',
'CircleParking',
'CircleParkingOff',
'CirclePause',
'CirclePercent',
'CirclePile',
'CirclePlay',
'CirclePlus',
'CirclePoundSterling',
'CirclePower',
'CircleQuestionMark',
'CircleSlash',
'CircleSlash2',
'CircleSlashed',
'CircleSmall',
'CircleStar',
'CircleStop',
'CircleUser',
'CircleUserRound',
'CircleX',
'CircuitBoard',
'Citrus',
'Clapperboard',
'Clipboard',
'ClipboardCheck',
'ClipboardClock',
'ClipboardCopy',
'ClipboardEdit',
'ClipboardList',
'ClipboardMinus',
'ClipboardPaste',
'ClipboardPen',
'ClipboardPenLine',
'ClipboardPlus',
'ClipboardSignature',
'ClipboardType',
'ClipboardX',
'Clock',
'Clock1',
'Clock10',
'Clock11',
'Clock12',
'Clock2',
'Clock3',
'Clock4',
'Clock5',
'Clock6',
'Clock7',
'Clock8',
'Clock9',
'ClockAlert',
'ClockArrowDown',
'ClockArrowUp',
'ClockCheck',
'ClockFading',
'ClockPlus',
'ClosedCaption',
'Cloud',
'CloudAlert',
'CloudBackup',
'CloudCheck',
'CloudCog',
'CloudDownload',
'CloudDrizzle',
'CloudFog',
'CloudHail',
'CloudLightning',
'CloudMoon',
'CloudMoonRain',
'CloudOff',
'CloudRain',
'CloudRainWind',
'CloudSnow',
'CloudSun',
'CloudSunRain',
'CloudSync',
'CloudUpload',
'Cloudy',
'Clover',
'Club',
'Code',
'Code2',
'CodeSquare',
'CodeXml',
'Codepen',
'Codesandbox',
'Coffee',
'Cog',
'Coins',
'Columns',
'Columns2',
'Columns3',
'Columns3Cog',
'Columns4',
'ColumnsSettings',
'Combine',
'Command',
'Compass',
'Component',
'Computer',
'ConciergeBell',
'Cone',
'Construction',
'Contact',
'Contact2',
'ContactRound',
'Container',
'Contrast',
'Cookie',
'CookingPot',
'Copy',
'CopyCheck',
'CopyMinus',
'CopyPlus',
'CopySlash',
'CopyX',
'Copyleft',
'Copyright',
'CornerDownLeft',
'CornerDownRight',
'CornerLeftDown',
'CornerLeftUp',
'CornerRightDown',
'CornerRightUp',
'CornerUpLeft',
'CornerUpRight',
'Cpu',
'CreativeCommons',
'CreditCard',
'Croissant',
'Crop',
'Cross',
'Crosshair',
'Crown',
'Cuboid',
'CupSoda',
'CurlyBraces',
'Currency',
'Cylinder',
'Dam',
'Database',
'DatabaseBackup',
'DatabaseSearch',
'DatabaseZap',
'DecimalsArrowLeft',
'DecimalsArrowRight',
'Delete',
'Dessert',
'Diameter',
'Diamond',
'DiamondMinus',
'DiamondPercent',
'DiamondPlus',
'Dice1',
'Dice2',
'Dice3',
'Dice4',
'Dice5',
'Dice6',
'Dices',
'Diff',
'Disc',
'Disc2',
'Disc3',
'DiscAlbum',
'Divide',
'DivideCircle',
'DivideSquare',
'Dna',
'DnaOff',
'Dock',
'Dog',
'DollarSign',
'Donut',
'DoorClosed',
'DoorClosedLocked',
'DoorOpen',
'Dot',
'DotSquare',
'Download',
'DownloadCloud',
'DraftingCompass',
'Drama',
'Dribbble',
'Drill',
'Drone',
'Droplet',
'DropletOff',
'Droplets',
'Drum',
'Drumstick',
'Dumbbell',
'Ear',
'EarOff',
'Earth',
'EarthLock',
'Eclipse',
'Edit',
'Edit2',
'Edit3',
'Egg',
'EggFried',
'EggOff',
'Ellipse',
'Ellipsis',
'EllipsisVertical',
'Equal',
'EqualApproximately',
'EqualNot',
'EqualSquare',
'Eraser',
'EthernetPort',
'Euro',
'EvCharger',
'Expand',
'ExternalLink',
'Eye',
'EyeClosed',
'EyeOff',
];
+727
View File
@@ -0,0 +1,727 @@
/**
* The lucide drawings the Studio offers, F to P: part of BOOK_ICON_NAMES
* (book-icons.ts). Generated from lucide-react 0.577.0 by
* scripts/gen-book-icon-names.mjs, never edited by hand; the client's
* iconLibrary.test.ts fails when the list and the library it ships drift apart.
*/
export const BOOK_ICON_NAMES_F_P: readonly string[] = [
'Facebook',
'Factory',
'Fan',
'FastForward',
'Feather',
'Fence',
'FerrisWheel',
'Figma',
'File',
'FileArchive',
'FileAudio',
'FileAudio2',
'FileAxis3D',
'FileAxis3d',
'FileBadge',
'FileBadge2',
'FileBarChart',
'FileBarChart2',
'FileBox',
'FileBraces',
'FileBracesCorner',
'FileChartColumn',
'FileChartColumnIncreasing',
'FileChartLine',
'FileChartPie',
'FileCheck',
'FileCheck2',
'FileCheckCorner',
'FileClock',
'FileCode',
'FileCode2',
'FileCodeCorner',
'FileCog',
'FileCog2',
'FileDiff',
'FileDigit',
'FileDown',
'FileEdit',
'FileExclamationPoint',
'FileHeadphone',
'FileHeart',
'FileImage',
'FileInput',
'FileJson',
'FileJson2',
'FileKey',
'FileKey2',
'FileLineChart',
'FileLock',
'FileLock2',
'FileMinus',
'FileMinus2',
'FileMinusCorner',
'FileMusic',
'FileOutput',
'FilePen',
'FilePenLine',
'FilePieChart',
'FilePlay',
'FilePlus',
'FilePlus2',
'FilePlusCorner',
'FileQuestion',
'FileQuestionMark',
'FileScan',
'FileSearch',
'FileSearch2',
'FileSearchCorner',
'FileSignal',
'FileSignature',
'FileSliders',
'FileSpreadsheet',
'FileStack',
'FileSymlink',
'FileTerminal',
'FileText',
'FileType',
'FileType2',
'FileTypeCorner',
'FileUp',
'FileUser',
'FileVideo',
'FileVideo2',
'FileVideoCamera',
'FileVolume',
'FileVolume2',
'FileWarning',
'FileX',
'FileX2',
'FileXCorner',
'Files',
'Film',
'Filter',
'FilterX',
'Fingerprint',
'FingerprintPattern',
'FireExtinguisher',
'Fish',
'FishOff',
'FishSymbol',
'FishingHook',
'FishingRod',
'Flag',
'FlagOff',
'FlagTriangleLeft',
'FlagTriangleRight',
'Flame',
'FlameKindling',
'Flashlight',
'FlashlightOff',
'FlaskConical',
'FlaskConicalOff',
'FlaskRound',
'FlipHorizontal',
'FlipHorizontal2',
'FlipVertical',
'FlipVertical2',
'Flower',
'Flower2',
'Focus',
'FoldHorizontal',
'FoldVertical',
'Folder',
'FolderArchive',
'FolderCheck',
'FolderClock',
'FolderClosed',
'FolderCode',
'FolderCog',
'FolderCog2',
'FolderDot',
'FolderDown',
'FolderEdit',
'FolderGit',
'FolderGit2',
'FolderHeart',
'FolderInput',
'FolderKanban',
'FolderKey',
'FolderLock',
'FolderMinus',
'FolderOpen',
'FolderOpenDot',
'FolderOutput',
'FolderPen',
'FolderPlus',
'FolderRoot',
'FolderSearch',
'FolderSearch2',
'FolderSymlink',
'FolderSync',
'FolderTree',
'FolderUp',
'FolderX',
'Folders',
'Footprints',
'ForkKnife',
'ForkKnifeCrossed',
'Forklift',
'Form',
'FormInput',
'Forward',
'Frame',
'Framer',
'Frown',
'Fuel',
'Fullscreen',
'FunctionSquare',
'Funnel',
'FunnelPlus',
'FunnelX',
'GalleryHorizontal',
'GalleryHorizontalEnd',
'GalleryThumbnails',
'GalleryVertical',
'GalleryVerticalEnd',
'Gamepad',
'Gamepad2',
'GamepadDirectional',
'GanttChart',
'GanttChartSquare',
'Gauge',
'GaugeCircle',
'Gavel',
'Gem',
'GeorgianLari',
'Ghost',
'Gift',
'GitBranch',
'GitBranchMinus',
'GitBranchPlus',
'GitCommit',
'GitCommitHorizontal',
'GitCommitVertical',
'GitCompare',
'GitCompareArrows',
'GitFork',
'GitGraph',
'GitMerge',
'GitMergeConflict',
'GitPullRequest',
'GitPullRequestArrow',
'GitPullRequestClosed',
'GitPullRequestCreate',
'GitPullRequestCreateArrow',
'GitPullRequestDraft',
'Github',
'Gitlab',
'GlassWater',
'Glasses',
'Globe',
'Globe2',
'GlobeLock',
'GlobeOff',
'GlobeX',
'Goal',
'Gpu',
'Grab',
'GraduationCap',
'Grape',
'Grid',
'Grid2X2',
'Grid2X2Check',
'Grid2X2Plus',
'Grid2X2X',
'Grid2x2',
'Grid2x2Check',
'Grid2x2Plus',
'Grid2x2X',
'Grid3X3',
'Grid3x2',
'Grid3x3',
'Grip',
'GripHorizontal',
'GripVertical',
'Group',
'Guitar',
'Ham',
'Hamburger',
'Hammer',
'Hand',
'HandCoins',
'HandFist',
'HandGrab',
'HandHeart',
'HandHelping',
'HandMetal',
'HandPlatter',
'Handbag',
'Handshake',
'HardDrive',
'HardDriveDownload',
'HardDriveUpload',
'HardHat',
'Hash',
'HatGlasses',
'Haze',
'Hd',
'HdmiPort',
'Heading',
'Heading1',
'Heading2',
'Heading3',
'Heading4',
'Heading5',
'Heading6',
'HeadphoneOff',
'Headphones',
'Headset',
'Heart',
'HeartCrack',
'HeartHandshake',
'HeartMinus',
'HeartOff',
'HeartPlus',
'HeartPulse',
'Heater',
'Helicopter',
'HelpCircle',
'HelpingHand',
'Hexagon',
'Highlighter',
'History',
'Home',
'Hop',
'HopOff',
'Hospital',
'Hotel',
'Hourglass',
'House',
'HouseHeart',
'HousePlug',
'HousePlus',
'HouseWifi',
'IceCream',
'IceCream2',
'IceCreamBowl',
'IceCreamCone',
'IdCard',
'IdCardLanyard',
'Image',
'ImageDown',
'ImageMinus',
'ImageOff',
'ImagePlay',
'ImagePlus',
'ImageUp',
'ImageUpscale',
'Images',
'Import',
'Inbox',
'Indent',
'IndentDecrease',
'IndentIncrease',
'IndianRupee',
'Infinity',
'Info',
'Inspect',
'InspectionPanel',
'Instagram',
'Italic',
'IterationCcw',
'IterationCw',
'JapaneseYen',
'Joystick',
'Kanban',
'KanbanSquare',
'KanbanSquareDashed',
'Kayak',
'Key',
'KeyRound',
'KeySquare',
'Keyboard',
'KeyboardMusic',
'KeyboardOff',
'Lamp',
'LampCeiling',
'LampDesk',
'LampFloor',
'LampWallDown',
'LampWallUp',
'LandPlot',
'Landmark',
'Languages',
'Laptop',
'Laptop2',
'LaptopMinimal',
'LaptopMinimalCheck',
'Lasso',
'LassoSelect',
'Laugh',
'Layers',
'Layers2',
'Layers3',
'LayersPlus',
'Layout',
'LayoutDashboard',
'LayoutGrid',
'LayoutList',
'LayoutPanelLeft',
'LayoutPanelTop',
'LayoutTemplate',
'Leaf',
'LeafyGreen',
'Lectern',
'LensConcave',
'LensConvex',
'LetterText',
'Library',
'LibraryBig',
'LibrarySquare',
'LifeBuoy',
'Ligature',
'Lightbulb',
'LightbulbOff',
'LineChart',
'LineDotRightHorizontal',
'LineSquiggle',
'Link',
'Link2',
'Link2Off',
'Linkedin',
'List',
'ListCheck',
'ListChecks',
'ListChevronsDownUp',
'ListChevronsUpDown',
'ListCollapse',
'ListEnd',
'ListFilter',
'ListFilterPlus',
'ListIndentDecrease',
'ListIndentIncrease',
'ListMinus',
'ListMusic',
'ListOrdered',
'ListPlus',
'ListRestart',
'ListStart',
'ListTodo',
'ListTree',
'ListVideo',
'ListX',
'Loader',
'Loader2',
'LoaderCircle',
'LoaderPinwheel',
'Locate',
'LocateFixed',
'LocateOff',
'LocationEdit',
'Lock',
'LockKeyhole',
'LockKeyholeOpen',
'LockOpen',
'LogIn',
'LogOut',
'Logs',
'Lollipop',
'Luggage',
'MSquare',
'Magnet',
'Mail',
'MailCheck',
'MailMinus',
'MailOpen',
'MailPlus',
'MailQuestion',
'MailQuestionMark',
'MailSearch',
'MailWarning',
'MailX',
'Mailbox',
'Mails',
'Map',
'MapMinus',
'MapPin',
'MapPinCheck',
'MapPinCheckInside',
'MapPinHouse',
'MapPinMinus',
'MapPinMinusInside',
'MapPinOff',
'MapPinPen',
'MapPinPlus',
'MapPinPlusInside',
'MapPinX',
'MapPinXInside',
'MapPinned',
'MapPlus',
'Mars',
'MarsStroke',
'Martini',
'Maximize',
'Maximize2',
'Medal',
'Megaphone',
'MegaphoneOff',
'Meh',
'MemoryStick',
'Menu',
'MenuSquare',
'Merge',
'MessageCircle',
'MessageCircleCheck',
'MessageCircleCode',
'MessageCircleDashed',
'MessageCircleHeart',
'MessageCircleMore',
'MessageCircleOff',
'MessageCirclePlus',
'MessageCircleQuestion',
'MessageCircleQuestionMark',
'MessageCircleReply',
'MessageCircleWarning',
'MessageCircleX',
'MessageSquare',
'MessageSquareCheck',
'MessageSquareCode',
'MessageSquareDashed',
'MessageSquareDiff',
'MessageSquareDot',
'MessageSquareHeart',
'MessageSquareLock',
'MessageSquareMore',
'MessageSquareOff',
'MessageSquarePlus',
'MessageSquareQuote',
'MessageSquareReply',
'MessageSquareShare',
'MessageSquareText',
'MessageSquareWarning',
'MessageSquareX',
'MessagesSquare',
'Metronome',
'Mic',
'Mic2',
'MicOff',
'MicVocal',
'Microchip',
'Microscope',
'Microwave',
'Milestone',
'Milk',
'MilkOff',
'Minimize',
'Minimize2',
'Minus',
'MinusCircle',
'MinusSquare',
'MirrorRectangular',
'MirrorRound',
'Monitor',
'MonitorCheck',
'MonitorCloud',
'MonitorCog',
'MonitorDot',
'MonitorDown',
'MonitorOff',
'MonitorPause',
'MonitorPlay',
'MonitorSmartphone',
'MonitorSpeaker',
'MonitorStop',
'MonitorUp',
'MonitorX',
'Moon',
'MoonStar',
'MoreHorizontal',
'MoreVertical',
'Motorbike',
'Mountain',
'MountainSnow',
'Mouse',
'MouseLeft',
'MouseOff',
'MousePointer',
'MousePointer2',
'MousePointer2Off',
'MousePointerBan',
'MousePointerClick',
'MousePointerSquareDashed',
'MouseRight',
'Move',
'Move3D',
'Move3d',
'MoveDiagonal',
'MoveDiagonal2',
'MoveDown',
'MoveDownLeft',
'MoveDownRight',
'MoveHorizontal',
'MoveLeft',
'MoveRight',
'MoveUp',
'MoveUpLeft',
'MoveUpRight',
'MoveVertical',
'Music',
'Music2',
'Music3',
'Music4',
'Navigation',
'Navigation2',
'Navigation2Off',
'NavigationOff',
'Network',
'Newspaper',
'Nfc',
'NonBinary',
'Notebook',
'NotebookPen',
'NotebookTabs',
'NotebookText',
'NotepadText',
'NotepadTextDashed',
'Nut',
'NutOff',
'Octagon',
'OctagonAlert',
'OctagonMinus',
'OctagonPause',
'OctagonX',
'Omega',
'Option',
'Orbit',
'Origami',
'Outdent',
'Package',
'Package2',
'PackageCheck',
'PackageMinus',
'PackageOpen',
'PackagePlus',
'PackageSearch',
'PackageX',
'PaintBucket',
'PaintRoller',
'Paintbrush',
'Paintbrush2',
'PaintbrushVertical',
'Palette',
'Palmtree',
'Panda',
'PanelBottom',
'PanelBottomClose',
'PanelBottomDashed',
'PanelBottomInactive',
'PanelBottomOpen',
'PanelLeft',
'PanelLeftClose',
'PanelLeftDashed',
'PanelLeftInactive',
'PanelLeftOpen',
'PanelLeftRightDashed',
'PanelRight',
'PanelRightClose',
'PanelRightDashed',
'PanelRightInactive',
'PanelRightOpen',
'PanelTop',
'PanelTopBottomDashed',
'PanelTopClose',
'PanelTopDashed',
'PanelTopInactive',
'PanelTopOpen',
'PanelsLeftBottom',
'PanelsLeftRight',
'PanelsRightBottom',
'PanelsTopBottom',
'PanelsTopLeft',
'Paperclip',
'Parentheses',
'ParkingCircle',
'ParkingCircleOff',
'ParkingMeter',
'ParkingSquare',
'ParkingSquareOff',
'PartyPopper',
'Pause',
'PauseCircle',
'PauseOctagon',
'PawPrint',
'PcCase',
'Pen',
'PenBox',
'PenLine',
'PenOff',
'PenSquare',
'PenTool',
'Pencil',
'PencilLine',
'PencilOff',
'PencilRuler',
'Pentagon',
'Percent',
'PercentCircle',
'PercentDiamond',
'PercentSquare',
'PersonStanding',
'PhilippinePeso',
'Phone',
'PhoneCall',
'PhoneForwarded',
'PhoneIncoming',
'PhoneMissed',
'PhoneOff',
'PhoneOutgoing',
'Pi',
'PiSquare',
'Piano',
'Pickaxe',
'PictureInPicture',
'PictureInPicture2',
'PieChart',
'PiggyBank',
'Pilcrow',
'PilcrowLeft',
'PilcrowRight',
'PilcrowSquare',
'Pill',
'PillBottle',
'Pin',
'PinOff',
'Pipette',
'Pizza',
'Plane',
'PlaneLanding',
'PlaneTakeoff',
'Play',
'PlayCircle',
'PlaySquare',
'Plug',
'Plug2',
'PlugZap',
'PlugZap2',
'Plus',
'PlusCircle',
'PlusSquare',
'Pocket',
'PocketKnife',
'Podcast',
'Pointer',
'PointerOff',
'Popcorn',
'Popsicle',
'PoundSterling',
'Power',
'PowerCircle',
'PowerOff',
'PowerSquare',
'Presentation',
'Printer',
'PrinterCheck',
'PrinterX',
'Projector',
'Proportions',
'Puzzle',
'Pyramid',
];
+577
View File
@@ -0,0 +1,577 @@
/**
* The lucide drawings the Studio offers, Q to Z: part of BOOK_ICON_NAMES
* (book-icons.ts). Generated from lucide-react 0.577.0 by
* scripts/gen-book-icon-names.mjs, never edited by hand; the client's
* iconLibrary.test.ts fails when the list and the library it ships drift apart.
*/
export const BOOK_ICON_NAMES_Q_Z: readonly string[] = [
'QrCode',
'Quote',
'Rabbit',
'Radar',
'Radiation',
'Radical',
'Radio',
'RadioReceiver',
'RadioTower',
'Radius',
'RailSymbol',
'Rainbow',
'Rat',
'Ratio',
'Receipt',
'ReceiptCent',
'ReceiptEuro',
'ReceiptIndianRupee',
'ReceiptJapaneseYen',
'ReceiptPoundSterling',
'ReceiptRussianRuble',
'ReceiptSwissFranc',
'ReceiptText',
'ReceiptTurkishLira',
'RectangleCircle',
'RectangleEllipsis',
'RectangleGoggles',
'RectangleHorizontal',
'RectangleVertical',
'Recycle',
'Redo',
'Redo2',
'RedoDot',
'RefreshCcw',
'RefreshCcwDot',
'RefreshCw',
'RefreshCwOff',
'Refrigerator',
'Regex',
'RemoveFormatting',
'Repeat',
'Repeat1',
'Repeat2',
'Replace',
'ReplaceAll',
'Reply',
'ReplyAll',
'Rewind',
'Ribbon',
'Rocket',
'RockingChair',
'RollerCoaster',
'Rose',
'Rotate3D',
'Rotate3d',
'RotateCcw',
'RotateCcwKey',
'RotateCcwSquare',
'RotateCw',
'RotateCwSquare',
'Route',
'RouteOff',
'Router',
'Rows',
'Rows2',
'Rows3',
'Rows4',
'Rss',
'Ruler',
'RulerDimensionLine',
'RussianRuble',
'Sailboat',
'Salad',
'Sandwich',
'Satellite',
'SatelliteDish',
'SaudiRiyal',
'Save',
'SaveAll',
'SaveOff',
'Scale',
'Scale3D',
'Scale3d',
'Scaling',
'Scan',
'ScanBarcode',
'ScanEye',
'ScanFace',
'ScanHeart',
'ScanLine',
'ScanQrCode',
'ScanSearch',
'ScanText',
'ScatterChart',
'School',
'School2',
'Scissors',
'ScissorsLineDashed',
'ScissorsSquare',
'ScissorsSquareDashedBottom',
'Scooter',
'ScreenShare',
'ScreenShareOff',
'Scroll',
'ScrollText',
'Search',
'SearchAlert',
'SearchCheck',
'SearchCode',
'SearchSlash',
'SearchX',
'Section',
'Send',
'SendHorizonal',
'SendHorizontal',
'SendToBack',
'SeparatorHorizontal',
'SeparatorVertical',
'Server',
'ServerCog',
'ServerCrash',
'ServerOff',
'Settings',
'Settings2',
'Shapes',
'Share',
'Share2',
'Sheet',
'Shell',
'ShelvingUnit',
'Shield',
'ShieldAlert',
'ShieldBan',
'ShieldCheck',
'ShieldClose',
'ShieldEllipsis',
'ShieldHalf',
'ShieldMinus',
'ShieldOff',
'ShieldPlus',
'ShieldQuestion',
'ShieldQuestionMark',
'ShieldUser',
'ShieldX',
'Ship',
'ShipWheel',
'Shirt',
'ShoppingBag',
'ShoppingBasket',
'ShoppingCart',
'Shovel',
'ShowerHead',
'Shredder',
'Shrimp',
'Shrink',
'Shrub',
'Shuffle',
'Sidebar',
'SidebarClose',
'SidebarOpen',
'Sigma',
'SigmaSquare',
'Signal',
'SignalHigh',
'SignalLow',
'SignalMedium',
'SignalZero',
'Signature',
'Signpost',
'SignpostBig',
'Siren',
'SkipBack',
'SkipForward',
'Skull',
'Slack',
'Slash',
'SlashSquare',
'Slice',
'Sliders',
'SlidersHorizontal',
'SlidersVertical',
'Smartphone',
'SmartphoneCharging',
'SmartphoneNfc',
'Smile',
'SmilePlus',
'Snail',
'Snowflake',
'SoapDispenserDroplet',
'Sofa',
'SolarPanel',
'SortAsc',
'SortDesc',
'Soup',
'Space',
'Spade',
'Sparkle',
'Sparkles',
'Speaker',
'Speech',
'SpellCheck',
'SpellCheck2',
'Spline',
'SplinePointer',
'Split',
'SplitSquareHorizontal',
'SplitSquareVertical',
'Spool',
'Spotlight',
'SprayCan',
'Sprout',
'Square',
'SquareActivity',
'SquareArrowDown',
'SquareArrowDownLeft',
'SquareArrowDownRight',
'SquareArrowLeft',
'SquareArrowOutDownLeft',
'SquareArrowOutDownRight',
'SquareArrowOutUpLeft',
'SquareArrowOutUpRight',
'SquareArrowRight',
'SquareArrowRightEnter',
'SquareArrowRightExit',
'SquareArrowUp',
'SquareArrowUpLeft',
'SquareArrowUpRight',
'SquareAsterisk',
'SquareBottomDashedScissors',
'SquareCenterlineDashedHorizontal',
'SquareCenterlineDashedVertical',
'SquareChartGantt',
'SquareCheck',
'SquareCheckBig',
'SquareChevronDown',
'SquareChevronLeft',
'SquareChevronRight',
'SquareChevronUp',
'SquareCode',
'SquareDashed',
'SquareDashedBottom',
'SquareDashedBottomCode',
'SquareDashedKanban',
'SquareDashedMousePointer',
'SquareDashedTopSolid',
'SquareDivide',
'SquareDot',
'SquareEqual',
'SquareFunction',
'SquareGanttChart',
'SquareKanban',
'SquareLibrary',
'SquareM',
'SquareMenu',
'SquareMinus',
'SquareMousePointer',
'SquareParking',
'SquareParkingOff',
'SquarePause',
'SquarePen',
'SquarePercent',
'SquarePi',
'SquarePilcrow',
'SquarePlay',
'SquarePlus',
'SquarePower',
'SquareRadical',
'SquareRoundCorner',
'SquareScissors',
'SquareSigma',
'SquareSlash',
'SquareSplitHorizontal',
'SquareSplitVertical',
'SquareSquare',
'SquareStack',
'SquareStar',
'SquareStop',
'SquareTerminal',
'SquareUser',
'SquareUserRound',
'SquareX',
'SquaresExclude',
'SquaresIntersect',
'SquaresSubtract',
'SquaresUnite',
'Squircle',
'SquircleDashed',
'Squirrel',
'Stamp',
'Star',
'StarHalf',
'StarOff',
'Stars',
'StepBack',
'StepForward',
'Stethoscope',
'Sticker',
'StickyNote',
'Stone',
'StopCircle',
'Store',
'StretchHorizontal',
'StretchVertical',
'Strikethrough',
'Subscript',
'Subtitles',
'Sun',
'SunDim',
'SunMedium',
'SunMoon',
'SunSnow',
'Sunrise',
'Sunset',
'Superscript',
'SwatchBook',
'SwissFranc',
'SwitchCamera',
'Sword',
'Swords',
'Syringe',
'Table',
'Table2',
'TableCellsMerge',
'TableCellsSplit',
'TableColumnsSplit',
'TableConfig',
'TableOfContents',
'TableProperties',
'TableRowsSplit',
'Tablet',
'TabletSmartphone',
'Tablets',
'Tag',
'Tags',
'Tally1',
'Tally2',
'Tally3',
'Tally4',
'Tally5',
'Tangent',
'Target',
'Telescope',
'Tent',
'TentTree',
'Terminal',
'TerminalSquare',
'TestTube',
'TestTube2',
'TestTubeDiagonal',
'TestTubes',
'Text',
'TextAlignCenter',
'TextAlignEnd',
'TextAlignJustify',
'TextAlignStart',
'TextCursor',
'TextCursorInput',
'TextInitial',
'TextQuote',
'TextSearch',
'TextSelect',
'TextSelection',
'TextWrap',
'Theater',
'Thermometer',
'ThermometerSnowflake',
'ThermometerSun',
'ThumbsDown',
'ThumbsUp',
'Ticket',
'TicketCheck',
'TicketMinus',
'TicketPercent',
'TicketPlus',
'TicketSlash',
'TicketX',
'Tickets',
'TicketsPlane',
'Timer',
'TimerOff',
'TimerReset',
'ToggleLeft',
'ToggleRight',
'Toilet',
'ToolCase',
'Toolbox',
'Tornado',
'Torus',
'Touchpad',
'TouchpadOff',
'TowelRack',
'TowerControl',
'ToyBrick',
'Tractor',
'TrafficCone',
'Train',
'TrainFront',
'TrainFrontTunnel',
'TrainTrack',
'TramFront',
'Transgender',
'Trash',
'Trash2',
'TreeDeciduous',
'TreePalm',
'TreePine',
'Trees',
'Trello',
'TrendingDown',
'TrendingUp',
'TrendingUpDown',
'Triangle',
'TriangleAlert',
'TriangleDashed',
'TriangleRight',
'Trophy',
'Truck',
'TruckElectric',
'TurkishLira',
'Turntable',
'Turtle',
'Tv',
'Tv2',
'TvMinimal',
'TvMinimalPlay',
'Twitch',
'Twitter',
'Type',
'TypeOutline',
'Umbrella',
'UmbrellaOff',
'Underline',
'Undo',
'Undo2',
'UndoDot',
'UnfoldHorizontal',
'UnfoldVertical',
'Ungroup',
'University',
'Unlink',
'Unlink2',
'Unlock',
'UnlockKeyhole',
'Unplug',
'Upload',
'UploadCloud',
'Usb',
'User',
'User2',
'UserCheck',
'UserCheck2',
'UserCircle',
'UserCircle2',
'UserCog',
'UserCog2',
'UserKey',
'UserLock',
'UserMinus',
'UserMinus2',
'UserPen',
'UserPlus',
'UserPlus2',
'UserRound',
'UserRoundCheck',
'UserRoundCog',
'UserRoundKey',
'UserRoundMinus',
'UserRoundPen',
'UserRoundPlus',
'UserRoundSearch',
'UserRoundX',
'UserSearch',
'UserSquare',
'UserSquare2',
'UserStar',
'UserX',
'UserX2',
'Users',
'Users2',
'UsersRound',
'Utensils',
'UtensilsCrossed',
'UtilityPole',
'Van',
'Variable',
'Vault',
'VectorSquare',
'Vegan',
'VenetianMask',
'Venus',
'VenusAndMars',
'Verified',
'Vibrate',
'VibrateOff',
'Video',
'VideoOff',
'Videotape',
'View',
'Voicemail',
'Volleyball',
'Volume',
'Volume1',
'Volume2',
'VolumeOff',
'VolumeX',
'Vote',
'Wallet',
'Wallet2',
'WalletCards',
'WalletMinimal',
'Wallpaper',
'Wand',
'Wand2',
'WandSparkles',
'Warehouse',
'WashingMachine',
'Watch',
'Waves',
'WavesArrowDown',
'WavesArrowUp',
'WavesLadder',
'Waypoints',
'Webcam',
'Webhook',
'WebhookOff',
'Weight',
'WeightTilde',
'Wheat',
'WheatOff',
'WholeWord',
'Wifi',
'WifiCog',
'WifiHigh',
'WifiLow',
'WifiOff',
'WifiPen',
'WifiSync',
'WifiZero',
'Wind',
'WindArrowDown',
'Wine',
'WineOff',
'Workflow',
'Worm',
'WrapText',
'Wrench',
'X',
'XCircle',
'XLineTop',
'XOctagon',
'XSquare',
'Youtube',
'Zap',
'ZapOff',
'ZodiacAquarius',
'ZodiacAries',
'ZodiacCancer',
'ZodiacCapricorn',
'ZodiacGemini',
'ZodiacLeo',
'ZodiacLibra',
'ZodiacOphiuchus',
'ZodiacPisces',
'ZodiacSagittarius',
'ZodiacScorpio',
'ZodiacTaurus',
'ZodiacVirgo',
'ZoomIn',
'ZoomOut',
];
+28 -7
View File
@@ -1,12 +1,33 @@
import { BOOK_ICON_NAMES_A_E } from './book-icon-names-a-e.data';
import { BOOK_ICON_NAMES_F_P } from './book-icon-names-f-p.data';
import { BOOK_ICON_NAMES_Q_Z } from './book-icon-names-q-z.data';
/**
* The drawings the assistant may put on a page, by their lucide names.
* Every drawing the Studio's icon tab offers, by its lucide name: the
* library as the client ships it, its old names included, the way the tab
* lists them (client/src/components/Studio/iconLibrary.ts). The book engine
* takes a new icon by any of these names, so the assistant may place every
* drawing a person could pick by hand.
*
* The Studio's icon tab opens on this shelf (client/src/components/Studio/iconLibrary.ts
* filters it against the lucide version it ships), and the book engine takes a
* new icon only from it: an icon element stores a free name the renderer
* resolves, and a model that names a drawing lucide does not have would place
* the fallback glyph. A curated list keeps the assistant to drawings that exist
* and that a travel page actually reaches for.
* A set of names rather than the enum the contract usually spells: the page
* edit's tool schema carries no `$ref`, and some two thousand names written
* into it three times over would take it far past its size ceiling. The names
* are checked against this set instead, which refuses exactly what an enum
* would. A client test holds the set to the lucide version the client runs,
* so an icon is never named here that would draw as the fallback glyph.
*/
export const BOOK_ICON_NAMES: ReadonlySet<string> = new Set([
...BOOK_ICON_NAMES_A_E,
...BOOK_ICON_NAMES_F_P,
...BOOK_ICON_NAMES_Q_Z,
]);
/**
* The shelf the Studio's icon tab opens on, by lucide name: the drawings a
* travel page reaches for first, in the order somebody would look for them
* (iconLibrary.ts filters it against the lucide version it ships). The tool
* schema names a few of them as examples, so a model starts from a drawing
* that belongs on a travel page rather than from the alphabet.
*/
export const BOOK_DESIGN_ICONS = [
'Plane',
+2 -1
View File
@@ -1,5 +1,6 @@
import { BOOK_GRIDS, bookGridElements, defaultBookGridBox } from './book-grids';
import { BOOK_COVER_TEMPLATES, BOOK_TEMPLATES, applyBookTemplate, type BookTemplate } from './book-layouts';
import { normalizeBookDocument } from './book-normalize';
import {
applySavedBookLayout,
bookLayoutFromSpread,
@@ -7,7 +8,7 @@ import {
bookLayoutRole,
nextBookLayoutName,
} from './book-saved-layouts';
import { normalizeBookDocument, type BookDocument } from './book.schema';
import type { BookDocument } from './book.schema';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
+68
View File
@@ -0,0 +1,68 @@
import {
MAX_BOOK_LAYOUTS,
MAX_SPREAD_ELEMENTS,
bookDocumentSchema,
bookElementSchema,
bookLayoutSchema,
type BookDocument,
} from './book.schema';
/**
* Read a stored document without ever throwing.
*
* Same shape as `normalizeAppearance`: a book that cannot be parsed must still
* open — an editor that refuses to load someone's work because one field drifted
* is worse than one that drops the field.
*
* ── Why there are two attempts ───────────────────────────────────────────
*
* Dropping *the field* was the intent; dropping the whole book was what the
* code did. `elements` is an array of a discriminated union, so one element the
* contract cannot read fails its spread, fails the array, fails the document —
* and this function then handed back an empty book. It is not hypothetical: a
* mood mark carried the journal's key in a field capped at two characters, so
* every book with one on a page refused to save, and would have opened blank.
* Both the client's load seam and the server's read *and* write path run
* through here, which means the empty book was also what got written down.
*
* So a document that fails whole is tried again with the unreadable elements
* removed, and only a document that fails even then is given up on. Losing one
* decoration to a version skew is a shrug; losing the book is not.
*/
export function normalizeBookDocument(raw: unknown): BookDocument {
const parsed = bookDocumentSchema.safeParse(raw);
if (parsed.success) return parsed.data;
const salvaged = bookDocumentSchema.safeParse(withoutUnreadableElements(raw));
if (salvaged.success) return salvaged.data;
return bookDocumentSchema.parse({});
}
function withoutUnreadableElements(raw: unknown): unknown {
if (!raw || typeof raw !== 'object') return raw;
const doc = raw as { spreads?: unknown };
if (!Array.isArray(doc.spreads)) return raw;
const readable = (list: unknown) =>
Array.isArray(list)
? // Trimmed to the cap as well as filtered: a spread that grew past 60
// fails the array the same way an unreadable element fails the union, and
// losing the sixty-first decoration beats losing the book.
list.filter((el) => bookElementSchema.safeParse(el).success).slice(0, MAX_SPREAD_ELEMENTS)
: list;
const layouts = (doc as { layouts?: unknown }).layouts;
return {
...doc,
// A saved layout is a convenience: one that no longer reads is dropped, not the book.
...(Array.isArray(layouts)
? { layouts: layouts.filter((l) => bookLayoutSchema.safeParse(l).success).slice(0, MAX_BOOK_LAYOUTS) }
: {}),
spreads: doc.spreads.map((sp) => {
if (!sp || typeof sp !== 'object') return sp;
const spread = sp as { elements?: unknown; parked?: unknown };
return { ...spread, elements: readable(spread.elements), parked: readable(spread.parked) };
}),
};
}
+62
View File
@@ -0,0 +1,62 @@
import { BOOK_DESIGN_ICONS, BOOK_ICON_NAMES } from './book-icons';
import * as described from './book-op-enums';
import { bookPageOpSchema } from './book-ops.schema';
import { BOOK_FONTS_IDS, BOOK_SHAPES, bookPhotoElementSchema } from './book.schema';
import { bookDesignDirectivesSchema } from './design/design-directives.schema';
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
// BKENUM-001 to BKENUM-004
/**
* The enums the book tools show a model: each says what its values mean, the
* values stay the document's own, and an icon is any drawing of the Studio's
* icon tab, refused by name exactly as an enum would refuse it.
*/
const schemas = Object.entries(described as Record<string, unknown>).filter(
(entry): entry is [string, z.ZodType] => entry[1] instanceof z.ZodType,
);
describe('the described enums', () => {
it('BKENUM-001: say what their values mean in one line each, without dashes', () => {
expect(schemas.length).toBeGreaterThan(20);
for (const [name, schema] of schemas) {
const text = schema.description ?? '';
expect(text.length, name).toBeGreaterThan(10);
expect(text, name).not.toMatch(/[\u2013\u2014\n]/);
}
expect(described.BOOK_BINDING_HINT).not.toMatch(/[\u2013\u2014\n]/);
});
it('BKENUM-002: keep the values of the document they describe', () => {
expect(described.bookFontIdSchema.options).toEqual([...BOOK_FONTS_IDS]);
expect(described.bookShapeIdSchema.options).toEqual([...BOOK_SHAPES]);
const frameStyles = bookPhotoElementSchema.shape.frameStyle.unwrap().options;
expect(described.bookFrameStyleSchema.options).toEqual(frameStyles);
for (const font of BOOK_FONTS_IDS) expect(String(described.bookFontIdSchema.description)).toContain(font);
});
it('BKENUM-003: carry their meaning into the page edit and the design directives a model reads', () => {
const page = JSON.stringify(z.toJSONSchema(bookPageOpSchema, { io: 'input' }));
for (const line of [described.bookFontIdSchema, described.bookShapeIdSchema, described.bookFrameStyleSchema]) {
expect(page).toContain(String(line.description));
}
const design = JSON.stringify(z.toJSONSchema(bookDesignDirectivesSchema, { io: 'input' }));
expect(design).toContain('Photos per spread: airy 1 to 2');
expect(design).toContain(String(described.bookFontIdSchema.description));
});
});
describe('the icon names', () => {
it('BKENUM-004: take every drawing of the icon tab, the travel shelf and old names among them, and nothing else', () => {
expect(BOOK_ICON_NAMES.size).toBeGreaterThan(1500);
for (const name of [...BOOK_DESIGN_ICONS, 'Pizza', 'AlertTriangle', 'ZoomOut']) {
expect(described.bookIconNameSchema.safeParse(name).success, name).toBe(true);
}
for (const name of ['NotAnIcon', 'PizzaIcon', 'LucidePizza', 'Icon', 'pizza', '']) {
expect(described.bookIconNameSchema.safeParse(name).success, name).toBe(false);
}
});
});
+141
View File
@@ -0,0 +1,141 @@
import { BOOK_ICON_NAMES } from './book-icons';
import {
BOOK_BADGES,
BOOK_FONTS_IDS,
BOOK_METRICS,
BOOK_SHAPES,
bookBadgeElementSchema,
bookCountriesElementSchema,
bookListElementSchema,
bookMapElementSchema,
bookPageSetupSchema,
bookPhotoElementSchema,
bookShapeElementSchema,
bookStatsElementSchema,
bookTextElementSchema,
} from './book.schema';
import { z } from 'zod';
/**
* The enums a model reads in the book tools, each with one line on what its
* values mean.
*
* A JSON schema that lists bare ids leaves the model to guess what `bebas`
* sets or what a `seal` draws, and a guess at a typeface is a page set in the
* wrong one. The values stay those of the document's own schema; only the
* description is added, which changes nothing a parse accepts.
*
* Short on purpose. The tool schemas carry no `$ref`, so a description is
* repeated wherever its field appears, and the page edit's schema is held
* under a size ceiling (MCP-SCHEMA-004 on the server). Where a field repeats
* on every travel element, a shorter line points back at the full one.
*/
const photo = bookPhotoElementSchema.shape;
const text = bookTextElementSchema.shape;
const shape = bookShapeElementSchema.shape;
const map = bookMapElementSchema.shape;
/** The book's typefaces, with the face each id sets. */
export const bookFontIdSchema = z
.enum(BOOK_FONTS_IDS)
.describe(
'Typeface: sans Poppins, inter Inter, serif Lora, garamond EB Garamond, playfair Playfair Display, display MuseoModerno, bebas Bebas Neue (caps, one weight).',
);
/** The typeface of a travel element, where the full line above would repeat ten times. */
export const bookTypesetFontSchema = z.enum(BOOK_FONTS_IDS).describe('Typeface id, as for text.');
/** Every shape the Studio draws, and so every mask a photo can be cut to. */
export const bookShapeIdSchema = z
.enum(BOOK_SHAPES)
.describe(
'A filled outline: rect, ellipse, line (a rule), polygons, arch and circle parts, stars, burst, seal, sparkle, arrows, chevrons, speech bubbles, nature and travel motifs, blobs and banners.',
);
export const bookPhotoFitSchema = photo.fit
.unwrap()
.describe('cover fills the frame, cropped at the focal point; contain shows all of it.');
export const bookPhotoMaskSchema = photo.mask
.unwrap()
.describe('A shape id to cut the photo to; null is the plain rectangle.');
export const bookFrameStyleSchema = photo.frameStyle
.unwrap()
.describe('polaroid (deep bottom border), white border, shadow, film or tape around it; none is a plain print.');
export const bookPhotoFilterSchema = photo.filter
.unwrap()
.describe('bw (black and white), warm, cool, fade (lifted blacks) or contrast; none as shot.');
export const bookTextAlignSchema = text.align.unwrap().describe('How the lines sit in the frame.');
export const bookShapeGradientSchema = shape.gradient
.unwrap()
.describe('Fade the fill out: up is solid at the top, down solid at the bottom; none is one flat fill.');
export const bookStrokeStyleSchema = shape.strokeStyle.unwrap().describe('A solid, dashed or dotted outline or rule.');
export const bookMapStyleSchema = map.style
.unwrap()
.describe('The map look: minimal, outline, dark (for a dark page) or paper.');
export const bookRouteStyleSchema = map.routeStyle
.unwrap()
.describe('plain is one stroke; drawn is a light line over a dark casing, as in an atlas.');
export const bookRouteArcSchema = map.routeArc
.unwrap()
.describe('bow draws the long legs as a curve, the way a flight is shown.');
export const bookRouteDashSchema = map.routeDash.unwrap().describe('arcs dashes the bowed legs; solid dashes nothing.');
export const bookPinStyleSchema = map.pinStyle.unwrap().describe('A stop as a dot, or as a small round photo of it.');
export const bookMapClipSchema = map.clip
.unwrap()
.describe('rect is a map in a box; country cuts it to the outline of its countries.');
/** Where a new map's ground comes from. */
export const bookMapSourceSchema = z
.enum(['vector', 'relief', 'satellite'])
.describe('vector outlines print sharp at any size; relief and satellite are imagery from the map provider.');
export const bookStatsLayoutSchema = bookStatsElementSchema.shape.layout
.unwrap()
.describe('The figures in a grid, one row or one column.');
export const bookCountriesLayoutSchema = bookCountriesElementSchema.shape.layout
.unwrap()
.describe('The countries as a list, a grid or one column.');
export const bookBadgeStyleSchema = bookBadgeElementSchema.shape.style
.unwrap()
.describe('plain words, a chip (filled capsule), an outline, or stacked over its line.');
export const bookBadgeVariantSchema = z
.enum(BOOK_BADGES)
.describe(
'What the mark shows: a flag, a date, a day counter, coordinates, a country, a distance, weather, altitude or mood.',
);
export const bookListLayoutSchema = bookListElementSchema.shape.layout
.unwrap()
.describe('columns sets pros beside cons; stacked sets one column with the marks inline.');
export const bookMetricSchema = z
.enum(BOOK_METRICS)
.describe(
'A travel figure: distance, days, steps (journal entries), photos, countries, places or furthest from the start.',
);
/** The page sizes a book is set up in. */
export const bookPagePresetSchema = bookPageSetupSchema.shape.preset
.unwrap()
.describe(
'The page: square-210 or square-300 (mm), a4-landscape, a4-portrait, a5-landscape; custom keeps its own size.',
);
/**
* Any drawing the Studio's icon tab offers, by its lucide name. Checked
* against the set rather than listed, for the size of the schema; refused
* exactly as an enum would refuse a name it does not hold.
*/
export const bookIconNameSchema = z
.string()
.regex(/^[A-Z][A-Za-z0-9]*$/, 'expected a lucide icon name')
.max(60)
.refine((name) => BOOK_ICON_NAMES.has(name), { message: 'not a lucide icon the Studio offers' })
.describe(
'A lucide icon name in PascalCase, any the Studio offers: Plane, Compass, MapPin, MountainSnow, Waves, Sun, Camera, Heart, Landmark, Croissant and the rest of lucide.',
);
/** What a text's binding means: where its words come from, and what each source needs beside it. */
export const BOOK_BINDING_HINT =
'Words read from the journey and kept in sync: journey.title or journey.subtitle; entry.title, entry.story, entry.location (format dms or decimal for coordinates) or entry.date with entryId; photo.caption with photoId.';
+5 -1
View File
@@ -14,4 +14,8 @@ export const bookElementIdSchema = z.string().regex(/^[\w-]{1,40}$/);
const gridIds = BOOK_GRIDS.map((g) => g.id) as [string, ...string[]];
/** The photo grids, by id. */
export const bookGridIdSchema = z.enum(gridIds);
export const bookGridIdSchema = z
.enum(gridIds)
.describe(
'A photo grid by id, named for its cells (two-across, grid-6, hero-three, cols-2-1, filmstrip); its frames come empty.',
);
+44 -18
View File
@@ -1,6 +1,15 @@
import { idSchema } from '../common/primitives.schema';
import { BOOK_ELEMENT_OP_SCHEMAS, TEXT_STYLE_FIELDS, bindingNamesItsSource } from './book-element-ops.schema';
import { BOOK_COVER_TEMPLATES, BOOK_TEMPLATES } from './book-layouts';
import {
BOOK_BINDING_HINT,
bookFontIdSchema,
bookFrameStyleSchema,
bookPagePresetSchema,
bookPhotoFilterSchema,
bookPhotoFitSchema,
bookPhotoMaskSchema,
} from './book-op-enums';
import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './book-op-ids';
import {
MAX_BOOK_TITLE,
@@ -9,7 +18,6 @@ import {
bookLayoutSchema,
bookPageNumbersSchema,
bookPageSetupSchema,
bookPhotoElementSchema,
bookSpreadSchema,
bookTextElementSchema,
} from './book.schema';
@@ -52,13 +60,18 @@ export const BOOK_OP_LIMITS = {
const idsOf = (list: readonly { id: string }[]) => list.map((t) => t.id) as [string, ...string[]];
/** The spread layouts, by id. */
export const bookTemplateIdSchema = z.enum(idsOf(BOOK_TEMPLATES));
export const bookTemplateIdSchema = z
.enum(idsOf(BOOK_TEMPLATES))
.describe('A built-in double page layout by id; list_book_layouts says what each one holds.');
/** The single-page (cover and back) layouts, by id. */
export const bookCoverTemplateIdSchema = z.enum(idsOf(BOOK_COVER_TEMPLATES));
export const bookCoverTemplateIdSchema = z
.enum(idsOf(BOOK_COVER_TEMPLATES))
.describe('A built-in cover or back layout by id; list_book_layouts says what each one holds.');
/** Either set: which one fits is the page's question, answered when the operation runs. */
export const bookAnyTemplateIdSchema = z.enum([...idsOf(BOOK_TEMPLATES), ...idsOf(BOOK_COVER_TEMPLATES)]);
export const bookAnyTemplateIdSchema = z
.enum([...idsOf(BOOK_TEMPLATES), ...idsOf(BOOK_COVER_TEMPLATES)])
.describe('A built-in layout by id: the cover-* ones fit a single page, the rest a double page.');
const photo = bookPhotoElementSchema.shape;
const text = bookTextElementSchema.shape;
/** A position in millimetres, inside the bounds the document takes. */
@@ -107,7 +120,7 @@ export const bookPageOpSchema = z.discriminatedUnion('op', [
}),
z.literal('new'),
]),
fit: photo.fit.unwrap().optional(),
fit: bookPhotoFitSchema.optional(),
focalX: unit01.optional(),
focalY: unit01.optional(),
}),
@@ -116,12 +129,12 @@ export const bookPageOpSchema = z.discriminatedUnion('op', [
.strictObject({
op: z.literal('photo_look'),
elementId: bookElementIdSchema,
fit: photo.fit.unwrap().optional(),
fit: bookPhotoFitSchema.optional(),
focalX: unit01.optional(),
focalY: unit01.optional(),
mask: photo.mask.unwrap().optional(),
frameStyle: photo.frameStyle.unwrap().optional(),
filter: photo.filter.unwrap().optional(),
mask: bookPhotoMaskSchema.optional(),
frameStyle: bookFrameStyleSchema.optional(),
filter: bookPhotoFilterSchema.optional(),
radius: z.number().finite().min(0).max(500).optional(),
})
.refine((v) => has(v, LOOK_KEYS), { message: 'name at least one of the look fields' }),
@@ -138,9 +151,12 @@ export const bookPageOpSchema = z.discriminatedUnion('op', [
.strictObject({
op: z.literal('text'),
elementId: bookElementIdSchema.optional(),
role: z.enum(['heading', 'body', 'meta']).optional(),
role: z
.enum(['heading', 'body', 'meta'])
.describe('heading is the largest type on the page, body the longest of the rest, meta the next.')
.optional(),
text: z.string().max(BOOK_OP_LIMITS.text).optional(),
binding: text.binding.unwrap().optional(),
binding: text.binding.unwrap().describe(`${BOOK_BINDING_HINT} Null unbinds.`).optional(),
...TEXT_STYLE_FIELDS,
})
.refine((v) => v.elementId !== undefined || v.role !== undefined, { message: 'name an elementId or a role' })
@@ -227,7 +243,7 @@ export const setBookCoverArgs = {
};
export const createBookArgs = {
title: z.string().max(MAX_BOOK_TITLE).optional(),
preset: bookPageSetupSchema.shape.preset.unwrap().optional(),
preset: bookPagePresetSchema.optional(),
};
/**
* A page as it was before a write, for the assistant's undo: `index` is where
@@ -276,14 +292,21 @@ export const restoreBookPageArgs = {
export const bookDesignIdSchema = z.string().regex(/^[\w-]{1,40}$/);
export const describeBookPageArgs = { spreadId: bookSpreadIdSchema, designId: bookDesignIdSchema.optional() };
export const listBookLayoutsArgs = { role: z.enum(['inner', 'single']).optional() };
export const listBookLayoutsArgs = {
role: z.enum(['inner', 'single']).describe('inner for double pages, single for the cover and back.').optional(),
};
export const suggestBookPhotosArgs = {
entryId: idSchema.optional(),
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
purpose: z.enum(['page', 'cover']).optional(),
purpose: z
.enum(['page', 'cover'])
.describe(
'cover prefers wide or square pictures with enough pixels for a whole cover (2400 px on the long edge); page is for an inner page.',
)
.optional(),
limit: z.number().int().min(1).max(PHOTO_PICK.maxLimit).optional(),
};
@@ -295,7 +318,7 @@ const pageNumbers = bookPageNumbersSchema.shape;
* answer carries `previous_setup` to set it back.
*/
export const setBookSetupArgs = {
preset: bookPageSetupSchema.shape.preset.unwrap().optional(),
preset: bookPagePresetSchema.optional(),
pageWidth: z.number().finite().min(60).max(500).optional(),
pageHeight: z.number().finite().min(60).max(500).optional(),
bleed: z.number().finite().min(0).max(20).optional(),
@@ -304,12 +327,15 @@ export const setBookSetupArgs = {
.strictObject({
show: pageNumbers.show.unwrap().optional(),
startAt: pageNumbers.startAt.unwrap().optional(),
position: pageNumbers.position.unwrap().optional(),
position: pageNumbers.position
.unwrap()
.describe('outer edge, inner edge by the fold, or centre of the foot.')
.optional(),
margin: z.number().finite().min(0).max(60).optional(),
size: pageNumbers.size.unwrap().optional(),
autoColor: pageNumbers.autoColor.unwrap().optional(),
color: pageNumbers.color.unwrap().optional(),
font: pageNumbers.font.unwrap().optional(),
font: bookFontIdSchema.optional(),
})
.optional(),
/** The assistant's undo sets the setup back only while the book is still at the version the change left. */
+112
View File
@@ -0,0 +1,112 @@
import { normalizeBookDocument } from './book-normalize';
import {
BOOK_SCHEMA_REV,
bookSchemaRev,
isBookNewerThanReader,
readBookDocument,
stampBookSchemaRev,
} from './book-schema-rev';
import { bookSaveRequestSchema } from './book-store.schema';
import { bookDocumentSchema } from './book.schema';
import { describe, expect, it } from 'vitest';
// BKREV-001 to BKREV-007
/**
* The reader guard: a document says which revision of the format wrote it, a
* reader older than that opens it without saving it, and a book saved today
* stores exactly what it stored before the field existed.
*/
const frame = { x: 12, y: 18, w: 60, h: 40 };
const doc = (over: Record<string, unknown> = {}) => ({
version: 1,
title: 'Lisbon',
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
spreads: [
{
id: 's1',
role: 'inner',
background: null,
entryId: null,
elements: [{ id: 't1', kind: 'text', frame, text: 'Alfama' }],
parked: [],
},
],
...over,
});
describe('the revision a document was written by', () => {
it('BKREV-001: reads 1 for every document from before the field, and for anything that is not a whole number', () => {
expect(BOOK_SCHEMA_REV).toBe(1);
expect(bookSchemaRev(doc())).toBe(1);
expect(bookSchemaRev(null)).toBe(1);
expect(bookSchemaRev('{"schemaRev":4}')).toBe(1);
for (const odd of [0, -2, 2.5, '3', Number.NaN, null]) expect(bookSchemaRev(doc({ schemaRev: odd }))).toBe(1);
expect(bookSchemaRev(doc({ schemaRev: 3 }))).toBe(3);
});
it('BKREV-002: keeps the revision through the contract, and lets a broken one fall back without losing the book', () => {
expect(bookDocumentSchema.parse(doc({ schemaRev: 2 })).schemaRev).toBe(2);
const odd = bookDocumentSchema.safeParse(doc({ schemaRev: 'two' }));
expect(odd.success).toBe(true);
expect(odd.success && odd.data.schemaRev).toBeUndefined();
expect(odd.success && odd.data.spreads[0]?.elements).toHaveLength(1);
});
it('BKREV-003: leaves a book saved today as it was, with no revision written into it', () => {
const today = normalizeBookDocument(doc());
expect('schemaRev' in today).toBe(false);
expect(stampBookSchemaRev(today)).toBe(today);
expect(JSON.stringify(stampBookSchemaRev(today))).toBe(JSON.stringify(today));
});
});
describe('a reader older than the document', () => {
it('BKREV-004: knows the document is newer than it, read from the raw value even when the rest will not parse', () => {
expect(isBookNewerThanReader(doc({ schemaRev: 2 }))).toBe(true);
expect(isBookNewerThanReader(doc({ schemaRev: 1 }))).toBe(false);
expect(isBookNewerThanReader(doc())).toBe(false);
// A reader of a later revision reads the same document as its own.
expect(isBookNewerThanReader(doc({ schemaRev: 2 }), 2)).toBe(false);
const unreadable = doc({ schemaRev: 5, page: 'not a page' });
expect(isBookNewerThanReader(unreadable)).toBe(true);
});
it('BKREV-005: reads a newer book as far as it can and says so, so nothing it dropped is saved', () => {
const sticker = { id: 'x1', kind: 'sticker', frame, stickerId: 'lemon' };
const raw = doc({ schemaRev: 2 });
const spread = (raw.spreads as { elements: unknown[] }[])[0];
if (spread) spread.elements.push(sticker);
const read = readBookDocument(raw);
expect(read.newer).toBe(true);
expect(read.document.schemaRev).toBe(2);
expect(read.document.spreads[0]?.elements.map((e) => e.id)).toEqual(['t1']);
expect(readBookDocument(doc()).newer).toBe(false);
});
it('BKREV-006: a writer stamps its own revision once that is above 1, and never lowers one', () => {
const today = normalizeBookDocument(doc());
expect(stampBookSchemaRev(today, 2).schemaRev).toBe(2);
const later = normalizeBookDocument(doc({ schemaRev: 3 }));
expect(stampBookSchemaRev(later, 2)).toBe(later);
expect(stampBookSchemaRev(later, 2).schemaRev).toBe(3);
});
});
describe('a save request carrying a newer book', () => {
it('BKREV-007: reaches the server as its revision alone, so the save is refused there rather than at the pipe', () => {
const sticker = { id: 'x1', kind: 'sticker', frame, stickerId: 'lemon' };
const raw = doc({ schemaRev: 2 });
const spread = (raw.spreads as { elements: unknown[] }[])[0];
if (spread) spread.elements.push(sticker);
expect(bookSaveRequestSchema.parse({ title: 'T', document: raw }).document).toEqual({ schemaRev: 2 });
// A book of today still reads whole, revision or none.
const today = bookSaveRequestSchema.parse({ title: 'T', document: doc() }).document;
expect(today).toEqual(bookDocumentSchema.parse(doc()));
expect(bookSaveRequestSchema.parse({ document: doc({ schemaRev: 1 }) }).document).toMatchObject({ schemaRev: 1 });
expect(bookSaveRequestSchema.safeParse({ document: { schemaRev: 1, spreads: 'no' } }).success).toBe(false);
});
});
+76
View File
@@ -0,0 +1,76 @@
import { normalizeBookDocument } from './book-normalize';
import type { BookDocument } from './book.schema';
/**
* Which revision of the book format a document was written by, and what a
* reader does with one that is newer than it knows.
*
* ── Why a revision beside `version` ──────────────────────────────────────
*
* `version` is the format itself and stays 1: every document ever saved
* reads under it, and nothing here asks for a migration. What grows is the
* vocabulary inside it, a new element kind, a new font, a new field the
* renderer draws, and an older reader cannot tell any of those from noise.
* `normalizeBookDocument` drops an element it cannot read and the contract
* strips a field it does not know, both on purpose, so a damaged book still
* opens. Saving what is left is the part that must not happen: an old tab, an
* offline copy or a replayed request would write the loss down for everyone.
*
* So the document says which revision wrote it. A reader that meets a newer
* one opens it read-only and asks for a reload, and the server refuses a save
* from a writer older than the book it would replace.
*
* ── When to bump ─────────────────────────────────────────────────────────
*
* `BOOK_SCHEMA_REV` goes up by one in the change that adds an element kind,
* an enum value or a drawn field to book.schema.ts, never for a change an
* older reader survives whole. A writer stamps its own revision on what it
* saves only once that is above 1, so a book saved today stores exactly what
* it stored before this existed.
*/
export const BOOK_SCHEMA_REV = 1;
/** The `code` a refused save carries in its body when the stored book is newer than the writer. */
export const BOOK_SCHEMA_NEWER_CODE = 'book_schema_newer';
/**
* The revision a document says it was written by, read from the raw value
* rather than the parsed one: a document so new that it fails to parse whole
* still says how new it is. Anything that is not a whole number from 1 up,
* and every document from before the field, reads as 1.
*/
export function bookSchemaRev(raw: unknown): number {
if (!raw || typeof raw !== 'object') return 1;
const rev = (raw as { schemaRev?: unknown }).schemaRev;
return typeof rev === 'number' && Number.isInteger(rev) && rev >= 1 ? rev : 1;
}
/** Whether a document was written by a newer revision than `readerRev` (this build's by default). */
export function isBookNewerThanReader(raw: unknown, readerRev: number = BOOK_SCHEMA_REV): boolean {
return bookSchemaRev(raw) > readerRev;
}
/**
* The document as a writer of `writerRev` saves it: stamped with that
* revision, unless it already says as much or more. A document at revision 1
* is handed back as it is, so nothing changes on disk until the first bump.
*/
export function stampBookSchemaRev<T extends BookDocument>(doc: T, writerRev: number = BOOK_SCHEMA_REV): T {
return bookSchemaRev(doc) >= writerRev ? doc : { ...doc, schemaRev: writerRev };
}
/** A stored document as a reader holds it: normalised, and whether it was written by a newer revision. */
export interface BookRead {
document: BookDocument;
/** True when the document is newer than the reader: show it, never save it. */
newer: boolean;
}
/**
* Read a stored document the way normalizeBookDocument does, and say whether
* it is newer than this reader. The normalised copy of a newer book has lost
* what this build cannot read, which is why `newer` has to travel with it.
*/
export function readBookDocument(raw: unknown, readerRev: number = BOOK_SCHEMA_REV): BookRead {
return { document: normalizeBookDocument(raw), newer: isBookNewerThanReader(raw, readerRev) };
}
+16 -1
View File
@@ -1,3 +1,4 @@
import { BOOK_SCHEMA_REV } from './book-schema-rev';
import { bookDocumentSchema } from './book.schema';
import { z } from 'zod';
@@ -42,9 +43,23 @@ export const bookRecordSchema = bookSummarySchema.extend({
});
export type BookRecord = z.infer<typeof bookRecordSchema>;
/**
* A document a newer TREK wrote, as far as a save request carries it: only the
* revision. Its new element kinds or values would fail `bookDocumentSchema` at
* the pipe and end in a 400, so it is taken first and handed to the service
* this bare, where the save is refused with 409 `book_schema_newer` before
* anything is written. Nothing of the rest is needed for that refusal.
*/
const newerBookDocumentSchema = z.object({
schemaRev: z
.number()
.int()
.min(BOOK_SCHEMA_REV + 1),
});
export const bookSaveRequestSchema = z.object({
title: z.string().max(200).default(''),
document: bookDocumentSchema,
document: z.union([newerBookDocumentSchema, bookDocumentSchema]),
/**
* The version this edit was made against.
*
+1 -1
View File
@@ -1,3 +1,4 @@
import { normalizeBookDocument } from './book-normalize';
import {
MAX_BOOK_LAYOUTS,
MAX_SPREAD_ELEMENTS,
@@ -7,7 +8,6 @@ import {
bookSpreadSchema,
bookStatsElementSchema,
bookTextElementSchema,
normalizeBookDocument,
} from './book.schema';
import { describe, expect, it } from 'vitest';
+2 -60
View File
@@ -936,6 +936,8 @@ export type BookLayout = z.infer<typeof bookLayoutSchema>;
export const bookDocumentSchema = z.object({
/** In the document, not in a column: a format bump must not need a migration. */
version: z.literal(1).catch(1).default(1),
/** The format revision that wrote it (book-schema-rev.ts); absent, or unreadable and so dropped, reads as 1. */
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([]),
@@ -943,63 +945,3 @@ export const bookDocumentSchema = z.object({
layouts: z.array(bookLayoutSchema).max(MAX_BOOK_LAYOUTS).optional(),
});
export type BookDocument = z.infer<typeof bookDocumentSchema>;
/**
* Read a stored document without ever throwing.
*
* Same shape as `normalizeAppearance`: a book that cannot be parsed must still
* open — an editor that refuses to load someone's work because one field drifted
* is worse than one that drops the field.
*
* ── Why there are two attempts ───────────────────────────────────────────
*
* Dropping *the field* was the intent; dropping the whole book was what the
* code did. `elements` is an array of a discriminated union, so one element the
* contract cannot read fails its spread, fails the array, fails the document —
* and this function then handed back an empty book. It is not hypothetical: a
* mood mark carried the journal's key in a field capped at two characters, so
* every book with one on a page refused to save, and would have opened blank.
* Both the client's load seam and the server's read *and* write path run
* through here, which means the empty book was also what got written down.
*
* So a document that fails whole is tried again with the unreadable elements
* removed, and only a document that fails even then is given up on. Losing one
* decoration to a version skew is a shrug; losing the book is not.
*/
export function normalizeBookDocument(raw: unknown): BookDocument {
const parsed = bookDocumentSchema.safeParse(raw);
if (parsed.success) return parsed.data;
const salvaged = bookDocumentSchema.safeParse(withoutUnreadableElements(raw));
if (salvaged.success) return salvaged.data;
return bookDocumentSchema.parse({});
}
function withoutUnreadableElements(raw: unknown): unknown {
if (!raw || typeof raw !== 'object') return raw;
const doc = raw as { spreads?: unknown };
if (!Array.isArray(doc.spreads)) return raw;
const readable = (list: unknown) =>
Array.isArray(list)
? // Trimmed to the cap as well as filtered: a spread that grew past 60
// fails the array the same way an unreadable element fails the union, and
// losing the sixty-first decoration beats losing the book.
list.filter((el) => bookElementSchema.safeParse(el).success).slice(0, MAX_SPREAD_ELEMENTS)
: list;
const layouts = (doc as { layouts?: unknown }).layouts;
return {
...doc,
// A saved layout is a convenience: one that no longer reads is dropped, not the book.
...(Array.isArray(layouts)
? { layouts: layouts.filter((l) => bookLayoutSchema.safeParse(l).success).slice(0, MAX_BOOK_LAYOUTS) }
: {}),
spreads: doc.spreads.map((sp) => {
if (!sp || typeof sp !== 'object') return sp;
const spread = sp as { elements?: unknown; parked?: unknown };
return { ...spread, elements: readable(spread.elements), parked: readable(spread.parked) };
}),
};
}
+76
View File
@@ -188,6 +188,82 @@ describe('findings', () => {
expect(codes(list)).not.toContain('severe:overlap');
});
it('BOOKDESC-025: takes photos laid over each other at an angle, or in a frame, as layered on purpose', () => {
const under = photo('p-a', 1, { x: 230, y: 20, w: 80, h: 60 });
const over = { x: 260, y: 40, w: 80, h: 60 };
for (const look of [{ rotation: 4 }, { rotation: -3 }, { rotation: 357 }, { frameStyle: 'polaroid' as const }]) {
const list = findings([under, photo('p-b', 1, over, look)]);
expect(
list.find((f) => f.code === 'overlap'),
JSON.stringify(look),
).toMatchObject({
severity: 'info',
elementIds: ['p-a', 'p-b'],
});
}
// A frame on the lower picture does not make the upper one look laid down.
const framedUnder = photo('p-a', 1, { x: 230, y: 20, w: 80, h: 60 }, { frameStyle: 'white' });
expect(codes(findings([framedUnder, photo('p-b', 1, over)]))).toContain('severe:overlap');
// Framed as the journal style frames every photo, but four fifths covered: an accident all the same.
const stacked = photo('p-b', 1, { x: 238, y: 26, w: 80, h: 60 }, { frameStyle: 'white', rotation: 4 });
expect(codes(findings([photo('p-a', 1, { x: 230, y: 20, w: 80, h: 60 }), stacked]))).toContain('severe:overlap');
});
it('BOOKDESC-026: keeps an accident severe: two straight photos of a size, under 3 degrees off, a full turn included', () => {
const under = photo('p-a', 1, { x: 230, y: 20, w: 80, h: 60 }, { rotation: 1.5 });
// 2 degrees is the scrapbook style's own tilt, so it does not make a pair layered on its own.
for (const rotation of [0, -1, 2, -2.5, 359, 720]) {
const list = findings([under, photo('p-b', 1, { x: 260, y: 40, w: 80, h: 60 }, { rotation })]);
expect(codes(list), String(rotation)).toContain('severe:overlap');
}
});
it('BOOKDESC-027: sets a small line over display type as context, over running text or a text of a size as a fault', () => {
const title = text('t-big', 'Lisbon', { x: 230, y: 40, w: 160, h: 60 }, { size: 60 });
const accent = text('t-over', 'travel', { x: 240, y: 70, w: 40, h: 12 });
expect(findings([title, accent]).find((f) => f.code === 'overlap')).toMatchObject({
severity: 'info',
elementIds: ['t-over', 't-big'],
});
// The same small line on a story block prints words over words.
const story = text('t-story', 'A long day on the ice, and the harbour after it. '.repeat(3).trim(), {
x: 230,
y: 40,
w: 160,
h: 60,
});
expect(findings([story, accent]).find((f) => f.code === 'overlap')).toMatchObject({ severity: 'severe' });
const twin = text('t-twin', 'Porto', { x: 240, y: 60, w: 160, h: 60 });
expect(codes(findings([title, twin]))).toContain('severe:overlap');
const tilted = text('t-tilt', 'Porto', { x: 310, y: 90, w: 80, h: 30 }, { rotation: -6 });
expect(codes(findings([title, tilted]))).toContain('info:overlap');
});
it('BOOKDESC-028: never compares a title over a photo, or a photo over a shape', () => {
const shape = bookShapeElementSchema.parse({
id: 's-block',
kind: 'shape',
frame: { x: 220, y: 10, w: 120, h: 90 },
});
const list = findings([
shape as BookElement,
photo('p-a', 1, { x: 230, y: 20, w: 80, h: 60 }),
text('t-a', 'Over it', { x: 235, y: 30, w: 70, h: 20 }),
]);
expect(list.filter((f) => f.code === 'overlap')).toEqual([]);
});
it('BOOKDESC-029: reports an accident on a busy layered page, however many insets come before it', () => {
const hero = photo('p-hero', 1, { x: 230, y: 20, w: 180, h: 170 });
const insets = Array.from({ length: 8 }, (_, i) =>
photo(`p-in-${i}`, 1, { x: 235 + (i % 4) * 42, y: 30 + Math.floor(i / 4) * 40, w: 20, h: 15 }),
);
const pair = [photo('p-a', 1, { x: 20, y: 20, w: 80, h: 60 }), photo('p-b', 1, { x: 50, y: 40, w: 80, h: 60 })];
const overlaps = findings([hero, ...insets, ...pair]).filter((f) => f.code === 'overlap');
expect(overlaps).toHaveLength(6);
expect(overlaps.find((f) => f.severity === 'severe')?.elementIds).toEqual(['p-a', 'p-b']);
});
it('BOOKDESC-013: puts severe first and stops at the cap', () => {
// 45 texts past the trim, each too small for its words: 90 severe findings before the cap.
const many = Array.from({ length: 45 }, (_, i) => text(`t-${i}`, 'Cut', { x: -10, y: 5 + i * 4, w: 40, h: 3 }));
@@ -8,6 +8,7 @@ import {
MAX_SPREADS,
MAX_SPREAD_ELEMENTS,
bookDocumentSchema,
bookPhotoElementSchema,
bookTextElementSchema,
type BookDocument,
type BookElement,
@@ -761,6 +762,33 @@ describe('design_book', () => {
const more = { ...cut, text: `${cut.text} A long day on the ice.` };
expect(textHeightNeeded(more, DESIGN_TEXT_HEADROOM)).toBeGreaterThan(frame.h);
});
it('BOOKDSN-031: pulls two photos apart that lie mostly on top of each other, framed as the journal style frames them', () => {
const page = input('rich').page;
const look = designLook(bookDesignDirectivesSchema.parse({ style: 'journal' }), page, []);
const framed = (id: string, x: number, y: number) =>
bookPhotoElementSchema.parse({
id,
kind: 'photo',
frame: { x, y, w: 80, h: 60 },
photoId: 1,
frameStyle: 'white',
});
const spread: BookSpread = {
id: 'sp-in',
role: 'inner',
background: '#ffffff',
elements: [framed('p-a', 230, 20), framed('p-b', 238, 26)],
parked: [],
entryId: null,
};
const photos = [{ photoId: 1, width: 4000, height: 3000 }];
expect(pageFindings(spread, page, photos).map((f) => `${f.severity}:${f.code}`)).toContain('severe:overlap');
let n = 0;
const fixed = repairSpread(spread, { page, photos, look, newId: (p) => `${p}-${++n}` });
expect(fixed.findings.filter((f) => f.code === 'overlap')).toEqual([]);
expect(fixed.spread.elements.find((e) => e.id === 'p-b')?.frame.y).toBe(82);
});
});
/** Photos of one shape, ids counted from `from`. */
+5 -2
View File
@@ -180,7 +180,10 @@ export function textEl(c: ComposeCtx, at: TextAt, words: string, role: TextRole,
return { ...el, frame: frameOf(at.x, at.y, at.w, textHeightNeeded(el, DESIGN_TEXT_HEADROOM) + 1.2) };
}
/** A heading that fits `maxH`: set smaller, down to the sub size, until it does. */
/**
* A heading that fits `maxH`: set at `opts.size` (the heading size when none
* is given), then smaller, down to the sub size, until it does.
*/
export function headingEl(
c: ComposeCtx,
at: TextAt,
@@ -188,7 +191,7 @@ export function headingEl(
maxH: number,
opts: TextOpts = {},
): BookTextElement {
let size = c.look.sizes.heading;
let size = opts.size ?? c.look.sizes.heading;
let el = textEl(c, at, words, 'heading', { ...opts, size });
while (el.frame.h > maxH && size > c.look.sizes.sub) {
size = Math.max(c.look.sizes.sub, size - 2);
@@ -1,6 +1,7 @@
import { idSchema } from '../../common/primitives.schema';
import { bookFontIdSchema, bookMetricSchema, bookPagePresetSchema } from '../book-op-enums';
import { bookCoverTemplateIdSchema } from '../book-ops.schema';
import { BOOK_FONTS_IDS, BOOK_METRICS, MAX_BOOK_TITLE, bookPageSetupSchema } from '../book.schema';
import { MAX_BOOK_TITLE } from '../book.schema';
import { z } from 'zod';
@@ -21,17 +22,31 @@ export const BOOK_DESIGN_STYLES = ['classic', 'minimal', 'editorial', 'journal',
export type BookDesignStyle = (typeof BOOK_DESIGN_STYLES)[number];
export const bookDesignDirectivesSchema = z.strictObject({
style: z.enum(BOOK_DESIGN_STYLES).default('classic'),
style: z
.enum(BOOK_DESIGN_STYLES)
.describe(
'The look: classic (white, sans, one warm accent), minimal (white, Inter, grey), editorial (cream, Playfair over Garamond), journal (warm paper, framed photos), bold (black paper, Bebas caps, strong accent), scrapbook (cream paper, MuseoModerno headings, polaroids turned a little).',
)
.default('classic'),
/** Photos per spread: airy 1 to 2, balanced 2 to 4, rich 4 to 6. */
density: z.enum(['airy', 'balanced', 'rich']).default('balanced'),
density: z
.enum(['airy', 'balanced', 'rich'])
.describe('Photos per spread: airy 1 to 2, balanced 2 to 4, rich 4 to 6.')
.default('balanced'),
/** Inner spreads, the covers and the first and last page not counted. */
pages: z.strictObject({ min: pageCount.optional(), max: pageCount.optional() }).optional(),
scope: z.strictObject({ entryIds: ids200.optional(), from: isoDate.optional(), to: isoDate.optional() }).optional(),
chapters: z.enum(['entry', 'day', 'place']).default('entry'),
chapters: z
.enum(['entry', 'day', 'place'])
.describe('What a chapter is: one journal entry, one day or one place.')
.default('entry'),
photos: z
.strictObject({
/** best: the photo-pick groups' best, near duplicates and blurry ones left out; all: every photo. */
pick: z.enum(['best', 'all']).default('best'),
pick: z
.enum(['best', 'all'])
.describe('best leaves out near duplicates and blurry shots; all takes every photo.')
.default('best'),
perChapterMax: z.number().int().min(1).max(24).optional(),
include: ids300.optional(),
exclude: ids300.optional(),
@@ -47,17 +62,51 @@ export const bookDesignDirectivesSchema = z.strictObject({
.optional(),
type: z
.strictObject({
heading: z.enum(BOOK_FONTS_IDS).optional(),
body: z.enum(BOOK_FONTS_IDS).optional(),
scale: z.number().min(0.8).max(1.3).optional(),
heading: bookFontIdSchema.optional(),
body: bookFontIdSchema.optional(),
/** Every type size at once, 1 the style's own: under 1 a quieter book, up to 2.5 a poster. */
scale: z
.number()
.min(0.8)
.max(2.5)
.describe('Every type size at once: 1 as the style sets it, 2.5 a poster.')
.optional(),
/**
* The cover title as a multiple of the heading size, on top of `scale`:
* 1, the default, sets it as every book so far was set; 6.5 takes the
* 28 pt heading to about 180 pt, a title that fills the cover, so the
* cover grows without the rest of the book. Words that would not fit
* the cover are set smaller.
*/
display: z
.number()
.min(1)
.max(6.5)
.describe(
'The cover title as a multiple of the heading size: 1 as before, up to 6.5 (about 180 pt) for a title that fills the cover.',
)
.optional(),
})
.optional(),
/** The book's colours: taken from the photos, ink only, or given; the style's own when absent. */
palette: z.union([z.enum(['from_photos', 'ink']), z.strictObject({ paper: hex, ink: hex, accent: hex })]).optional(),
palette: z
.union([
z
.enum(['from_photos', 'ink'])
.describe('from_photos takes the accent from the pictures; ink sets every colour in the ink.'),
z.strictObject({ paper: hex, ink: hex, accent: hex }),
])
.optional(),
text: z
.strictObject({
captions: z.enum(['none', 'entry', 'photo']).default('entry'),
story: z.enum(['full', 'excerpt', 'none']).default('full'),
captions: z
.enum(['none', 'entry', 'photo'])
.describe('Words under the pictures: none, the entry title, or each photo caption.')
.default('entry'),
story: z
.enum(['full', 'excerpt', 'none'])
.describe('The entry story in full, cut to excerptChars, or left out.')
.default('full'),
excerptChars: z.number().int().min(80).max(1200).optional(),
})
.optional(),
@@ -67,13 +116,16 @@ export const bookDesignDirectivesSchema = z.strictObject({
countries: z.boolean().default(true),
chapterMaps: z.boolean().default(false),
dayBadges: z.boolean().default(true),
metrics: z.array(z.enum(BOOK_METRICS)).min(1).max(7).optional(),
metrics: z.array(bookMetricSchema).min(1).max(7).optional(),
})
.optional(),
/** What stays: pages holding a locked element, also hand-made pages, or nothing (a fresh book). */
keep: z.enum(['nothing', 'locked', 'edited']).default('locked'),
keep: z
.enum(['nothing', 'locked', 'edited'])
.describe('What stays: nothing, pages holding a locked element, or also the pages made by hand.')
.default('locked'),
/** Only honoured for a blank or a new book. */
preset: bookPageSetupSchema.shape.preset.unwrap().optional(),
preset: bookPagePresetSchema.optional(),
seed: z.number().int().min(0).max(2147483647).optional(),
});
export type BookDesignDirectives = z.infer<typeof bookDesignDirectivesSchema>;
+7 -2
View File
@@ -48,7 +48,11 @@ interface CoverWords {
ownSubtitle?: string;
}
/** The cover: a photo to the bleed with the title on a scrim at its foot, or the title alone on the paper. */
/**
* The cover: a photo to the bleed with the title on a scrim at its foot, or
* the title alone on the paper. The title starts at the display size and is
* set smaller only as far as its box needs.
*/
export function composeCover(c: ComposeCtx, words: CoverWords, photo: AutoPhoto | null): BookSpread {
const { pageWidth: PW, pageHeight: H, bleed: b, margin: m } = c.grid;
const els: BookElement[] = [];
@@ -64,7 +68,8 @@ export function composeCover(c: ComposeCtx, words: CoverWords, photo: AutoPhoto
const y = photo ? box.y : box.y + box.h * 0.35;
if (title.words.trim()) {
const binding = title.bound ? { source: 'journey.title' as const } : null;
const head = headingEl(c, { x: box.x, y, w: box.w }, title.words, box.h * 0.6, { binding, color: colour });
const size = c.look.sizes.display;
const head = headingEl(c, { x: box.x, y, w: box.w }, title.words, box.h * 0.6, { binding, color: colour, size });
els.push(head);
if (sub.words.trim()) {
const subBinding = sub.bound ? { source: 'journey.subtitle' as const } : null;
@@ -0,0 +1,80 @@
import { bookPageSetupSchema, type BookDocument, type BookTextElement } from '../book.schema';
import { designBook, type BookDesignInput } from './design-book';
import { bookDesignDirectivesSchema, type BookDesignDirectivesInput } from './design-directives.schema';
import { designLook } from './design-style';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
// BOOKSTY-001 to BOOKSTY-004
/**
* The type scale of a designed book: `type.scale` reaches a poster's sizes,
* the cover title has a display tier of its own, and a book designed without
* either is set exactly as before.
*/
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210 });
const lookOf = (directives: BookDesignDirectivesInput, on = page) =>
designLook(bookDesignDirectivesSchema.parse(directives), on, []);
const here = (file: string) => fileURLToPath(new URL(file, import.meta.url));
const fixtures = JSON.parse(readFileSync(here('./design-book.fixtures.json'), 'utf8')) as {
rich: { input: BookDesignInput };
};
/** The rich fixture journey, its ids minted by a counter so every run designs the same book. */
function rich(): BookDesignInput {
let n = 0;
return { ...fixtures.rich.input, newId: (p) => `${p}-${++n}` };
}
/** The cover's title: the text bound to the journey's title. */
function coverTitle(doc: BookDocument): BookTextElement | undefined {
const cover = doc.spreads.find((s) => s.role === 'cover');
return cover?.elements.find((e): e is BookTextElement => e.kind === 'text' && e.binding?.source === 'journey.title');
}
describe('the type scale', () => {
it('BOOKSTY-001: sets the cover title at the heading size when nothing asks for more, as before', () => {
const look = lookOf({ style: 'classic' });
expect(look.sizes).toEqual({ display: 28, heading: 28, sub: 14, body: 10, caption: 7.5, meta: 7 });
expect(lookOf({ style: 'classic', type: { scale: 1.3 } }).sizes.heading).toBe(36.4);
});
it('BOOKSTY-002: takes a scale up to 2.5 and a display tier up to 6.5, and nothing past either', () => {
const parse = (type: Record<string, number>) => bookDesignDirectivesSchema.safeParse({ type }).success;
expect([parse({ scale: 2.5 }), parse({ scale: 2.6 }), parse({ scale: 0.7 })]).toEqual([true, false, false]);
expect([parse({ display: 6.5 }), parse({ display: 6.6 }), parse({ display: 0.9 })]).toEqual([true, false, false]);
const poster = lookOf({ style: 'bold', type: { scale: 2.5, display: 2 } });
expect([poster.sizes.heading, poster.sizes.display, poster.sizes.body]).toEqual([70, 140, 25]);
// The cover tier alone carries a cover-filling title: body stays where the style sets it.
const cover = lookOf({ style: 'bold', type: { display: 6.5 } });
expect([cover.sizes.heading, cover.sizes.display, cover.sizes.body]).toEqual([28, 182, 10]);
});
it('BOOKSTY-003: never sets a size past what a text element takes, on the largest page either', () => {
const large = bookPageSetupSchema.parse({ preset: 'custom', pageWidth: 500, pageHeight: 500 });
const look = lookOf({ style: 'bold', type: { scale: 2.5, display: 3 } }, large);
expect(look.sizes.display).toBe(200);
expect(Math.max(...Object.values(look.sizes))).toBeLessThanOrEqual(200);
});
});
describe('the cover title', () => {
it('BOOKSTY-004: starts at the display size and comes out larger than the heading, still inside its box', () => {
const plain = coverTitle(designBook(null, rich(), { style: 'classic' }).doc);
const big = coverTitle(designBook(null, rich(), { style: 'classic', type: { display: 3 } }).doc);
const base = plain?.size ?? 0;
expect(base).toBeGreaterThan(0);
expect(big?.size ?? 0).toBeGreaterThan(base);
expect(big?.size ?? 0).toBeLessThanOrEqual(base * 3);
const largest = coverTitle(designBook(null, rich(), { style: 'classic', type: { display: 6.5 } }).doc);
expect(largest?.size ?? 0).toBeGreaterThanOrEqual(big?.size ?? 0);
expect(largest?.size ?? 0).toBeLessThanOrEqual(182);
expect((largest?.frame.y ?? 0) + (largest?.frame.h ?? 0)).toBeLessThanOrEqual(page.pageHeight);
// The box under the title is the foot of the cover, below its 60 % line.
expect((big?.frame.y ?? 0) + (big?.frame.h ?? 0)).toBeLessThanOrEqual(page.pageHeight);
});
});
+13 -5
View File
@@ -9,9 +9,12 @@ import type { BookDesignDirectives, BookDesignStyle } from './design-directives.
*
* At most two families per book; badges, figures, maps and lists take the
* heading face. Sizes are set for a 210 mm page and scale with the page
* width, times `type.scale`, never under what a reader can read. Every
* colour a text is set in is checked against the paper to WCAG contrast;
* a pair that fails falls back to the ink.
* width, times `type.scale`, never under what a reader can read and never
* over what a text element takes. The cover title has a tier of its own,
* `display`, the heading size times `type.display`: 1 by default, so a book
* designed without it is set exactly as before, and up to 6.5 for a title
* that fills the cover. Every colour a text is set in is checked against the paper
* to WCAG contrast; a pair that fails falls back to the ink.
*/
export interface BookDesignLook {
@@ -28,7 +31,8 @@ export interface BookDesignLook {
onScrim: string;
heading: BookFontFamily;
body: BookFontFamily;
sizes: { heading: number; sub: number; body: number; caption: number; meta: number };
/** Points. `display` is the cover title's, the heading's unless `type.display` raises it. */
sizes: { display: number; heading: number; sub: number; body: number; caption: number; meta: number };
/** The frame decoration and the slight turn a scrapbook gives its pictures. */
frameStyle: BookPhotoElement['frameStyle'];
tilt: number;
@@ -114,6 +118,8 @@ const PRESETS: Record<BookDesignStyle, StylePreset> = {
const BASE_SIZES = { heading: 28, sub: 14, body: 10, caption: 7.5, meta: 7 };
/** Never under these: running text reads from 7 pt, a caption or meta line from 5.5 pt (with room to spare). */
const FLOOR = { heading: 14, sub: 9, body: 8, caption: 7, meta: 6 };
/** Never over this: the largest size a text element takes (book.schema.ts). */
const MAX_PT = 200;
const round1 = (v: number) => Math.round(v * 10) / 10;
@@ -163,7 +169,8 @@ export function designLook(
const softer = mix(ink, colours.paper, 0.3);
const muted = contrastRatio(softer, colours.paper) >= 4.6 ? softer : ink;
const scale = (page.pageWidth / 210) * (directives.type?.scale ?? 1);
const size = (role: keyof typeof BASE_SIZES) => Math.max(FLOOR[role], round1(BASE_SIZES[role] * scale));
const size = (role: keyof typeof BASE_SIZES, times = 1) =>
Math.min(MAX_PT, Math.max(FLOOR[role], round1(BASE_SIZES[role] * scale * times)));
const heading = directives.type?.heading ?? preset.heading;
return {
style: directives.style,
@@ -177,6 +184,7 @@ export function designLook(
heading,
body: directives.type?.body ?? preset.body,
sizes: {
display: size('heading', directives.type?.display ?? 1),
heading: size('heading'),
sub: size('sub'),
body: size('body'),
+2
View File
@@ -677,6 +677,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'تدوير لليسار',
'journey.studio.rotateRight': 'تدوير لليمين',
'journey.studio.saveReadOnly': 'للقراءة فقط، لا يتم الحفظ',
'journey.studio.saveNewer': 'حفظ إصدار أحدث من Studio هذا الكتاب. أعد التحميل لتعديله.',
'journey.studio.saveReload': 'إعادة التحميل',
'journey.studio.filterPhotos': 'تصفية الصور',
'journey.studio.filterAll': 'كل الصور',
'journey.studio.filterLoose': 'ليست ضمن إدخال',
+2
View File
@@ -712,6 +712,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Sola fırlat',
'journey.studio.rotateRight': 'Sağa fırlat',
'journey.studio.saveReadOnly': 'Yalnız oxuma rejimi — yadda saxlanılmadı',
'journey.studio.saveNewer': 'Bu kitabı Studio-nun daha yeni versiyası saxlayıb. Redaktə etmək üçün yenidən yükləyin.',
'journey.studio.saveReload': 'Yenidən yüklə',
'journey.studio.filterPhotos': 'Fotoları filtrlə',
'journey.studio.filterAll': 'Bütün fotolar',
+2
View File
@@ -652,6 +652,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Girar à esquerda',
'journey.studio.rotateRight': 'Girar à direita',
'journey.studio.saveReadOnly': 'Somente leitura, nada é salvo',
'journey.studio.saveNewer': 'Uma versão mais recente do Studio salvou este livro. Recarregue para editar.',
'journey.studio.saveReload': 'Recarregar',
'journey.studio.filterPhotos': 'Filtrar fotos',
'journey.studio.filterAll': 'Todas as fotos',
'journey.studio.filterLoose': 'Sem entrada',
+2
View File
@@ -653,6 +653,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': "Gira cap a l'esquerra",
'journey.studio.rotateRight': 'Gira cap a la dreta',
'journey.studio.saveReadOnly': 'Només lectura, no es desa',
'journey.studio.saveNewer': 'Una versió més recent de Studio ha desat aquest llibre. Recarrega per editar-lo.',
'journey.studio.saveReload': 'Recarrega',
'journey.studio.filterPhotos': 'Filtra les fotos',
'journey.studio.filterAll': 'Totes les fotos',
'journey.studio.filterLoose': 'Sense entrada',
+2
View File
@@ -655,6 +655,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Otočit doleva',
'journey.studio.rotateRight': 'Otočit doprava',
'journey.studio.saveReadOnly': 'Jen ke čtení, neukládá se',
'journey.studio.saveNewer': 'Tuto knihu uložila novější verze aplikace Studio. Pro úpravy ji načtěte znovu.',
'journey.studio.saveReload': 'Načíst znovu',
'journey.studio.filterPhotos': 'Filtrovat fotografie',
'journey.studio.filterAll': 'Všechny fotografie',
'journey.studio.filterLoose': 'Bez záznamu',
+2
View File
@@ -654,6 +654,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Nach links drehen',
'journey.studio.rotateRight': 'Nach rechts drehen',
'journey.studio.saveReadOnly': 'Nur lesen, nichts wird gespeichert',
'journey.studio.saveNewer': 'Eine neuere Studio-Version hat dieses Buch gespeichert. Zum Bearbeiten neu laden.',
'journey.studio.saveReload': 'Neu laden',
'journey.studio.filterPhotos': 'Fotos filtern',
'journey.studio.filterAll': 'Alle Fotos',
'journey.studio.filterLoose': 'Keinem Eintrag zugeordnet',
+2
View File
@@ -648,6 +648,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Rotate left',
'journey.studio.rotateRight': 'Rotate right',
'journey.studio.saveReadOnly': 'Read-only, not saved',
'journey.studio.saveNewer': 'A newer Studio saved this book. Reload to edit it.',
'journey.studio.saveReload': 'Reload',
'journey.studio.filterPhotos': 'Filter photos',
'journey.studio.filterAll': 'All photos',
'journey.studio.filterLoose': 'Not in an entry',
+2
View File
@@ -652,6 +652,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Girar a la izquierda',
'journey.studio.rotateRight': 'Girar a la derecha',
'journey.studio.saveReadOnly': 'Solo lectura, no se guarda',
'journey.studio.saveNewer': 'Una versión más reciente de Studio guardó este libro. Recarga para editarlo.',
'journey.studio.saveReload': 'Recargar',
'journey.studio.filterPhotos': 'Filtrar fotos',
'journey.studio.filterAll': 'Todas las fotos',
'journey.studio.filterLoose': 'Sin entrada',
+2
View File
@@ -649,6 +649,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Pööra vasakule',
'journey.studio.rotateRight': 'Pööra paremale',
'journey.studio.saveReadOnly': 'Ainult lugemiseks, salvestamata',
'journey.studio.saveNewer': 'Selle raamatu salvestas Studio uuem versioon. Muutmiseks laadi leht uuesti.',
'journey.studio.saveReload': 'Laadi uuesti',
'journey.studio.filterPhotos': 'Filtreeri fotosid',
'journey.studio.filterAll': 'Kõik fotod',
'journey.studio.filterLoose': 'Sissekandega sidumata',
+2
View File
@@ -651,6 +651,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Pivoter à gauche',
'journey.studio.rotateRight': 'Pivoter à droite',
'journey.studio.saveReadOnly': "Lecture seule, rien n'est enregistré",
'journey.studio.saveNewer': 'Une version plus récente de Studio a enregistré ce livre. Rechargez pour le modifier.',
'journey.studio.saveReload': 'Recharger',
'journey.studio.filterPhotos': 'Filtrer les photos',
'journey.studio.filterAll': 'Toutes les photos',
'journey.studio.filterLoose': 'Sans entrée',
+2
View File
@@ -657,6 +657,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Περιστροφή αριστερά',
'journey.studio.rotateRight': 'Περιστροφή δεξιά',
'journey.studio.saveReadOnly': 'Μόνο για ανάγνωση, δεν αποθηκεύεται',
'journey.studio.saveNewer': 'Μια νεότερη έκδοση του Studio αποθήκευσε αυτό το βιβλίο. Φορτώστε ξανά για επεξεργασία.',
'journey.studio.saveReload': 'Επαναφόρτωση',
'journey.studio.filterPhotos': 'Φιλτράρισμα φωτογραφιών',
'journey.studio.filterAll': 'Όλες οι φωτογραφίες',
'journey.studio.filterLoose': 'Χωρίς καταχώρηση',
+2
View File
@@ -649,6 +649,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Forgatás balra',
'journey.studio.rotateRight': 'Forgatás jobbra',
'journey.studio.saveReadOnly': 'Csak olvasható, nem mentődik',
'journey.studio.saveNewer': 'Ezt a könyvet a Studio egy újabb verziója mentette. Töltsd újra a szerkesztéshez.',
'journey.studio.saveReload': 'Újratöltés',
'journey.studio.filterPhotos': 'Fotók szűrése',
'journey.studio.filterAll': 'Összes fotó',
'journey.studio.filterLoose': 'Nincs bejegyzésben',
+2
View File
@@ -641,6 +641,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Putar ke kiri',
'journey.studio.rotateRight': 'Putar ke kanan',
'journey.studio.saveReadOnly': 'Hanya baca, tidak disimpan',
'journey.studio.saveNewer': 'Buku ini disimpan oleh versi Studio yang lebih baru. Muat ulang untuk mengeditnya.',
'journey.studio.saveReload': 'Muat ulang',
'journey.studio.filterPhotos': 'Filter foto',
'journey.studio.filterAll': 'Semua foto',
'journey.studio.filterLoose': 'Tidak dalam entri',
+2
View File
@@ -651,6 +651,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Ruota a sinistra',
'journey.studio.rotateRight': 'Ruota a destra',
'journey.studio.saveReadOnly': 'Sola lettura, non si salva',
'journey.studio.saveNewer': 'Una versione più recente di Studio ha salvato questo libro. Ricarica per modificarlo.',
'journey.studio.saveReload': 'Ricarica',
'journey.studio.filterPhotos': 'Filtra foto',
'journey.studio.filterAll': 'Tutte le foto',
'journey.studio.filterLoose': 'Senza voce',
+3
View File
@@ -639,6 +639,9 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': '左に回転',
'journey.studio.rotateRight': '右に回転',
'journey.studio.saveReadOnly': '閲覧のみ・保存されません',
'journey.studio.saveNewer':
'この本は新しいバージョンの Studio で保存されました。編集するには再読み込みしてください。',
'journey.studio.saveReload': '再読み込み',
'journey.studio.filterPhotos': '写真を絞り込む',
'journey.studio.filterAll': 'すべての写真',
'journey.studio.filterLoose': 'エントリーに属さない',
+2
View File
@@ -636,6 +636,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': '왼쪽으로 회전',
'journey.studio.rotateRight': '오른쪽으로 회전',
'journey.studio.saveReadOnly': '읽기 전용, 저장되지 않음',
'journey.studio.saveNewer': '이 책은 더 새로운 버전의 Studio에서 저장되었습니다. 편집하려면 새로고침하세요.',
'journey.studio.saveReload': '새로고침',
'journey.studio.filterPhotos': '사진 필터',
'journey.studio.filterAll': '모든 사진',
'journey.studio.filterLoose': '항목에 없음',
+2
View File
@@ -652,6 +652,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Naar links draaien',
'journey.studio.rotateRight': 'Naar rechts draaien',
'journey.studio.saveReadOnly': 'Alleen lezen, wordt niet opgeslagen',
'journey.studio.saveNewer': 'Een nieuwere versie van Studio heeft dit boek opgeslagen. Herlaad om het te bewerken.',
'journey.studio.saveReload': 'Herladen',
'journey.studio.filterPhotos': "Foto's filteren",
'journey.studio.filterAll': "Alle foto's",
'journey.studio.filterLoose': 'Niet in een vermelding',
+2
View File
@@ -668,6 +668,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Obróć w lewo',
'journey.studio.rotateRight': 'Obróć w prawo',
'journey.studio.saveReadOnly': 'Tylko do odczytu, nie zapisuje się',
'journey.studio.saveNewer': 'Nowsza wersja Studio zapisała tę książkę. Odśwież, aby ją edytować.',
'journey.studio.saveReload': 'Odśwież',
'journey.studio.filterPhotos': 'Filtruj zdjęcia',
'journey.studio.filterAll': 'Wszystkie zdjęcia',
'journey.studio.filterLoose': 'Bez wpisu',
+2
View File
@@ -667,6 +667,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Повернуть влево',
'journey.studio.rotateRight': 'Повернуть вправо',
'journey.studio.saveReadOnly': 'Только чтение, не сохраняется',
'journey.studio.saveNewer': 'Эту книгу сохранила более новая версия Studio. Перезагрузите, чтобы редактировать.',
'journey.studio.saveReload': 'Перезагрузить',
'journey.studio.filterPhotos': 'Фильтр фото',
'journey.studio.filterAll': 'Все фото',
'journey.studio.filterLoose': 'Без записи',
+2
View File
@@ -660,6 +660,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Otočiť doľava',
'journey.studio.rotateRight': 'Otočiť doprava',
'journey.studio.saveReadOnly': 'Iba na čítanie, neukladá sa',
'journey.studio.saveNewer': 'Túto knihu uložila novšia verzia aplikácie Studio. Na úpravy ju načítajte znova.',
'journey.studio.saveReload': 'Načítať znova',
'journey.studio.filterPhotos': 'Filtrovať fotografie',
'journey.studio.filterAll': 'Všetky fotografie',
'journey.studio.filterLoose': 'Bez záznamu',
+2
View File
@@ -648,6 +648,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Rotera åt vänster',
'journey.studio.rotateRight': 'Rotera åt höger',
'journey.studio.saveReadOnly': 'Skrivskyddad, sparas inte',
'journey.studio.saveNewer': 'En nyare version av Studio har sparat den här boken. Ladda om för att redigera.',
'journey.studio.saveReload': 'Ladda om',
'journey.studio.filterPhotos': 'Filtrera foton',
'journey.studio.filterAll': 'Alla foton',
'journey.studio.filterLoose': 'Inte i något inlägg',
+2
View File
@@ -633,6 +633,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'หมุนไปทางซ้าย',
'journey.studio.rotateRight': 'หมุนไปทางขวา',
'journey.studio.saveReadOnly': 'อ่านอย่างเดียว ไม่ได้บันทึก',
'journey.studio.saveNewer': 'หนังสือเล่มนี้ถูกบันทึกโดย Studio เวอร์ชันที่ใหม่กว่า โปรดโหลดใหม่เพื่อแก้ไข',
'journey.studio.saveReload': 'โหลดใหม่',
'journey.studio.filterPhotos': 'กรองรูปภาพ',
'journey.studio.filterAll': 'รูปภาพทั้งหมด',
'journey.studio.filterLoose': 'ไม่อยู่ในรายการ',
+3
View File
@@ -650,6 +650,9 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Sola döndür',
'journey.studio.rotateRight': 'Sağa döndür',
'journey.studio.saveReadOnly': 'Salt okunur, kaydedilmiyor',
'journey.studio.saveNewer':
'Bu kitap Studio’nun daha yeni bir sürümüyle kaydedildi. Düzenlemek için yeniden yükleyin.',
'journey.studio.saveReload': 'Yeniden yükle',
'journey.studio.filterPhotos': 'Fotoğrafları filtrele',
'journey.studio.filterAll': 'Tüm fotoğraflar',
'journey.studio.filterLoose': 'Bir kayıtta değil',
+2
View File
@@ -671,6 +671,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Повернути ліворуч',
'journey.studio.rotateRight': 'Повернути праворуч',
'journey.studio.saveReadOnly': 'Лише для читання, не зберігається',
'journey.studio.saveNewer': 'Цю книгу збережено новішою версією Studio. Перезавантажте, щоб редагувати.',
'journey.studio.saveReload': 'Перезавантажити',
'journey.studio.filterPhotos': 'Фільтр фото',
'journey.studio.filterAll': 'Усі фото',
'journey.studio.filterLoose': 'Без запису',
+2
View File
@@ -640,6 +640,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Xoay trái',
'journey.studio.rotateRight': 'Xoay phải',
'journey.studio.saveReadOnly': 'Chỉ đọc, không lưu',
'journey.studio.saveNewer': 'Cuốn sách này đã được lưu bằng phiên bản Studio mới hơn. Tải lại để chỉnh sửa.',
'journey.studio.saveReload': 'Tải lại',
'journey.studio.filterPhotos': 'Lọc ảnh',
'journey.studio.filterAll': 'Tất cả ảnh',
'journey.studio.filterLoose': 'Không thuộc mục nào',
+2
View File
@@ -629,6 +629,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': '向左旋轉',
'journey.studio.rotateRight': '向右旋轉',
'journey.studio.saveReadOnly': '唯讀,不會儲存',
'journey.studio.saveNewer': '此書由較新版本的 Studio 儲存。請重新載入後再編輯。',
'journey.studio.saveReload': '重新載入',
'journey.studio.filterPhotos': '篩選照片',
'journey.studio.filterAll': '全部照片',
'journey.studio.filterLoose': '不屬於任何條目',
+2
View File
@@ -629,6 +629,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': '向左旋转',
'journey.studio.rotateRight': '向右旋转',
'journey.studio.saveReadOnly': '只读,不会保存',
'journey.studio.saveNewer': '此书由更新版本的 Studio 保存。请重新加载后再编辑。',
'journey.studio.saveReload': '重新加载',
'journey.studio.filterPhotos': '筛选照片',
'journey.studio.filterAll': '全部照片',
'journey.studio.filterLoose': '不属于任何条目',
+2
View File
@@ -116,6 +116,8 @@ export * from './journey/journey-recording-prep.schema';
export * from './journey/journey-map.schema';
export * from './journey/trip-journal-link.schema';
export * from './book/book.schema';
export * from './book/book-normalize';
export * from './book/book-schema-rev';
export * from './book/journey-stats.schema';
export * from './book/book-store.schema';
export * from './book/book-ids';
+4 -1
View File
@@ -71,7 +71,10 @@ sonar.coverage.exclusions=\
# server/src/db/kysely/ is generated from the migrated schema by
# server/scripts/generate-db-types.ts (one interface per table, CI checks it with
# check:db-types), so a column list two tables share is not copied code.
sonar.cpd.exclusions=shared/src/i18n/**,server/src/db/kysely/**
# shared/src/book/book-icon-names-*.data.ts is the lucide name list the book
# engine takes, generated by shared/scripts/gen-book-icon-names.mjs: three
# files of one quoted name per line, data rather than code.
sonar.cpd.exclusions=shared/src/i18n/**,server/src/db/kysely/**,shared/src/book/book-icon-names-*.data.ts
# The full analysis of a push to dev outgrows the JS/TS analyser's default heap
# of about 4 GB since the ORM migration and stops with "Analysis of JS/TS files