Compare commits

..

3 Commits

Author SHA1 Message Date
Maurice 473132fc07 fix(studio): a slider commits the value it was moved to, and the paint render test uses a real shape
- The opacity and paint sliders remember the value they were last moved to and commit that when let go, instead of reading the controlled input, which still shows the old value until the parent renders.
- The render test's old-fade shape is star-5; star is not a shape, so the whole fixture failed to parse.
2026-10-23 21:07:04 +02:00
Maurice e7a5316990 test(studio): a raced Studio save carries its revision, as the Studio's own saves do 2026-10-23 20:58:19 +02:00
Maurice 670c841866 feat(studio): paint shapes and pages with gradients, alpha and twelve patterns
- A shape and a page take a paint: one colour with its own alpha, a linear or
  radial gradient of two to six stops, or one of twelve patterns (checks,
  gingham, stripes, pinstripes, dots, polka dots, zigzag, waves, grid, scales,
  sunburst, confetti). fill, gradient and background keep the colour that
  stands for the paint, so an older reader still draws something close, and a
  plain colour or the old cover fade is stored in the old fields alone.
- Gradients render as SVG in user space with ids per mounted copy, so the two
  halves of a spread in page mode never share a defs id; patterns are drawn
  out as geometry clipped to the outline and capped at 2000 primitives.
- The new paint picker has none, solid, gradient and pattern tabs, a stop bar
  to click, drag and pull stops off, an angle dial, a radial centre pad and
  pattern tiles drawn by the page's own generator.
- Every swatch row gains the book's own colours, colours read from the
  page's photos, recent colours and, where it applies, an opacity slider.
- With nothing selected the inspector sets the page background, gives it to
  every page of its kind, and lays a photo under the whole page.
- The assistant's background operation takes a paint as well as a colour,
  style and add set a shape's paint, and describe_book_page reads both.
- BOOK_SCHEMA_REV goes to 2.
2026-10-23 20:10:36 +02:00
201 changed files with 6964 additions and 4137 deletions
-51
View File
@@ -53,54 +53,3 @@ own switch in the admin panel. https://cloud.google.com/maps-platform/terms
`server/assets/airports.json` is built from **OurAirports**
(https://ourairports.com/data/), released into the public domain.
## Typefaces: Studio books and the app
Every typeface a book can be set in, and the app's own (Poppins, MuseoModerno,
Geist), is self-hosted from the **@fontsource** npm packages, which redistribute
the families published on Google Fonts split into subsets by script. The catalogue of book faces is
`shared/src/book/book-fonts.data.ts`. Each package carries its licence and the
copyright notice of the family's authors in its `LICENSE` file.
Both licences allow a font to be bundled with software and embedded in a
document made with it, so a printed or exported book may carry the faces it is
set in. No family is renamed, redrawn or sold on its own. SIL OFL 1.1: https://openfontlicense.org. Apache 2.0:
https://www.apache.org/licenses/LICENSE-2.0
| Family | Package | Licence |
|---|---|---|
| Abril Fatface | `@fontsource/abril-fatface` | SIL Open Font License 1.1 |
| Alfa Slab One | `@fontsource/alfa-slab-one` | SIL Open Font License 1.1 |
| Allura | `@fontsource/allura` | SIL Open Font License 1.1 |
| Anton | `@fontsource/anton` | SIL Open Font License 1.1 |
| Archivo Black | `@fontsource/archivo-black` | SIL Open Font License 1.1 |
| Bebas Neue | `@fontsource/bebas-neue` | SIL Open Font License 1.1 |
| Big Shoulders Display | `@fontsource/big-shoulders-display` | SIL Open Font License 1.1 |
| Bodoni Moda | `@fontsource/bodoni-moda` | SIL Open Font License 1.1 |
| Bricolage Grotesque | `@fontsource/bricolage-grotesque` | SIL Open Font License 1.1 |
| Caveat | `@fontsource/caveat` | SIL Open Font License 1.1 |
| Cormorant Garamond | `@fontsource/cormorant-garamond` | SIL Open Font License 1.1 |
| Dancing Script | `@fontsource/dancing-script` | SIL Open Font License 1.1 |
| DM Mono | `@fontsource/dm-mono` | SIL Open Font License 1.1 |
| DM Serif Display | `@fontsource/dm-serif-display` | SIL Open Font License 1.1 |
| EB Garamond | `@fontsource/eb-garamond` | SIL Open Font License 1.1 |
| Fraunces | `@fontsource/fraunces` | SIL Open Font License 1.1 |
| Great Vibes | `@fontsource/great-vibes` | SIL Open Font License 1.1 |
| Homemade Apple | `@fontsource/homemade-apple` | Apache License 2.0 |
| Inter | `@fontsource/inter` | SIL Open Font License 1.1 |
| Kaushan Script | `@fontsource/kaushan-script` | SIL Open Font License 1.1 |
| League Gothic | `@fontsource/league-gothic` | SIL Open Font License 1.1 |
| Lora | `@fontsource/lora` | SIL Open Font License 1.1 |
| Montserrat | `@fontsource/montserrat` | SIL Open Font License 1.1 |
| MuseoModerno | `@fontsource/museomoderno` | SIL Open Font License 1.1 |
| Oswald | `@fontsource/oswald` | SIL Open Font License 1.1 |
| Pacifico | `@fontsource/pacifico` | SIL Open Font License 1.1 |
| Parisienne | `@fontsource/parisienne` | SIL Open Font License 1.1 |
| Permanent Marker | `@fontsource/permanent-marker` | Apache License 2.0 |
| Playfair Display | `@fontsource/playfair-display` | SIL Open Font License 1.1 |
| Poppins | `@fontsource/poppins` | SIL Open Font License 1.1 |
| Sacramento | `@fontsource/sacramento` | SIL Open Font License 1.1 |
| Satisfy | `@fontsource/satisfy` | Apache License 2.0 |
| Shrikhand | `@fontsource/shrikhand` | SIL Open Font License 1.1 |
| Yellowtail | `@fontsource/yellowtail` | Apache License 2.0 |
| Geist Sans | `@fontsource/geist-sans` | SIL Open Font License 1.1 |
-67
View File
@@ -1,67 +0,0 @@
import { BOOK_FONT_FACES, BOOK_FONTS_PRECACHED } from '@trek/shared';
/**
* Which font files the service worker installs with the app.
*
* The precache takes every woff and woff2 the build emits, and with the book's
* catalogue at thirty four families that is every weight, both styles and
* every script subset of each: megabytes fetched at install by every user,
* most of it for faces no book of theirs is set in. So the book's faces are
* held to this:
*
* - The faces the app itself draws (Poppins and MuseoModerno for the chrome,
* imported in src/main.tsx) stay exactly as they were.
* - The faces the book shipped with before the catalogue grew
* (`BOOK_FONTS_PRECACHED`) keep their latin woff2, so a book set in them
* still opens offline before Studio was ever opened online.
* - Every other file of a book family, woff, other subsets, new families,
* real italics, is left to the `book-fonts` runtime cache in vite.config.js,
* which the Studio fills with the faces of each book it opens
* (src/components/Studio/bookFontWarm.ts).
*
* Shared by vite.config.js and its test, which reads the filter against file
* names as the build writes them.
*/
/** The faces src/main.tsx imports for the app chrome, which the book shares; bookFontPrecache.test.ts checks this copy. */
export const APP_FACES = {
poppins: [300, 400, 500, 600, 700],
museomoderno: [400, 700, 800],
};
/** The book packages, longest name first, so `dm-serif-display` is never read as `dm-…`. */
const BOOK_PACKAGES = [...new Set(Object.values(BOOK_FONT_FACES).map((f) => f.pkg))].sort(
(a, b) => b.length - a.length
);
const PRECACHED = new Map(
Object.entries(BOOK_FONTS_PRECACHED).map(([id, weights]) => [BOOK_FONT_FACES[id].pkg, new Set(weights)])
);
/**
* A font file as the build names it, `<package>-<subset>-<weight>-<style>-<hash>.<ext>`,
* read against the book's packages; null for any other file.
*/
export function bookFontFile(url) {
const name = url.split('/').pop() ?? '';
const match = /^(.+)-(\d{3})-(normal|italic)-[\w-]+\.(woff2?)$/.exec(name);
if (!match) return null;
const [, head, weight, style, ext] = match;
const pkg = BOOK_PACKAGES.find((p) => head.startsWith(`${p}-`));
if (!pkg) return null;
return { pkg, subset: head.slice(pkg.length + 1), weight: Number(weight), italic: style === 'italic', ext };
}
/** Whether a file of the build goes into the install-time precache. */
export function keepInPrecache(url) {
const file = bookFontFile(url);
if (!file) return true;
if (!file.italic && APP_FACES[file.pkg]?.includes(file.weight)) return true;
const kept = PRECACHED.get(file.pkg);
return !file.italic && file.subset === 'latin' && file.ext === 'woff2' && !!kept?.has(file.weight);
}
/** The precache manifest without the book faces left to the runtime cache: a Workbox `manifestTransforms` entry. */
export function withoutRuntimeFonts(entries) {
return { manifest: entries.filter((entry) => keepInPrecache(entry.url)), warnings: [] };
}
-27
View File
@@ -47,41 +47,14 @@
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/abril-fatface": "^5.3.0",
"@fontsource/alfa-slab-one": "^5.3.0",
"@fontsource/allura": "^5.3.0",
"@fontsource/anton": "^5.3.0",
"@fontsource/archivo-black": "^5.3.0",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/big-shoulders-display": "^5.3.0",
"@fontsource/bodoni-moda": "^5.3.0",
"@fontsource/bricolage-grotesque": "^5.3.0",
"@fontsource/caveat": "^5.3.0",
"@fontsource/cormorant-garamond": "^5.3.0",
"@fontsource/dancing-script": "^5.3.0",
"@fontsource/dm-mono": "^5.3.0",
"@fontsource/dm-serif-display": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/fraunces": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/great-vibes": "^5.3.0",
"@fontsource/homemade-apple": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/kaushan-script": "^5.3.0",
"@fontsource/league-gothic": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/montserrat": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/oswald": "^5.3.0",
"@fontsource/pacifico": "^5.3.0",
"@fontsource/parisienne": "^5.3.0",
"@fontsource/permanent-marker": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.3.0",
"@fontsource/sacramento": "^5.3.0",
"@fontsource/satisfy": "^5.3.0",
"@fontsource/shrikhand": "^5.3.0",
"@fontsource/yellowtail": "^5.3.0",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.3.0",
+1 -1
View File
@@ -26,5 +26,5 @@
"src/pages/atlas/useAtlas.ts": 1211,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 2057
"src/styles/studio.css": 2087
}
+1 -1
View File
@@ -102,7 +102,7 @@
"components/Studio/BookSheetsView.tsx": 2,
"components/Studio/SpreadView.tsx": 3,
"components/Studio/StudioElementsPanel.tsx": 8,
"components/Studio/StudioInspector.tsx": 2,
"components/Studio/StudioInspector.tsx": 1,
"components/Studio/StudioShell.tsx": 2,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 5,
@@ -91,7 +91,7 @@ function SheetView({
height: `${sheet.height + box.bleed * 2}mm`,
// The spread's own paper colour, carried out into the bleed. Without
// it a coloured page would be cut with a white edge on three sides.
background: sheet.spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
background: (sheet.spread.backgroundPaint ? null : sheet.spread.background) ?? '#ffffff', // theme-lint-disable: paper, not app chrome
overflow: 'hidden',
}}
>
+2 -2
View File
@@ -1,5 +1,5 @@
import type { BookPageSetup, BookSpread } from '@trek/shared'
import { fontStack, nearestWeight } from './bookFonts'
import { fontStack } from './bookFonts'
import { folioInk } from './folioColour'
import { isNumbered } from './bookSheets'
@@ -94,7 +94,7 @@ export function PageNumbers({
textAlign: at.align,
fontFamily: fontStack(cfg.font),
fontSize: `${size}pt`,
fontWeight: nearestWeight(cfg.font, 500),
fontWeight: 500,
letterSpacing: '0.08em',
lineHeight: 1,
color: ink.color,
+17 -6
View File
@@ -3,11 +3,14 @@ import type {
BookElement, BookIconElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread,
} from '@trek/shared'
import { photoSrc } from './bookRender'
import { fontStack, nearestWeight } from './bookFonts'
import { fontStack } from './bookFonts'
import { iconComponent } from './iconLibrary'
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'
import { TravelElementView } from './TravelElements'
import { PageNumbers } from './PageNumbers'
import { PaintedShapeView } from './paint/PaintedShape'
import { SpreadPaint } from './paint/paintRender'
import { usePaintId } from './paint/usePaintId'
/**
* One spread, drawn.
@@ -86,7 +89,8 @@ const FRAME_INSET: Record<string, { pad: number; bottom: number }> = {
*/
function ShapeView({ el }: { el: BookShapeElement }) {
const fill = el.fill ?? 'transparent'
const gradientId = `g-${el.id}`
// Per mounted copy: page mode draws a spread twice (paint/paintRender.tsx).
const gradientId = usePaintId(`g-${el.id}`)
const gradient = el.gradient !== 'none' && el.fill
if (el.shape === 'rect' || el.shape === 'ellipse') {
@@ -171,7 +175,7 @@ function PhotoView({ el, big, print, dropLabel }: {
// stylesheet that a person may well end up reading.
const pad = round2(deco.pad * side)
const bottom = round2(deco.bottom * side)
const clipId = `c-${el.id}`
const clipId = usePaintId(`c-${el.id}`)
const clipped = el.mask && el.mask !== 'rect'
/*
@@ -394,7 +398,9 @@ export function ElementView({
return <PhotoView el={el} big={big} print={print} dropLabel={dropLabel} />
}
if (el.kind === 'shape') return <ShapeView el={el} />
if (el.kind === 'shape') {
return el.paint ? <PaintedShapeView el={el} paint={el.paint} frameStyle={frameStyle(el)} /> : <ShapeView el={el} />
}
// Above the catch-all below, which routes everything that is not text into
// the travel elements and draws null for anything they do not know.
@@ -417,7 +423,7 @@ export function ElementView({
// the conversion exactly.
fontSize: `${el.size}pt`,
fontFamily: fontStack(el.font),
fontWeight: nearestWeight(el.font, el.weight),
fontWeight: el.weight,
fontStyle: el.italic ? 'italic' : undefined,
lineHeight: el.leading,
letterSpacing: `${el.tracking}em`,
@@ -474,7 +480,8 @@ export function SpreadView({
style={{
position: 'absolute',
inset: 0,
background: spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
// Under a paint the sheet is the paper, which a see-through paint lets show.
background: (spread.backgroundPaint ? null : spread.background) ?? '#ffffff', // theme-lint-disable: paper, not app chrome
/*
* On screen the page ends at the trim, so anything past it is clipped.
* In print it must not be: the bleed is the part that runs off the edge
@@ -485,6 +492,10 @@ export function SpreadView({
overflow: print ? 'visible' : 'hidden',
}}
>
{spread.backgroundPaint && (
<SpreadPaint paint={spread.backgroundPaint} width={w} height={page.pageHeight} bleed={page.bleed} idPrefix={spread.id} />
)}
{spread.elements.map(el => (
<ElementView
key={el.id}
@@ -0,0 +1,130 @@
import { bookElementId, spreadPaint, withSpreadPaint, type BookPageSetup } from '@trek/shared';
import { Check, CopyCheck, ImageOff } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useStudioStore } from '../../store/studioStore';
import { photoSrc } from './bookRender';
import {
backgroundCandidates,
backgroundPhotoOf,
backgroundToAll,
withBackgroundPhoto,
withoutBackgroundPhoto,
} from './paint/backgroundPhoto';
import { spreadPhotoIds } from './paint/paletteModel';
import { usePaintChannel } from './paint/usePaintChannel';
import { Section } from './StudioControls';
import { StudioPaintPicker } from './StudioPaintPicker';
import type { JourneySource, StudioT } from './StudioSidebar';
/**
* The page's background, in the inspector when nothing is selected.
*
* Until now only the assistant could set it; a person had to lay a shape
* across the page and remember to send it to the back. Three things, in the
* order people reach for them: the paint itself, the same background for
* every page, and a photograph under the whole page.
*/
export function StudioBackgroundSection({
spreadIndex,
page,
source,
t,
}: {
spreadIndex: number;
page: BookPageSetup;
source: JourneySource;
t: StudioT;
}) {
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
const commit = useStudioStore((s) => s.commit);
const channel = usePaintChannel((paint) => (doc) => ({
...doc,
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withSpreadPaint(sp, paint) : sp)),
}));
// "Applied" stands in the button for a moment, so a press that changed pages out of sight is seen to have worked.
const [applied, setApplied] = useState(false);
useEffect(() => {
if (!applied) return;
const timer = window.setTimeout(() => setApplied(false), 1600);
return () => window.clearTimeout(timer);
}, [applied]);
const current = backgroundPhotoOf(spread, page);
const candidates = useMemo(() => {
if (!spread) return [];
const entry = source.entries.find((e) => e.id === spread.entryId);
return backgroundCandidates(
spreadPhotoIds(spread),
entry?.photoIds ?? [],
source.photos.map((p) => p.photoId)
);
}, [spread, source]);
if (!spread) return null;
const setPhoto = (photoId: number) =>
commit((doc) => ({
...doc,
spreads: doc.spreads.map((sp, i) => {
if (i !== spreadIndex) return sp;
return withBackgroundPhoto(sp, page, photoId, () => bookElementId('p')) ?? sp;
}),
}));
const clearPhoto = () =>
commit((doc) => ({
...doc,
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withoutBackgroundPhoto(sp, page) : sp)),
}));
const applyToAll = () => {
commit((doc) => backgroundToAll(doc, spreadIndex));
setApplied(true);
};
return (
<Section label={t('journey.studio.paint.background')}>
<StudioPaintPicker
value={spreadPaint(spread)}
channel={channel}
t={t}
noneLabel={t('journey.studio.paint.paper')}
/>
<button type="button" className={`st-chip st-paint-apply ${applied ? 'is-done' : ''}`} onClick={applyToAll}>
{applied ? <Check size={13} strokeWidth={2.4} /> : <CopyCheck size={13} />}
{applied ? t('journey.studio.paint.appliedAll') : t('journey.studio.paint.applyAll')}
</button>
<div className="st-paint-field">
<span className="st-paint-caption">{t('journey.studio.paint.photoBackground')}</span>
{candidates.length ? (
<>
<div className="st-bgphoto-grid">
{candidates.map((id) => (
<button
type="button"
key={id}
className={`st-bgphoto ${current?.photoId === id ? 'is-on' : ''}`}
aria-pressed={current?.photoId === id}
aria-label={t('journey.studio.paint.photoBackground')}
onClick={() => (current?.photoId === id ? clearPhoto() : setPhoto(id))}
>
<img src={photoSrc(id, false)} alt="" loading="lazy" draggable={false} />
</button>
))}
</div>
<p className="st-hint st-paint-hint">{t('journey.studio.paint.photoBackgroundHint')}</p>
{current && (
<button type="button" className="st-chip is-danger st-paint-apply" onClick={clearPhoto}>
<ImageOff size={13} />
{t('journey.studio.paint.removePhotoBackground')}
</button>
)}
</>
) : (
<p className="st-hint st-paint-hint">{t('journey.studio.paint.noPhotos')}</p>
)}
</div>
</Section>
);
}
@@ -5,7 +5,7 @@ import { SpreadFold, SpreadView } from './SpreadView'
import { PeerCursors } from './PeerCursors'
import type { PeerCursor } from './useBookPresence'
import { bookElementId as elementId } from '@trek/shared'
import { fontStack, nearestWeight } from './bookFonts'
import { fontStack } from './bookFonts'
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
import { useStudioStore } from '../../store/studioStore'
import { useTranslation } from '../../i18n'
@@ -325,7 +325,7 @@ export function StudioCanvas({
// The document is in pt; the overlay is on screen at the same zoom.
fontSize: `${el.size * (96 / 72) * zoom}px`,
fontFamily: fontStack(el.font),
fontWeight: nearestWeight(el.font, el.weight),
fontWeight: el.weight,
fontStyle: el.italic ? 'italic' : undefined,
lineHeight: el.leading,
letterSpacing: `${el.tracking}em`,
@@ -1,112 +0,0 @@
import { blankBook, bookTextElementSchema, type BookDocument, type BookElement, type BookSpread } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, within } from '../../../tests/helpers/render';
import { useStudioStore } from '../../store/studioStore';
import { BOOK_FONT_ORDER } from './bookFonts';
import { matchFonts, pickFont } from './fontPicker';
import { StudioFontPicker } from './StudioFontPicker';
/**
* The typeface picker (FE-STUDIO-FONTPICK): the current family set in itself,
* a list grouped by kind with every family in its own face, the book's own
* families first, a search, and a weight that lands on one the family has.
*/
const t = (k: string, params?: Record<string, string | number>) =>
params ? `${k} ${Object.values(params).join(' ')}` : k;
const groupName = (c: string) => `journey.studio.fonts.cat.${c}`;
const frame = { x: 10, y: 10, w: 80, h: 20 };
function bookIn(...fonts: string[]): BookDocument {
const doc = blankBook({ title: 'Porto', newId: (p) => `${p}-x` });
const elements = fonts.map(
(font, i) => bookTextElementSchema.parse({ id: `t${i}`, kind: 'text', frame, text: 'Porto', font }) as BookElement
);
const [cover, ...rest] = doc.spreads as [BookSpread, ...BookSpread[]];
return {
...doc,
page: { ...doc.page, pageNumbers: { ...doc.page.pageNumbers, show: false } },
spreads: [{ ...cover, elements }, ...rest],
};
}
const open = () => fireEvent.click(screen.getByRole('button', { name: /journey\.studio\.fonts\.change/ }));
afterEach(() => {
useStudioStore.setState({ doc: null });
});
describe('choosing a family', () => {
it('FE-STUDIO-FONTPICK-001: moves the weight onto one the new family has', () => {
expect(pickFont('anton', 700)).toEqual({ font: 'anton', weight: 400 });
expect(pickFont('sans', 700)).toEqual({ font: 'sans', weight: 700 });
expect(pickFont('oswald', 900)).toEqual({ font: 'oswald', weight: 700 });
});
it('FE-STUDIO-FONTPICK-002: finds families by name, id and group, in picker order', () => {
expect(matchFonts('', groupName)).toEqual(BOOK_FONT_ORDER);
expect(matchFonts(' GARA ', groupName)).toEqual(['garamond', 'cormorant']);
expect(matchFonts('mono', groupName)).toEqual(['mono']);
expect(matchFonts('cat.script', groupName)).toContain('pacifico');
expect(matchFonts('nothing like it', groupName)).toEqual([]);
});
});
describe('the picker', () => {
it('FE-STUDIO-FONTPICK-003: shows the current family set in itself and opens a grouped list with the book families first', () => {
act(() => useStudioStore.setState({ doc: bookIn('caveat', 'serif') }));
render(<StudioFontPicker font="serif" weight={400} onPick={vi.fn()} t={t} />);
const current = screen.getByRole('button', { name: 'journey.studio.fonts.change: Lora' });
expect(current.getAttribute('aria-expanded')).toBe('false');
expect(within(current).getByText('Lora').getAttribute('style')).toContain('Lora');
open();
expect(current.getAttribute('aria-expanded')).toBe('true');
expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'journey.studio.fonts.search' }));
const groups = screen.getAllByRole('group');
expect(groups[0]?.getAttribute('aria-label')).toBe('journey.studio.fonts.used');
const used = within(groups[0] as HTMLElement).getAllByRole('option');
expect(used.map((o) => o.querySelector('.st-fontpick-face')?.textContent)).toEqual(['Lora', 'Caveat']);
expect(used[1]?.textContent).toContain('journey.studio.fonts.range 400 700');
// Every family, once per group it is in, each set in its own face.
const rows = screen.getAllByRole('option');
expect(rows).toHaveLength(BOOK_FONT_ORDER.length + 2);
const anton = rows.find((r) => r.textContent?.startsWith('Anton'));
expect(anton?.querySelector('span')?.getAttribute('style')).toContain('Anton');
expect(anton?.textContent).toContain('400');
expect(rows.filter((r) => r.getAttribute('aria-selected') === 'true')).toHaveLength(2);
});
it('FE-STUDIO-FONTPICK-004: picks a family at a weight it has and closes, back on the button', () => {
const onPick = vi.fn();
render(<StudioFontPicker font="sans" weight={700} onPick={onPick} t={t} />);
open();
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'anton' } });
expect(screen.queryByRole('group', { name: 'journey.studio.fonts.used' })).toBeNull();
fireEvent.click(screen.getByText('Anton'));
expect(onPick).toHaveBeenCalledWith({ font: 'anton', weight: 400 });
expect(screen.queryByRole('listbox')).toBeNull();
expect(document.activeElement?.getAttribute('aria-expanded')).toBe('false');
});
it('FE-STUDIO-FONTPICK-005: says when nothing matches, clears the search, and closes on Escape', () => {
render(<StudioFontPicker font="sans" weight={400} onPick={vi.fn()} t={t} />);
open();
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'zzz' } });
expect(screen.getByText('journey.studio.fonts.none')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'common.clear' }));
expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe('');
expect(screen.queryByText('journey.studio.fonts.none')).toBeNull();
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Escape' });
expect(screen.queryByRole('listbox')).toBeNull();
// A second click on the open button closes it too.
open();
open();
expect(screen.queryByRole('listbox')).toBeNull();
});
it('FE-STUDIO-FONTPICK-006: reads a family this build does not know as the default one', () => {
render(<StudioFontPicker font="gone" weight={400} onPick={vi.fn()} t={t} />);
expect(screen.getByRole('button', { name: 'journey.studio.fonts.change: Poppins' })).toBeTruthy();
});
});
@@ -1,198 +0,0 @@
import { BOOK_FONT_CATEGORIES, bookFontsUsed, type BookFontCategory } from '@trek/shared';
import { Check, ChevronDown, Search, X } from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { useStudioStore } from '../../store/studioStore';
import '../../styles/studio-fonts.css';
import { BOOK_FONTS, nearestWeight, type BookFontId } from './bookFonts';
import { matchFonts, pickFont, weightSpan, type FontPick, type FontT } from './fontPicker';
/**
* The typeface picker, shared by the text inspector and the travel inspector.
*
* Thirty four families do not fit as a wall of buttons the way seven did, and
* a plain list of names says nothing about how a face looks, which is the only
* thing anyone is choosing between. So the current family sits on one line,
* set in itself, and opens a list grouped the way people look for type
* (a sans, a serif, something for a poster, a script to lay over a photo)
* where every family is again set in its own face. The families the book is
* already set in come first, because the second text of a book is usually set
* like the first.
*
* The list opens inside the inspector rather than floating over the page: the
* inspector scrolls, and a popover pinned to a control that scrolls away is a
* popover pointing at nothing.
*/
function FontRow({
id,
on,
weight,
onPick,
t,
}: {
id: BookFontId;
on: boolean;
weight: number;
onPick: () => void;
t: FontT;
}) {
const face = BOOK_FONTS[id];
return (
<button
type="button"
role="option"
aria-selected={on}
className={`st-fontpick-row ${on ? 'is-on' : ''}`}
onClick={onPick}
title={face.name}
>
<span className="st-fontpick-face" style={{ fontFamily: face.stack, fontWeight: nearestWeight(id, weight) }}>
{face.name}
</span>
<span className="st-fontpick-span">{weightSpan(id, t)}</span>
<span className="st-fontpick-tick">{on && <Check size={13} />}</span>
</button>
);
}
function FontGroup({
label,
ids,
font,
weight,
onPick,
t,
}: {
label: string;
ids: BookFontId[];
font: string;
weight: number;
onPick: (id: BookFontId) => void;
t: FontT;
}) {
if (!ids.length) return null;
return (
<div className="st-fontpick-group" role="group" aria-label={label}>
<div className="st-fontpick-label">{label}</div>
{ids.map((id) => (
<FontRow key={id} id={id} on={font === id} weight={weight} onPick={() => onPick(id)} t={t} />
))}
</div>
);
}
/** The families the open book is set in, read from the document the Studio holds. */
function useFontsInBook(): BookFontId[] {
const doc = useStudioStore((s) => s.doc);
return useMemo(() => (doc ? bookFontsUsed(doc) : []), [doc]);
}
export function StudioFontPicker({
font,
weight,
onPick,
t,
}: {
font: string;
weight: number;
onPick: (patch: FontPick) => void;
t: FontT;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const trigger = useRef<HTMLButtonElement>(null);
const search = useRef<HTMLInputElement>(null);
const listId = useId();
const inBook = useFontsInBook();
const current = BOOK_FONTS[font as BookFontId] ?? BOOK_FONTS.sans;
const groupName = (c: BookFontCategory) => t(`journey.studio.fonts.cat.${c}`);
const found = matchFonts(query, groupName);
// Opened on purpose, so typing goes straight to the search.
useEffect(() => {
if (open) search.current?.focus();
}, [open]);
const close = () => {
setOpen(false);
setQuery('');
trigger.current?.focus();
};
const choose = (id: BookFontId) => {
onPick(pickFont(id, weight));
close();
};
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.stopPropagation();
close();
};
return (
<div className={`st-fontpick ${open ? 'is-open' : ''}`} onKeyDown={onKey}>
<button
ref={trigger}
type="button"
className="st-fontpick-current"
aria-expanded={open}
aria-controls={listId}
aria-label={`${t('journey.studio.fonts.change')}: ${current.name}`}
onClick={() => (open ? close() : setOpen(true))}
>
<span
className="st-fontpick-face"
style={{ fontFamily: current.stack, fontWeight: nearestWeight(current.id, weight) }}
>
{current.name}
</span>
<span className="st-fontpick-span">{groupName(current.category)}</span>
<ChevronDown size={14} className="st-fontpick-caret" />
</button>
{open && (
<div className="st-fontpick-panel">
<div className="st-search st-fontpick-search">
<Search size={14} />
<input
ref={search}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('journey.studio.fonts.search')}
aria-label={t('journey.studio.fonts.search')}
aria-controls={listId}
spellCheck={false}
/>
{query && (
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')}>
<X size={13} />
</button>
)}
</div>
<div id={listId} className="st-fontpick-list" role="listbox" aria-label={t('journey.studio.fonts.list')}>
{!query && (
<FontGroup
label={t('journey.studio.fonts.used')}
ids={inBook}
font={font}
weight={weight}
onPick={choose}
t={t}
/>
)}
{BOOK_FONT_CATEGORIES.map((category) => (
<FontGroup
key={category}
label={groupName(category)}
ids={found.filter((id) => BOOK_FONTS[id].category === category)}
font={font}
weight={weight}
onPick={choose}
t={t}
/>
))}
{!found.length && <p className="st-hint st-fontpick-none">{t('journey.studio.fonts.none')}</p>}
</div>
</div>
)}
</div>
);
}
@@ -10,12 +10,12 @@ import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes'
import { ShapeGlyph } from './StudioElementsPanel'
import { TravelInspector } from './StudioTravelInspector'
import { Swatches } from './StudioSwatches'
import { StudioBackgroundSection } from './StudioBackgroundSection'
import { StudioShapePaint } from './StudioShapePaint'
import { formatBookCoords } from '@trek/shared'
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
import { StudioFontPicker } from './StudioFontPicker'
import { WeightLine } from './StudioTypeControls'
import { italicTitle } from './fontPicker'
import { FontButtons, WeightLine } from './StudioTypeControls'
import type { JourneySource } from './StudioSidebar'
import { Tooltip } from '../shared/Tooltip'
@@ -102,6 +102,8 @@ export function StudioInspector({
<aside className="st-panel st-inspector">
<div className="st-panel-head"><span>{t('journey.studio.document')}</span></div>
<div className="st-panel-scroll">
<StudioBackgroundSection spreadIndex={spreadIndex} page={page} source={source} t={t} />
<Section label={t('journey.studio.pageNumbers')}>
<Switch
label={t('journey.studio.pageNumbers')}
@@ -325,7 +327,7 @@ export function StudioInspector({
nothing about a typeface — and grouped, because "which serif"
is a different question from "serif or sans".
*/}
<StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch)} t={t} />
<FontButtons font={el.font} weight={el.weight} onPick={patch => set(patch)} />
<div style={{ marginTop: 10 }}>
<WeightLine font={el.font} weight={el.weight} onPick={weight => set({ weight })} t={t} />
<Line label={t('journey.studio.align')}>
@@ -346,7 +348,7 @@ export function StudioInspector({
<button type="button"
className={`st-chip is-icon ${el.italic ? 'is-on' : ''}`}
onClick={() => set({ italic: !el.italic })}
title={italicTitle(el.font, el.weight, t)}
title={t('journey.studio.italic')}
aria-label={t('journey.studio.italic')}
aria-pressed={el.italic}
>
@@ -517,54 +519,13 @@ export function StudioInspector({
</div>
</Section>
<Section label={t('journey.studio.colour')}>
{/*
Fill and stroke, both named. The panel used to show one
unlabelled swatch row that wrote `fill`, so on an outline-only
shape it lit up a colour that was not being drawn and there was
no way to reach the line you could actually see.
*/}
<Line label={t('journey.studio.fill')} wide>
<Swatches value={el.fill ?? '#111111'} onPick={c => set({ fill: c })} />
</Line>
<Line label={t('journey.studio.stroke')} wide>
<Swatches
value={el.stroke ?? '#141414'}
onPick={c => set({ stroke: c, strokeWidth: el.strokeWidth || 0.5 })}
/>
</Line>
<div style={{ marginTop: 10 }}>
<Switch
label={t('journey.studio.fillOn')}
on={el.fill !== null}
onToggle={() => set({
// Turning the fill off on a shape with no stroke would make
// it invisible, so the outline comes on with it.
fill: el.fill === null ? '#111111' : null,
...(el.fill !== null && !el.stroke ? { stroke: '#141414', strokeWidth: el.strokeWidth || 0.5 } : {}),
})}
/>
<Switch
label={t('journey.studio.gradient')}
on={el.gradient !== 'none'}
onToggle={() => set({ gradient: el.gradient === 'none' ? 'down' : 'none' })}
/>
</div>
{el.gradient !== 'none' && (
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.gradient')}>
<Choice
value={el.gradient}
options={[
{ value: 'down' as const, label: t('journey.studio.gradientDown') },
{ value: 'up' as const, label: t('journey.studio.gradientUp') },
]}
onPick={gradient => set({ gradient })}
/>
</Line>
</div>
)}
</Section>
<StudioShapePaint
el={el}
spreadIndex={spreadIndex}
selection={selection}
onStroke={c => set({ stroke: c, strokeWidth: el.strokeWidth || 0.5 })}
t={t}
/>
<Section label={t('journey.studio.strokeStyle')} defaultOpen={false}>
<Line label={t('journey.studio.strokeStyle')} wide>
@@ -0,0 +1,371 @@
import {
PATTERN_SCALE,
RADIAL_RADIUS,
stopAlpha,
type BookGradientPaint,
type BookPaint,
type BookPattern,
type BookPatternPaint,
} from '@trek/shared';
import { ArrowLeftRight, Ban, Trash2 } from 'lucide-react';
import { useRef, useState } from 'react';
import '../../styles/studio-paint.css';
import { NumField } from './StudioControls';
import type { StudioT } from './StudioSidebar';
import { Swatches } from './StudioSwatches';
import { AngleDial } from './paint/AngleDial';
import { GradientStopBar } from './paint/GradientStopBar';
import { PatternSwatches } from './paint/PatternSwatches';
import { RadialPad } from './paint/RadialPad';
import {
PATTERN_ANGLE,
clampRadius,
clampScale,
cssPaint,
gradientAs,
kindOf,
normAngle,
paintAs,
removeStop,
reverseStops,
updateStop,
type PaintKind,
} from './paint/paintEdit';
import type { PaintChannel } from './paint/usePaintChannel';
/**
* Paint, picked: none, one colour, a gradient or a pattern.
*
* ── How it is laid out ───────────────────────────────────────────────────
*
* One row of four tabs says what kind of paint it is, each with a swatch of
* what choosing it gives, and below it only the controls that kind has. The
* tab is the question people ask first ("flat or a gradient?"), so it is a
* row they can read at a glance rather than a menu.
*
* Switching kind keeps the colour: a solid becomes a gradient from that
* colour to a lighter partner, a gradient becomes a pattern in its first
* colour, and coming back to a kind already visited in this session brings
* it back as it was left, so trying a pattern costs nothing.
*
* Every drag (a stop, the dial, the centre, a slider) writes live and is one
* undo step when it is let go, through the channel the caller hands in.
*/
const KINDS: readonly PaintKind[] = ['none', 'solid', 'gradient', 'pattern'];
/** A kind's name on its tab. */
function kindLabel(k: Exclude<PaintKind, 'none'>, t: StudioT): string {
if (k === 'solid') return t('journey.studio.paint.solid');
return k === 'gradient' ? t('journey.studio.paint.gradient') : t('journey.studio.paint.pattern');
}
export function StudioPaintPicker({
value,
channel,
t,
noneLabel,
}: {
value: BookPaint | null;
channel: PaintChannel;
t: StudioT;
/** What "no paint" is called here: no fill on a shape, the paper on a page. */
noneLabel: string;
}) {
const kind = kindOf(value);
// The last paint of each kind shown here, so a kind visited again comes back as it was left.
const [memory, setMemory] = useState<Partial<Record<PaintKind, BookPaint>>>({});
const [stop, setStop] = useState(0);
const write = (paint: BookPaint | null, done = true) => {
const k = kindOf(paint);
if (paint && k !== 'none') setMemory((m) => ({ ...m, [k]: paint }));
if (done) channel.commit(paint);
else channel.live(paint);
};
const choose = (next: PaintKind) => {
if (next === kind) return;
const kept = value ? { ...memory, [kind]: value } : memory;
if (value) setMemory((m) => ({ ...m, [kind]: value }));
setStop(0);
write(paintAs(next, value, kept));
};
const tabPreview = (k: PaintKind) =>
k === 'none' ? undefined : cssPaint(k === kind ? value : paintAs(k, value, memory));
return (
<div className="st-paint">
<div className="st-paint-tabs" role="tablist" aria-label={t('journey.studio.paint.kind')}>
{KINDS.map((k) => (
<button
type="button"
key={k}
role="tab"
aria-selected={k === kind}
className={`st-paint-tab ${k === kind ? 'is-on' : ''}`}
onClick={() => choose(k)}
>
<span
className={`st-paint-tab-chip ${k === 'none' ? 'is-none' : ''} ${k === 'pattern' ? 'is-pattern' : ''}`}
// A pattern's chip keeps its strokes (studio-paint.css) over the ground colour.
style={k === 'pattern' ? { backgroundColor: tabPreview(k) } : { background: tabPreview(k) }}
aria-hidden
>
{k === 'none' && <Ban size={11} strokeWidth={2.2} />}
</span>
<span className="st-paint-tab-label">{k === 'none' ? noneLabel : kindLabel(k, t)}</span>
</button>
))}
</div>
{value?.type === 'solid' && (
<Swatches
value={value.color}
onPick={(color) => write({ ...value, color })}
alpha={stopAlpha(value)}
onAlpha={(alpha, done) => write(withAlpha(value, alpha), done)}
/>
)}
{(value?.type === 'linear' || value?.type === 'radial') && (
<GradientControls
paint={value}
stop={Math.min(stop, value.stops.length - 1)}
onStop={setStop}
write={write}
t={t}
/>
)}
{value?.type === 'pattern' && <PatternControls paint={value} write={write} t={t} />}
</div>
);
}
const withAlpha = <T extends { alpha?: number }>(o: T, alpha: number): T => {
const { alpha: _old, ...rest } = o;
return (alpha >= 1 ? rest : { ...rest, alpha: Math.round(alpha * 100) / 100 }) as T;
};
type Write = (paint: BookPaint | null, done?: boolean) => void;
function GradientControls({
paint,
stop,
onStop,
write,
t,
}: {
paint: BookGradientPaint;
stop: number;
onStop: (i: number) => void;
write: Write;
t: StudioT;
}) {
const current = paint.stops[stop];
const canRemove = paint.stops.length > 2;
return (
<div className="st-paint-body">
<div className="st-paint-row">
<div className="st-seg" role="radiogroup" aria-label={t('journey.studio.paint.gradient')}>
{(['linear', 'radial'] as const).map((type) => (
<button
type="button"
key={type}
role="radio"
aria-checked={paint.type === type}
className={`st-seg-item ${paint.type === type ? 'is-on' : ''}`}
onClick={() => write(gradientAs(paint, type))}
>
{type === 'linear' ? t('journey.studio.paint.linear') : t('journey.studio.paint.radial')}
</button>
))}
</div>
<button
type="button"
className="st-chip is-icon"
title={t('journey.studio.paint.reverse')}
aria-label={t('journey.studio.paint.reverse')}
onClick={() => write(reverseStops(paint))}
>
<ArrowLeftRight size={13} />
</button>
</div>
<GradientStopBar
paint={paint}
selected={stop}
onSelect={onStop}
onChange={(p, done) => write(p, done)}
labels={{
bar: t('journey.studio.paint.addStopHint'),
stop: (pos) => t('journey.studio.paint.stopAt', { pos }),
}}
/>
{current && (
<div className="st-paint-stop">
<div className="st-paint-row">
<span className="st-paint-caption">
{t('journey.studio.paint.stopAt', { pos: Math.round(current.at * 100) })}
</span>
<button
type="button"
className="st-chip is-icon is-danger"
disabled={!canRemove}
title={t('journey.studio.paint.removeStop')}
aria-label={t('journey.studio.paint.removeStop')}
onClick={() => {
onStop(Math.max(0, stop - 1));
write(removeStop(paint, stop));
}}
>
<Trash2 size={13} />
</button>
</div>
<Swatches
value={current.color}
onPick={(color) => write(updateStop(paint, stop, { color }))}
alpha={stopAlpha(current)}
onAlpha={(alpha, done) => write(updateStop(paint, stop, { alpha }), done)}
/>
</div>
)}
{paint.type === 'linear' ? (
<div className="st-paint-row is-dial">
<AngleDial
value={paint.angle}
label={t('journey.studio.paint.angle')}
onChange={(angle, done) => write({ ...paint, angle }, done)}
/>
<NumField
label={t('journey.studio.paint.angle')}
value={normAngle(paint.angle)}
min={0}
max={359}
step={1}
unit="°"
onChange={(angle) => write({ ...paint, angle: normAngle(angle) })}
/>
</div>
) : (
<div className="st-paint-row is-dial">
<RadialPad paint={paint} label={t('journey.studio.paint.centre')} onChange={(p, done) => write(p, done)} />
<Slider
label={t('journey.studio.paint.radius')}
value={paint.r}
min={RADIAL_RADIUS.min}
max={RADIAL_RADIUS.max}
shown={`${Math.round(paint.r * 100)}%`}
onChange={(r, done) => write({ ...paint, r: clampRadius(r) }, done)}
/>
</div>
)}
</div>
);
}
function PatternControls({ paint, write, t }: { paint: BookPatternPaint; write: Write; t: StudioT }) {
const pick = (pattern: BookPattern) => write({ ...paint, pattern, angle: PATTERN_ANGLE[pattern] ?? paint.angle });
return (
<div className="st-paint-body">
<PatternSwatches value={paint} onPick={pick} label={(p) => t(`journey.studio.pattern.${p}`)} />
<div className="st-paint-field">
<span className="st-paint-caption">{t('journey.studio.paint.ink')}</span>
<Swatches value={paint.fg} onPick={(fg) => write({ ...paint, fg })} />
</div>
<div className="st-paint-field">
<div className="st-paint-row">
<span className="st-paint-caption">{t('journey.studio.paint.ground')}</span>
<button
type="button"
role="switch"
aria-checked={paint.bg === null}
className={`st-chip is-pick ${paint.bg === null ? 'is-on' : ''}`}
onClick={() => write({ ...paint, bg: paint.bg === null ? '#ffffff' : null })}
>
{t('journey.studio.paint.seeThrough')}
</button>
</div>
{paint.bg !== null && <Swatches value={paint.bg} onPick={(bg) => write({ ...paint, bg })} />}
</div>
<div className="st-paint-row is-dial">
<AngleDial
value={paint.angle}
label={t('journey.studio.paint.angle')}
onChange={(angle, done) => write({ ...paint, angle }, done)}
/>
<Slider
label={t('journey.studio.paint.scale')}
value={paint.scale}
min={PATTERN_SCALE.min}
max={PATTERN_SCALE.max}
shown={`${Math.round(paint.scale * 100)}%`}
log
onChange={(scale, done) => write({ ...paint, scale: clampScale(scale) }, done)}
/>
</div>
</div>
);
}
/** A labelled slider with its value beside it; `done` as for the dial. */
function Slider({
label,
value,
min,
max,
shown,
onChange,
log = false,
}: {
label: string;
value: number;
min: number;
max: number;
shown: string;
onChange: (v: number, done: boolean) => void;
/** Even steps by ratio rather than by difference: a pattern's scale doubles, it does not add. */
log?: boolean;
}) {
const steps = 100;
const toValue = (raw: string) => {
const t = Number(raw) / steps;
return log ? min * (max / min) ** t : min + (max - min) * t;
};
const pos = Math.round((log ? Math.log(value / min) / Math.log(max / min) : (value - min) / (max - min)) * steps);
// What the slider was last moved to: a controlled input may still show the old value when it is let go.
const moved = useRef(value);
return (
<label className="st-paint-slider">
<span className="st-paint-slider-head">
<span className="st-paint-caption">{label}</span>
<span className="st-paint-value">{shown}</span>
</span>
<input
type="range"
min={0}
max={steps}
step={1}
value={pos}
onPointerDown={() => {
moved.current = value;
}}
onKeyDown={() => {
moved.current = value;
}}
onChange={(e) => {
moved.current = toValue(e.target.value);
onChange(moved.current, false);
}}
onPointerUp={() => onChange(moved.current, true)}
onKeyUp={() => onChange(moved.current, true)}
/>
</label>
);
}
@@ -0,0 +1,60 @@
import { shapePaint, withShapePaint, type BookElement, type BookShapeElement } from '@trek/shared';
import { usePaintChannel } from './paint/usePaintChannel';
import { Line, Section } from './StudioControls';
import { StudioPaintPicker } from './StudioPaintPicker';
import type { StudioT } from './StudioSidebar';
import { Swatches } from './StudioSwatches';
/** The outline a shape is given when its fill goes and it has none, so it never turns invisible. */
const OUTLINE = '#141414';
/**
* A shape's colours: its fill as a paint, and its outline.
*
* The fill goes to every selected shape at once, the way the rest of the
* inspector's properties do. Taking the fill away from a shape with no
* outline gives it one, as the old fill switch did: a shape with neither is
* a shape nobody can see or find again.
*/
export function StudioShapePaint({
el,
spreadIndex,
selection,
onStroke,
t,
}: {
el: BookShapeElement;
spreadIndex: number;
selection: readonly string[];
onStroke: (color: string) => void;
t: StudioT;
}) {
const channel = usePaintChannel((paint) => (doc) => ({
...doc,
spreads: doc.spreads.map((sp, i) =>
i !== spreadIndex
? sp
: {
...sp,
elements: sp.elements.map((e): BookElement => {
if (e.kind !== 'shape' || !selection.includes(e.id)) return e;
const painted = withShapePaint(e, paint);
return paint === null && !e.stroke
? { ...painted, stroke: OUTLINE, strokeWidth: e.strokeWidth || 0.5 }
: painted;
}),
}
),
}));
return (
<Section label={t('journey.studio.colour')}>
<Line label={t('journey.studio.fill')} wide>
<StudioPaintPicker value={shapePaint(el)} channel={channel} t={t} noneLabel={t('journey.studio.paint.none')} />
</Line>
<Line label={t('journey.studio.stroke')} wide>
<Swatches value={el.stroke ?? OUTLINE} onPick={onStroke} />
</Line>
</Section>
);
}
+13 -13
View File
@@ -13,6 +13,7 @@ import { ProviderPicker } from '../Journey/JourneyDetailPageProviderPicker'
import { useAuthStore } from '../../store/authStore'
import { StudioCanvas } from './StudioCanvas'
import { StudioInspector } from './StudioInspector'
import { StudioPaletteScope } from './paint/StudioPalette'
import { StudioWordmark } from './StudioWordmark'
import { SaveIndicator } from './SaveIndicator'
import { downloadSpread } from './spreadFile'
@@ -26,7 +27,6 @@ import { useStudioAssistant, type StudioAssistant } from './useStudioAssistant'
import '../../styles/dashboard.css'
import '../../styles/studio.css'
import './bookFontFaces'
import { useWarmBookFaces } from './bookFontWarm'
import HelpButton from '../Help/HelpButton'
/**
@@ -46,8 +46,6 @@ export default function StudioShell() {
const [bookView, setBookView] = useState(true)
const [exporting, setExporting] = useState(false)
const assistant = useStudioAssistant({ journeyId: s.journeyId, flush: s.flushBook })
// The book's faces into the offline cache as it opens (bookFontWarm.ts).
useWarmBookFaces()
if (s.isMobile) {
return createPortal(
@@ -127,16 +125,18 @@ export default function StudioShell() {
onBrowseProvider={s.browseProvider}
/>
<Workbench s={s} bookView={bookView} />
<StudioInspector
spreadIndex={s.activeSpread}
page={s.page}
stats={s.stats}
path={s.path}
source={s.source}
setPageNumbers={s.setPageNumbers}
t={s.t}
locale={s.locale}
/>
<StudioPaletteScope spreadIndex={s.activeSpread} t={s.t}>
<StudioInspector
spreadIndex={s.activeSpread}
page={s.page}
stats={s.stats}
path={s.path}
source={s.source}
setPageNumbers={s.setPageNumbers}
t={s.t}
locale={s.locale}
/>
</StudioPaletteScope>
</div>
<StudioAssistantColumn assistant={assistant} title={s.journey?.title} bookVersion={s.bookVersion} />
<StudioPageStage assistant={assistant} />
+121 -15
View File
@@ -1,3 +1,8 @@
import { useRef } from 'react'
import { BookOpen, History, Image } from 'lucide-react'
import { useStudioPalette } from './paint/paletteModel'
import '../../styles/studio-paint.css'
/**
* The colour picker, shared by everything in the inspector that has a colour.
*
@@ -8,6 +13,12 @@
* The palette is print ink rather than app chrome — it does not follow the
* user's theme, because a book is printed once and read on paper, where an
* accent chosen for a dark interface means nothing.
*
* Under the fixed inks sit up to three short rows from the open book
* (paint/StudioPalette.tsx): the colours the book already uses, the colours
* of the photos on this page, and what this person picked lately. Each row
* is there only when it has something in it, so a new book shows the inks
* alone, as before.
*/
const SWATCHES = [
@@ -16,23 +27,118 @@ const SWATCHES = [
'#1e3a8a', '#9f1239', '#4c1d95',
]
export function Swatches({ value, onPick }: { value: string; onPick: (c: string) => void }) {
function Swatch({ color, on, onPick }: { color: string; on: boolean; onPick: (c: string) => void }) {
return (
<div className="st-swatches">
{SWATCHES.map(c => (
<button type="button"
key={c}
className={`st-swatch ${value.toLowerCase() === c ? 'is-on' : ''}`}
style={{ background: c }} // theme-lint-disable — a colour picker shows the colours themselves
onClick={() => onPick(c)}
aria-label={c}
/>
<button type="button"
className={`st-swatch ${on ? 'is-on' : ''}`}
style={{ background: color }} // theme-lint-disable: a colour picker shows the colours themselves
onClick={() => onPick(color)}
aria-label={color}
aria-pressed={on}
/>
)
}
export function Swatches({ value, onPick, alpha, onAlpha, rows = true }: {
value: string
onPick: (c: string) => void
/** With `onAlpha`, an opacity slider under the swatches, 0 to 1. */
alpha?: number
/** `done` is false while the slider is dragged and true when it is let go. */
onAlpha?: (a: number, done: boolean) => void
/** The book, photo and recent rows; off where only the fixed inks make sense. */
rows?: boolean
}) {
const palette = useStudioPalette()
const current = value.toLowerCase()
// The wheel reports every colour it passes through; only the one it stops on is worth remembering.
const wheel = useRef<string | null>(null)
const pick = (c: string) => {
palette?.remember(c)
onPick(c)
}
const groups = rows && palette
? ([
['book', BookOpen, palette.book],
['photos', Image, palette.photos],
['recent', History, palette.recent],
] as const).filter(([, , list]) => list.length > 0)
: []
return (
<div className="st-swatch-stack">
<div className="st-swatches">
{SWATCHES.map(c => <Swatch key={c} color={c} on={current === c} onPick={pick} />)}
{/* Anything at all, for a book that has to match a cover or a brand. */}
<label className="st-swatch st-swatch-custom">
{/* Named by the colour it holds, the way the preset swatches above are. */}
<input
type="color"
aria-label={value}
value={value}
onChange={e => { wheel.current = e.target.value; onPick(e.target.value) }}
onBlur={() => { if (wheel.current) palette?.remember(wheel.current); wheel.current = null }}
/>
</label>
</div>
{groups.map(([key, Icon, list]) => (
<div key={key} className="st-swatch-row" role="group" aria-label={palette?.labels[key]}>
<span className="st-swatch-row-icon" title={palette?.labels[key]} aria-hidden>
<Icon size={12} strokeWidth={2} />
</span>
<div className="st-swatches">
{list.map(c => <Swatch key={c} color={c} on={current === c} onPick={pick} />)}
</div>
</div>
))}
{/* Anything at all, for a book that has to match a cover or a brand. */}
<label className="st-swatch st-swatch-custom">
{/* Named by the colour it holds, the way the preset swatches above are. */}
<input type="color" aria-label={value} value={value} onChange={e => onPick(e.target.value)} />
</label>
{onAlpha && <AlphaSlider color={value} value={alpha ?? 1} onChange={onAlpha} label={palette?.labels.opacity} />}
</div>
)
}
/**
* How opaque the colour is, as a slider over the colour itself.
*
* The track runs from nothing to the full colour over a chequerboard, so the
* slider shows what it does rather than needing a label to explain it, and
* the figure beside it says the exact value for anyone matching a reference.
*/
export function AlphaSlider({ color, value, onChange, label }: {
color: string
value: number
onChange: (a: number, done: boolean) => void
/** The slider's accessible name, in the reader's language. */
label?: string
}) {
const pct = Math.round(value * 100)
// What the slider was last moved to: a controlled input may still show the old value when it is let go.
const moved = useRef(value)
const move = (raw: string) => {
moved.current = Math.max(0, Math.min(100, Number(raw))) / 100
onChange(moved.current, false)
}
const finish = () => onChange(moved.current, true)
return (
<div className="st-alpha">
<div className="st-alpha-track" style={{ ['--st-alpha-colour' as string]: color }}>
<input
type="range"
min={0}
max={100}
step={1}
value={pct}
aria-label={label}
onPointerDown={() => { moved.current = value }}
onKeyDown={() => { moved.current = value }}
onChange={e => move(e.target.value)}
onPointerUp={finish}
onKeyUp={finish}
/>
</div>
<span className="st-alpha-value">{pct}%</span>
</div>
)
}
@@ -5,8 +5,7 @@ import type {
} from '@trek/shared'
import { BOOK_METRICS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch } from '@trek/shared'
import { Swatches } from './StudioSwatches'
import { StudioFontPicker } from './StudioFontPicker'
import { WeightLine } from './StudioTypeControls'
import { FontButtons, WeightLine } from './StudioTypeControls'
import { sourceIdOf, useMapSources } from './mapSources'
import { printDpi, tileView } from './mapTiles'
import { useCartoApiKey } from '../../hooks/useTileUrl'
@@ -83,7 +82,7 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
* wrong typeface and no way to fix it.
*/}
<Section label={t('journey.studio.typography')} defaultOpen={false}>
<StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch as Partial<BookElement>)} t={t} />
<FontButtons font={el.font} weight={el.weight} onPick={patch => set(patch as Partial<BookElement>)} />
<div style={{ marginTop: 10 }}>
<WeightLine
font={el.font}
@@ -1,47 +1,50 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { italicTitle } from './fontPicker';
import { WeightLine } from './StudioTypeControls';
import { BOOK_FONT_ORDER } from './bookFonts';
import { FontButtons, WeightLine } from './StudioTypeControls';
const t = (k: string) => k;
describe('WeightLine', () => {
it('steps through only the weights the family has and says the drawn one by name and number', () => {
render(<WeightLine font="oswald" weight={700} onPick={vi.fn()} t={t} />);
expect(screen.getByText('journey.studio.weight')).toBeTruthy();
const slider = screen.getByRole('slider') as HTMLInputElement;
// Oswald ships 200 to 700: six stops, the last one drawn.
expect([slider.min, slider.max, slider.value]).toEqual(['0', '5', '5']);
expect(slider.getAttribute('aria-valuetext')).toBe('journey.studio.weight.700 700');
expect(screen.getByText('journey.studio.weight.700 700')).toBeTruthy();
describe('FontButtons', () => {
it('offers every bundled family in the picker order and marks the current one', () => {
render(<FontButtons font="serif" weight={400} onPick={vi.fn()} />);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(BOOK_FONT_ORDER.length);
expect(screen.getByRole('button', { name: 'Lora' }).className).toContain('is-on');
expect(screen.getByRole('button', { name: 'Poppins' }).className).not.toContain('is-on');
});
it('hands back the weight at the stop picked', () => {
it('keeps the weight when the new family ships it', () => {
const onPick = vi.fn();
render(<FontButtons font="sans" weight={700} onPick={onPick} />);
fireEvent.click(screen.getByRole('button', { name: 'Inter' }));
expect(onPick).toHaveBeenCalledWith({ font: 'inter', weight: 700 });
});
it('moves the weight to one the new family really has', () => {
const onPick = vi.fn();
render(<FontButtons font="sans" weight={700} onPick={onPick} />);
fireEvent.click(screen.getByRole('button', { name: 'Bebas Neue' }));
expect(onPick).toHaveBeenCalledWith({ font: 'bebas', weight: 400 });
});
});
describe('WeightLine', () => {
it('greys the weights the family does not ship and says why', () => {
render(<WeightLine font="bebas" weight={400} onPick={vi.fn()} t={t} />);
expect(screen.getByText('journey.studio.weight')).toBeTruthy();
const bold = screen.getByRole('radio', { name: '700' }) as HTMLButtonElement;
expect(bold.disabled).toBe(true);
expect(bold.title).toBe('journey.studio.weightMissing');
const regular = screen.getByRole('radio', { name: '400' }) as HTMLButtonElement;
expect(regular.disabled).toBe(false);
expect(regular.getAttribute('aria-checked')).toBe('true');
});
it('hands the picked weight back', () => {
const onPick = vi.fn();
render(<WeightLine font="sans" weight={400} onPick={onPick} t={t} />);
fireEvent.change(screen.getByRole('slider'), { target: { value: '0' } });
expect(onPick).toHaveBeenCalledWith(100);
fireEvent.change(screen.getByRole('slider'), { target: { value: '8' } });
expect(onPick).toHaveBeenLastCalledWith(900);
});
it('shows the one weight of a family that has one, with nothing to drag, and the weight it really draws', () => {
render(<WeightLine font="bebas" weight={700} onPick={vi.fn()} t={t} />);
expect(screen.queryByRole('slider')).toBeNull();
expect(screen.getByText('journey.studio.weightOnly')).toBeTruthy();
expect(screen.getByText('journey.studio.weight.400 400')).toBeTruthy();
});
it('reads a family this build does not know as the default one', () => {
render(<WeightLine font="gone" weight={500} onPick={vi.fn()} t={t} />);
expect((screen.getByRole('slider') as HTMLInputElement).value).toBe('4');
});
});
describe('italicTitle', () => {
it('says when the browser slants a family that has no italic of its own', () => {
expect(italicTitle('garamond', 400, t)).toBe('journey.studio.italic');
expect(italicTitle('sans', 400, t)).toBe('journey.studio.italicSlanted');
expect(italicTitle('playfair', 300, t)).toBe('journey.studio.italic');
fireEvent.click(screen.getByRole('radio', { name: '600' }));
expect(onPick).toHaveBeenCalledWith(600);
});
});
@@ -1,27 +1,58 @@
import type { BookWeight } from '@trek/shared';
import { BOOK_FONTS, nearestWeight, type BookFontId } from './bookFonts';
import { Line } from './StudioControls';
import type { BookTextElement } from '@trek/shared';
import type { BookFontId } from './bookFonts';
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts';
import { Choice, Line } from './StudioControls';
/**
* The weight line, shared by the text inspector and the travel inspector so
* both set type the same way. The typeface itself is picked in
* StudioFontPicker.tsx, the italic's title comes from fontPicker.ts.
* The typeface buttons and the weight line, shared by the text inspector and
* the travel inspector so both set type the same way.
*/
type T = (key: string) => string;
/** The weight set the book schema allows, taken from it so there is one list. */
type BookWeight = BookTextElement['weight'];
/** The weights a family ships, lightest first; Poppins' for a family this build does not know. */
const weightsOf = (font: string) => (BOOK_FONTS[font as BookFontId] ?? BOOK_FONTS.sans).weights;
const WEIGHTS = [400, 500, 600, 700] as const;
/**
* A slider over the weights the family really has, and only those.
*
* Nine chips from 100 to 900 with most of them greyed out read as a broken
* control for a family with two weights, and as a wall for one with nine.
* The slider steps through what exists, says the weight by its name and its
* number, and a family with a single weight shows that weight and nothing to
* drag.
*/
/** One button per bundled family, each set in its own face. */
export function FontButtons({
font,
weight,
onPick,
}: {
font: string;
weight: BookWeight;
onPick: (patch: { font: BookFontId; weight: BookWeight }) => void;
}) {
return (
<div className="st-fonts">
{BOOK_FONT_ORDER.map((id) => {
const face = BOOK_FONTS[id];
return (
<button
type="button"
key={id}
className={`st-font ${font === id ? 'is-on' : ''}`}
style={{ fontFamily: face.stack }}
onClick={() =>
onPick({
font: id,
// A family that does not ship this weight would render a
// synthesised bold, a smeared regular in print, so the
// weight moves to the nearest one it really has.
weight: nearestWeight(id, weight) as BookWeight,
})
}
title={face.name}
>
{face.name}
</button>
);
})}
</div>
);
}
/** The four weights, with the ones the family does not ship greyed out. */
export function WeightLine({
font,
weight,
@@ -29,36 +60,22 @@ export function WeightLine({
t,
}: {
font: string;
weight: number;
weight: BookWeight;
onPick: (weight: BookWeight) => void;
t: T;
t: (k: string) => string;
}) {
const weights = weightsOf(font);
const drawn = nearestWeight(font, weight);
const at = Math.max(0, weights.indexOf(drawn as BookWeight));
const word = `${t(`journey.studio.weight.${drawn}`)} ${drawn}`;
return (
<Line label={t('journey.studio.weight')}>
<div className="st-field st-weight">
{weights.length > 1 ? (
<input
type="range"
min={0}
max={weights.length - 1}
step={1}
value={at}
aria-label={t('journey.studio.weight')}
aria-valuetext={word}
onChange={(e) => {
const next = weights[Number(e.target.value)];
if (next !== undefined) onPick(next);
}}
/>
) : (
<span className="st-weight-only">{t('journey.studio.weightOnly')}</span>
)}
<span className="st-weight-value">{word}</span>
</div>
<Choice
value={weight}
options={WEIGHTS.map((w) => ({
value: w,
label: String(w),
disabled: !hasWeight(font, w),
title: hasWeight(font, w) ? undefined : t('journey.studio.weightMissing'),
}))}
onPick={onPick}
/>
</Line>
);
}
@@ -7,7 +7,7 @@ import {
Camera, CalendarDays, Footprints, Globe, MapPin, Navigation, Route,
type LucideIcon,
} from 'lucide-react'
import { fontStack, lighterWeight, nearestWeight } from './bookFonts'
import { fontStack } from './bookFonts'
import { haversineKm } from '../../utils/geo'
import { brightness } from './folioColour'
import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes'
@@ -140,8 +140,8 @@ export function TravelElementView({ el, frameStyle, big = false }: {
* `textScale` moves their size. Hard-coded 700s meant the only way to lighten a
* stats block was to not use one.
*/
const heavy = (el: { font: string; weight: number }) => nearestWeight(el.font, el.weight)
const light = (el: { font: string; weight: number }) => lighterWeight(el.font, el.weight)
const heavy = (el: { weight: number }) => el.weight
const light = (el: { weight: number }) => Math.max(400, el.weight - 100)
const MAP_PALETTES = {
minimal: { land: '#e9e5dc', border: '#d6d0c4', onDark: false },
+13 -183
View File
@@ -1,213 +1,43 @@
/**
* The @font-face rules behind the book's typefaces.
*
* The catalogue in @trek/shared (book-fonts.data.ts) names the families, the
* weights each ships and the weights it has a real italic for; this is where
* those faces arrive, one import per face. Poppins 300 to 700 and
* MuseoModerno 400, 700 and 800 come with the app chrome in main.tsx and are
* not repeated here: importing a face twice only duplicates every rule.
* bookFonts.ts names the seven families; this is where the faces they need
* arrive. Poppins comes with the app chrome in main.tsx and is not repeated
* here, and neither are MuseoModerno's 400 and 700: importing a face twice only
* duplicates every rule. Its 500 and 600 are the book's alone, which is why
* they are down here rather than up there.
*
* Pulled in by StudioShell rather than by main.tsx: nothing outside the editor
* is set in Lora or Anton, so the rules travel with the Studio chunk and cost
* the app's first paint nothing. Once that chunk is loaded they are ordinary
* stylesheets on the document, which is what lets printSheets copy them into
* the print frame with everything else.
* is set in Lora or Bebas Neue, so the faces travel with the Studio chunk and
* cost the app's first paint nothing. Once that chunk is loaded they are
* ordinary stylesheets on the document, which is what lets printSheets copy
* them into the print frame with everything else.
*
* A rule is not a download. The browser fetches a face's file only when text
* on the page is set in it, and only the subsets (latin, latin-ext, cyrillic
* and so on) whose characters that text uses, so a book set in two families
* loads two families however long this list grows. The print frame loads the
* same way, and the assistant's page snapshot embeds only the faces a page
* draws (snapshotFonts.ts).
*
* Real italics for the serifs, where the browser's slant reads as a mistake;
* the sans faces keep the synthetic oblique they always had, which saves a
* second file per weight nobody would tell apart.
*
* Big Shoulders Display is imported without `.css`: its package maps `./*` to
* `./*.css` and has no `./*.css` entry of its own, so `100.css` would resolve
* to `100.css.css` and fail the build.
* Upright only, as everywhere else in the app: the editor's italic toggle has
* always been the browser's synthetic slant, and a second file per weight is a
* lot of download to change that.
*/
import '@fontsource/poppins/100.css'
import '@fontsource/poppins/200.css'
import '@fontsource/poppins/800.css'
import '@fontsource/poppins/900.css'
import '@fontsource/inter/100.css'
import '@fontsource/inter/200.css'
import '@fontsource/inter/300.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
import '@fontsource/inter/600.css'
import '@fontsource/inter/700.css'
import '@fontsource/inter/800.css'
import '@fontsource/inter/900.css'
import '@fontsource/montserrat/100.css'
import '@fontsource/montserrat/200.css'
import '@fontsource/montserrat/300.css'
import '@fontsource/montserrat/400.css'
import '@fontsource/montserrat/500.css'
import '@fontsource/montserrat/600.css'
import '@fontsource/montserrat/700.css'
import '@fontsource/montserrat/800.css'
import '@fontsource/montserrat/900.css'
import '@fontsource/lora/400.css'
import '@fontsource/lora/500.css'
import '@fontsource/lora/600.css'
import '@fontsource/lora/700.css'
import '@fontsource/lora/400-italic.css'
import '@fontsource/lora/500-italic.css'
import '@fontsource/lora/600-italic.css'
import '@fontsource/lora/700-italic.css'
import '@fontsource/eb-garamond/400.css'
import '@fontsource/eb-garamond/500.css'
import '@fontsource/eb-garamond/600.css'
import '@fontsource/eb-garamond/700.css'
import '@fontsource/eb-garamond/800.css'
import '@fontsource/eb-garamond/400-italic.css'
import '@fontsource/eb-garamond/500-italic.css'
import '@fontsource/eb-garamond/600-italic.css'
import '@fontsource/eb-garamond/700-italic.css'
import '@fontsource/eb-garamond/800-italic.css'
import '@fontsource/playfair-display/400.css'
import '@fontsource/playfair-display/500.css'
import '@fontsource/playfair-display/600.css'
import '@fontsource/playfair-display/700.css'
import '@fontsource/playfair-display/800.css'
import '@fontsource/playfair-display/900.css'
import '@fontsource/playfair-display/400-italic.css'
import '@fontsource/playfair-display/500-italic.css'
import '@fontsource/playfair-display/600-italic.css'
import '@fontsource/playfair-display/700-italic.css'
import '@fontsource/playfair-display/800-italic.css'
import '@fontsource/playfair-display/900-italic.css'
import '@fontsource/fraunces/100.css'
import '@fontsource/fraunces/200.css'
import '@fontsource/fraunces/300.css'
import '@fontsource/fraunces/400.css'
import '@fontsource/fraunces/500.css'
import '@fontsource/fraunces/600.css'
import '@fontsource/fraunces/700.css'
import '@fontsource/fraunces/800.css'
import '@fontsource/fraunces/900.css'
import '@fontsource/fraunces/100-italic.css'
import '@fontsource/fraunces/200-italic.css'
import '@fontsource/fraunces/300-italic.css'
import '@fontsource/fraunces/400-italic.css'
import '@fontsource/fraunces/500-italic.css'
import '@fontsource/fraunces/600-italic.css'
import '@fontsource/fraunces/700-italic.css'
import '@fontsource/fraunces/800-italic.css'
import '@fontsource/fraunces/900-italic.css'
import '@fontsource/cormorant-garamond/300.css'
import '@fontsource/cormorant-garamond/400.css'
import '@fontsource/cormorant-garamond/500.css'
import '@fontsource/cormorant-garamond/600.css'
import '@fontsource/cormorant-garamond/700.css'
import '@fontsource/cormorant-garamond/300-italic.css'
import '@fontsource/cormorant-garamond/400-italic.css'
import '@fontsource/cormorant-garamond/500-italic.css'
import '@fontsource/cormorant-garamond/600-italic.css'
import '@fontsource/cormorant-garamond/700-italic.css'
import '@fontsource/dm-serif-display/400.css'
import '@fontsource/dm-serif-display/400-italic.css'
import '@fontsource/bodoni-moda/400.css'
import '@fontsource/bodoni-moda/500.css'
import '@fontsource/bodoni-moda/600.css'
import '@fontsource/bodoni-moda/700.css'
import '@fontsource/bodoni-moda/800.css'
import '@fontsource/bodoni-moda/900.css'
import '@fontsource/bodoni-moda/400-italic.css'
import '@fontsource/bodoni-moda/500-italic.css'
import '@fontsource/bodoni-moda/600-italic.css'
import '@fontsource/bodoni-moda/700-italic.css'
import '@fontsource/bodoni-moda/800-italic.css'
import '@fontsource/bodoni-moda/900-italic.css'
import '@fontsource/bebas-neue/400.css'
import '@fontsource/anton/400.css'
import '@fontsource/oswald/200.css'
import '@fontsource/oswald/300.css'
import '@fontsource/oswald/400.css'
import '@fontsource/oswald/500.css'
import '@fontsource/oswald/600.css'
import '@fontsource/oswald/700.css'
import '@fontsource/league-gothic/400.css'
import '@fontsource/big-shoulders-display/100'
import '@fontsource/big-shoulders-display/200'
import '@fontsource/big-shoulders-display/300'
import '@fontsource/big-shoulders-display/400'
import '@fontsource/big-shoulders-display/500'
import '@fontsource/big-shoulders-display/600'
import '@fontsource/big-shoulders-display/700'
import '@fontsource/big-shoulders-display/800'
import '@fontsource/big-shoulders-display/900'
import '@fontsource/archivo-black/400.css'
import '@fontsource/museomoderno/100.css'
import '@fontsource/museomoderno/200.css'
import '@fontsource/museomoderno/300.css'
import '@fontsource/museomoderno/500.css'
import '@fontsource/museomoderno/600.css'
import '@fontsource/museomoderno/900.css'
import '@fontsource/shrikhand/400.css'
import '@fontsource/alfa-slab-one/400.css'
import '@fontsource/bricolage-grotesque/200.css'
import '@fontsource/bricolage-grotesque/300.css'
import '@fontsource/bricolage-grotesque/400.css'
import '@fontsource/bricolage-grotesque/500.css'
import '@fontsource/bricolage-grotesque/600.css'
import '@fontsource/bricolage-grotesque/700.css'
import '@fontsource/bricolage-grotesque/800.css'
import '@fontsource/abril-fatface/400.css'
import '@fontsource/caveat/400.css'
import '@fontsource/caveat/500.css'
import '@fontsource/caveat/600.css'
import '@fontsource/caveat/700.css'
import '@fontsource/dancing-script/400.css'
import '@fontsource/dancing-script/500.css'
import '@fontsource/dancing-script/600.css'
import '@fontsource/dancing-script/700.css'
import '@fontsource/pacifico/400.css'
import '@fontsource/great-vibes/400.css'
import '@fontsource/sacramento/400.css'
import '@fontsource/kaushan-script/400.css'
import '@fontsource/allura/400.css'
import '@fontsource/parisienne/400.css'
import '@fontsource/satisfy/400.css'
import '@fontsource/yellowtail/400.css'
import '@fontsource/homemade-apple/400.css'
import '@fontsource/permanent-marker/400.css'
import '@fontsource/dm-mono/300.css'
import '@fontsource/dm-mono/400.css'
import '@fontsource/dm-mono/500.css'
import '@fontsource/bebas-neue/400.css'
@@ -1,79 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { blankBook, bookTextElementSchema, type BookDocument, type BookElement, type BookSpread } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useStudioStore } from '../../store/studioStore';
import { bookSample, faceSpec, useWarmBookFaces, warmBookFaces } from './bookFontWarm';
/**
* The open book's faces go into the offline cache as it opens
* (FE-STUDIO-FONTWARM): each face it uses is asked for once, with the book's
* own characters, and a face that does not load costs nothing.
*/
const frame = { x: 10, y: 10, w: 80, h: 20 };
const text = (id: string, words: string, over: Record<string, unknown> = {}) =>
bookTextElementSchema.parse({ id, kind: 'text', frame, text: words, ...over }) as BookElement;
function book(elements: BookElement[]): BookDocument {
const doc = blankBook({ title: 'Kraków', newId: (p) => `${p}-x` });
const [cover, ...rest] = doc.spreads as [BookSpread, ...BookSpread[]];
return { ...doc, spreads: [{ ...cover, elements }, ...rest] };
}
afterEach(() => {
useStudioStore.setState({ doc: null });
});
describe('the faces of a book', () => {
it('FE-STUDIO-FONTWARM-001: samples each character of the book once, the title and the figures with it', () => {
const sample = bookSample(book([text('t1', 'Łódź Łódź'), text('t2', ' ')]));
expect(sample).toContain('Ł');
expect(sample).toContain('ó');
expect(sample).toContain('K');
expect(sample.startsWith('0123456789')).toBe(true);
expect(new Set(sample).size).toBe(sample.length);
expect(sample).not.toContain(' ');
});
it('FE-STUDIO-FONTWARM-002: names a face the way document.fonts reads it', () => {
expect(faceSpec({ font: 'serif', weight: 700, italic: true })).toBe('italic 700 16px "Lora"');
expect(faceSpec({ font: 'caveat', weight: 400, italic: false })).toBe('400 16px "Caveat"');
});
it('FE-STUDIO-FONTWARM-003: asks for every face once, counts the ones that loaded and never rejects', async () => {
const doc = book([
text('t1', 'Porto', { font: 'caveat', weight: 700 }),
text('t2', 'Douro', { font: 'serif', italic: true }),
]);
const load = vi.fn(async (spec: string, _text?: string) => (spec.includes('Caveat') ? [{} as FontFace] : []));
expect(await warmBookFaces(doc, { load })).toBe(1);
const specs = load.mock.calls.map(([spec]) => spec);
expect(specs).toEqual(expect.arrayContaining(['700 16px "Caveat"', 'italic 400 16px "Lora"']));
expect(load.mock.calls[0]?.[1]).toContain('P');
const broken = vi.fn(async () => {
throw new Error('offline');
});
expect(await warmBookFaces(doc, { load: broken })).toBe(0);
expect(await warmBookFaces(doc, undefined)).toBe(0);
});
it('FE-STUDIO-FONTWARM-004: warms when the set of faces changes, not on every edit of the words', async () => {
const load = vi.fn(async () => [] as FontFace[]);
Object.defineProperty(document, 'fonts', { configurable: true, value: { load } });
const doc = book([text('t1', 'Porto', { font: 'anton' })]);
act(() => useStudioStore.setState({ doc }));
renderHook(() => useWarmBookFaces());
await act(async () => {});
const first = load.mock.calls.length;
expect(first).toBeGreaterThan(0);
const retyped = book([text('t1', 'Porto, again', { font: 'anton' })]);
await act(async () => useStudioStore.setState({ doc: retyped }));
expect(load.mock.calls.length).toBe(first);
const another = book([text('t1', 'Porto', { font: 'anton' }), text('t2', 'Lisboa', { font: 'vibes' })]);
await act(async () => useStudioStore.setState({ doc: another }));
expect(load.mock.calls.length).toBeGreaterThan(first);
});
});
@@ -1,64 +0,0 @@
import { BOOK_FONT_FACES, bookFacesUsed, type BookDocument, type BookFaceUse } from '@trek/shared';
import { useEffect, useMemo } from 'react';
import { useStudioStore } from '../../store/studioStore';
/**
* The faces of the open book, loaded as soon as it opens, so it reads in its
* own type offline as well.
*
* The service worker installs the latin files of the seven families the book
* shipped with (vite.config.js); every other face is cached by its runtime
* rule the first time it is fetched. A face the browser only fetches once
* text is drawn in it would be cached only for the pages someone happened to
* look at, so a book opened online and read offline later would show its
* third chapter in the fallback. Asking for every face the book uses, with the
* characters it sets, fetches each one now, through the worker, into that
* cache: a face nobody uses is never downloaded.
*/
/** The book's own characters, for asking the right subsets of each face: its words, its title, the figures. */
export function bookSample(doc: BookDocument): string {
const seen = new Set<string>('0123456789');
const take = (s: string) => {
for (const ch of s) if (ch.trim()) seen.add(ch);
};
take(doc.title);
for (const spread of doc.spreads) {
for (const el of spread.elements) if (el.kind === 'text') take(el.text);
}
// A face's subsets are chosen by its unicode ranges, so one character of each is enough; the cap keeps it short.
return [...seen].slice(0, 512).join('');
}
/** One face as the CSS font shorthand `document.fonts.load` takes. */
export function faceSpec(use: BookFaceUse): string {
const family = BOOK_FONT_FACES[use.font].name;
return `${use.italic ? 'italic ' : ''}${use.weight} 16px "${family}"`;
}
type FontLoader = Pick<FontFaceSet, 'load'>;
/** Fetch every face the book needs; resolves with how many loaded, never rejects. */
export async function warmBookFaces(
doc: BookDocument,
fonts: FontLoader | undefined = globalThis.document?.fonts
): Promise<number> {
if (!fonts) return 0;
const sample = bookSample(doc);
const loads = await Promise.allSettled(bookFacesUsed(doc).map((use) => fonts.load(faceSpec(use), sample)));
return loads.filter((r) => r.status === 'fulfilled' && r.value.length > 0).length;
}
/**
* Warms the faces of the book the Studio holds whenever the set of faces it
* uses changes; typing into a text does not, so an edit costs nothing here.
*/
export function useWarmBookFaces(): void {
const doc = useStudioStore((s) => s.doc);
const key = useMemo(() => (doc ? bookFacesUsed(doc).map(faceSpec).sort().join('|') : ''), [doc]);
useEffect(() => {
const current = useStudioStore.getState().doc;
// A face that will not load is drawn in its fallback, which the picker already shows; nothing to tell anyone.
if (key && current) void warmBookFaces(current);
}, [key]);
}
+105 -38
View File
@@ -1,51 +1,118 @@
import {
BOOK_FONT_CATEGORIES, BOOK_FONT_FACES, BOOK_FONTS_IDS, bookFontHasItalic, bookFontHasWeight, bookLighterWeight,
nearestBookWeight,
type BookFontCategory, type BookFontFace, type BookFontFamily,
} from '@trek/shared'
/**
* The book's typefaces, as the Studio draws them.
* The book's typefaces.
*
* ── Why this file exists ──────────────────────────────────────────────────
*
* The font stack used to name Georgia for its serif. Georgia is a system font:
* it is on most desktops and on no Linux container, so the print renderer would
* quietly substitute something else and a book proofread in one typeface would
* print in another.
* print in another. The existing Journey PDF export has the same bug today — it
* asks for Inter while only Poppins, Geist and MuseoModerno are bundled.
*
* So every family is self-hosted through @fontsource, OFL or Apache 2.0, and
* So every family here is self-hosted through @fontsource, all of them OFL, and
* every stack ends in a generic rather than in another named font nobody
* shipped. What the editor draws is what the renderer has.
*
* The catalogue itself (names, stacks, weights, italics, roles) lives in
* @trek/shared (book-fonts.data.ts), because the findings, the design engine
* and the assistant read the same families. This file only gives the Studio
* its view of it; the faces are loaded next door in bookFontFaces.ts.
* The faces themselves are loaded next door in bookFontFaces.ts. A family named
* here and not imported there falls back to exactly the system font this file
* was written against, which is how five of the seven shipped (#2183).
*
* ── Why these seven ───────────────────────────────────────────────────────
*
* A photo book needs more than one voice and fewer than a menu. Two workhorse
* sans faces, three serifs across the range from bookish to high-contrast, and
* two display faces for a cover. Anything past that is a font picker rather
* than a design decision.
*/
export type BookFontId = BookFontFamily
export interface BookFont extends BookFontFace {
export interface BookFont {
id: BookFontId
/** What it is called, shown as itself in the picker. */
name: string
/** The CSS stack. Ends generic — never in a font that is not bundled. */
stack: string
/** Where it belongs, for grouping the picker. */
kind: 'sans' | 'serif' | 'display'
/**
* Weights actually loaded. Asking for 700 on a family that ships one weight
* gets a synthesised bold, which prints as a smeared version of the regular.
*/
weights: number[]
}
export const BOOK_FONTS = Object.fromEntries(
BOOK_FONTS_IDS.map(id => [id, { id, ...BOOK_FONT_FACES[id] }]),
) as unknown as Record<BookFontId, BookFont>
export type BookFontId =
| 'sans' | 'serif' | 'display'
| 'inter' | 'garamond' | 'playfair' | 'bebas'
/**
* `sans`, `serif` and `display` keep their names because documents already
* contain them — the three original slots, now pointing at bundled families
* rather than at whatever the machine happened to have.
*/
export const BOOK_FONTS: Record<BookFontId, BookFont> = {
sans: {
id: 'sans',
name: 'Poppins',
stack: '"Poppins", system-ui, sans-serif',
kind: 'sans',
weights: [400, 500, 600, 700],
},
inter: {
id: 'inter',
name: 'Inter',
stack: '"Inter", system-ui, sans-serif',
kind: 'sans',
weights: [400, 500, 600, 700],
},
serif: {
id: 'serif',
name: 'Lora',
stack: '"Lora", Georgia, serif',
kind: 'serif',
weights: [400, 500, 600, 700],
},
garamond: {
id: 'garamond',
name: 'EB Garamond',
stack: '"EB Garamond", Georgia, serif',
kind: 'serif',
weights: [400, 500, 600, 700],
},
playfair: {
id: 'playfair',
name: 'Playfair Display',
stack: '"Playfair Display", Georgia, serif',
kind: 'serif',
weights: [400, 500, 600, 700],
},
display: {
id: 'display',
name: 'MuseoModerno',
stack: '"MuseoModerno", "Poppins", system-ui, sans-serif',
kind: 'display',
weights: [400, 500, 600, 700],
},
bebas: {
id: 'bebas',
name: 'Bebas Neue',
stack: '"Bebas Neue", Impact, sans-serif',
kind: 'display',
// One weight only. Asking for bold gets a synthesised one, which is why
// the inspector greys the weights this family does not have.
weights: [400],
},
}
// Geist Sans is deliberately absent: it is TREK's own subtext face, part of the
// app's voice rather than the book's, and a book set in the interface font
// reads as a screenshot of the app.
/** The picker's order: by category (sans first, mono last), the catalogue's order within one. */
export const BOOK_FONT_ORDER: BookFontId[] = BOOK_FONT_CATEGORIES.flatMap(category =>
BOOK_FONTS_IDS.filter(id => BOOK_FONT_FACES[id].category === category))
/** The families of one picker group, in picker order. */
export function fontsIn(category: BookFontCategory): BookFontId[] {
return BOOK_FONT_ORDER.filter(id => BOOK_FONTS[id].category === category)
}
/** The picker's order: workhorses first, display last. */
export const BOOK_FONT_ORDER: BookFontId[] = [
'sans', 'inter',
'serif', 'garamond', 'playfair',
'display', 'bebas',
]
/**
* The CSS stack for a font id.
@@ -59,20 +126,20 @@ export function fontStack(id: string): string {
}
/** Whether a family actually ships the weight being asked for. */
export const hasWeight = bookFontHasWeight
/** Whether a family has a real italic at that weight, rather than the browser's slant. */
export const hasItalic = bookFontHasItalic
export function hasWeight(id: string, weight: number): boolean {
const font = BOOK_FONTS[id as BookFontId]
return font ? font.weights.includes(weight) : true
}
/**
* The nearest weight a family really has.
*
* A synthesised bold is a smeared regular in print, so a family with one weight
* gets that weight rather than a request the renderer has to fake. The
* renderer draws every element through this as well, so a book whose weight
* was set before its family changed still prints in a face that exists.
* gets that weight rather than a request the renderer has to fake.
*/
export const nearestWeight = nearestBookWeight
/** The lighter weight a travel element sets beside its figures, one the family has. */
export const lighterWeight = bookLighterWeight
export function nearestWeight(id: string, weight: number): number {
const font = BOOK_FONTS[id as BookFontId]
if (!font || font.weights.includes(weight)) return weight
return font.weights.reduce((best, w) =>
(Math.abs(w - weight) < Math.abs(best - weight) ? w : best), font.weights[0])
}
@@ -1,47 +0,0 @@
import { BOOK_FONT_CATEGORIES, type BookFontCategory, type BookWeight } from '@trek/shared';
import { BOOK_FONTS, fontsIn, hasItalic, nearestWeight, type BookFontId } from './bookFonts';
/**
* The type controls' decisions, apart from their markup: which families a
* search finds, the weight a newly picked family is set at, and what the row
* and the italic button say. StudioFontPicker.tsx and StudioTypeControls.tsx
* draw them.
*/
export type FontT = (key: string, params?: Record<string, string | number>) => string;
export interface FontPick {
font: BookFontId;
weight: BookWeight;
}
/** The family, its weight moved onto one the new family has. */
export function pickFont(id: BookFontId, weight: number): FontPick {
// A family that does not ship this weight would render a synthesised bold,
// a smeared regular in print, so the weight moves to the nearest one it has.
return { font: id, weight: nearestWeight(id, weight) as BookWeight };
}
/** The families that match a search, by name, id or group, in picker order. */
export function matchFonts(query: string, groupName: (c: BookFontCategory) => string): BookFontId[] {
const q = query.trim().toLowerCase();
return BOOK_FONT_CATEGORIES.flatMap((category) => {
const group = groupName(category).toLowerCase();
return fontsIn(category).filter(
(id) => !q || BOOK_FONTS[id].name.toLowerCase().includes(q) || id.includes(q) || group.includes(q)
);
});
}
/** The weights a family has, as its row says them: one number, or the first and the last. */
export function weightSpan(id: BookFontId, t: FontT): string {
const ws = BOOK_FONTS[id].weights;
const first = ws[0] ?? 400;
const last = ws[ws.length - 1] ?? first;
return first === last ? String(first) : t('journey.studio.fonts.range', { from: first, to: last });
}
/** The italic button's words: plain where the family has a real italic, the reason where the browser slants it. */
export function italicTitle(font: string, weight: number, t: FontT): string {
return hasItalic(font, nearestWeight(font, weight)) ? t('journey.studio.italic') : t('journey.studio.italicSlanted');
}
@@ -1,5 +1,4 @@
import { ASSISTANT_PAGE_IMAGE, type BookPageSetup, type BookSpread } from '@trek/shared';
import { snapshotFontCss } from './snapshotFonts';
/**
* One Studio page as a JPEG, for the assistant to look at (journey-studio.md
@@ -127,8 +126,6 @@ export async function snapshotSpread(
hiddenPhotoIds: ReadonlySet<number>
): Promise<PageSnapshot | null> {
await picturesLoaded(root, hiddenPhotoIds);
// Only the faces the page draws travel with it (snapshotFonts.ts); without any, the library looks for itself.
const fontCss = await snapshotFontCss(root);
// A single page is drawn up to the long edge too: a larger scale renders the page sharper, it stretches no pixels.
const scale = ASSISTANT_PAGE_IMAGE.longEdge / Math.max(size.width, size.height);
const { domToBlob } = await import('modern-screenshot');
@@ -140,7 +137,6 @@ export async function snapshotSpread(
quality: ASSISTANT_PAGE_IMAGE.jpegQuality,
filter: (node) => keepNode(node, hiddenPhotoIds),
timeout: LOAD_TIMEOUT_MS,
...(fontCss ? { font: { cssText: fontCss } } : {}),
fetch: { requestInit: { credentials: 'same-origin', cache: 'force-cache' } },
});
if (image.size > ASSISTANT_PAGE_IMAGE.maxBytes) return null;
@@ -0,0 +1,91 @@
import { useRef, type KeyboardEvent, type PointerEvent } from 'react';
import { dialAngle, normAngle } from './paintEdit';
/**
* An angle, set by turning a dial.
*
* A number field alone asks people to know that a gradient running to the
* bottom right is 135 degrees; a dial lets them point at it. It snaps to the
* eight compass angles when the pointer passes close to one (Alt turns the
* snap off), and takes the arrow keys, Shift for steps of fifteen.
*
* `done` is false while the dial is turned and true on the value it is let go
* on, so the turn is one undo step (usePaintChannel.ts).
*/
export function AngleDial({
value,
onChange,
label,
}: {
value: number;
onChange: (angle: number, done: boolean) => void;
label: string;
}) {
const dial = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
const last = useRef(value);
const read = (e: PointerEvent<HTMLDivElement>) => {
const box = dial.current?.getBoundingClientRect();
if (!box) return last.current;
return dialAngle(e.clientX - (box.left + box.width / 2), e.clientY - (box.top + box.height / 2), e.altKey);
};
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.currentTarget.setPointerCapture?.(e.pointerId);
dragging.current = true;
last.current = read(e);
onChange(last.current, false);
};
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
const a = read(e);
if (a === last.current) return;
last.current = a;
onChange(a, false);
};
const onPointerUp = () => {
if (!dragging.current) return;
dragging.current = false;
onChange(last.current, true);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const step = e.shiftKey ? 15 : 1;
const by =
e.key === 'ArrowRight' || e.key === 'ArrowUp' ? step : e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -step : 0;
if (!by) return;
e.preventDefault();
last.current = normAngle(value + by);
onChange(last.current, false);
};
const onKeyUp = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key.startsWith('Arrow')) onChange(last.current, true);
};
return (
<div
ref={dial}
className="st-dial"
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={359}
aria-valuenow={normAngle(value)}
aria-valuetext={`${normAngle(value)}°`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
<span className="st-dial-needle" style={{ transform: `rotate(${normAngle(value)}deg)` }} aria-hidden>
<span className="st-dial-knob" />
</span>
</div>
);
}
@@ -0,0 +1,129 @@
import type { BookGradientPaint } from '@trek/shared';
import { useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
import { addStop, cssStops, removeStop, updateStop } from './paintEdit';
/**
* The stops of a gradient, on a bar that shows the gradient itself.
*
* Click the bar to add a stop there, in the colour the gradient already has
* at that point, so adding one changes nothing until it is moved or recoloured.
* Drag a stop along the bar to move it, or pull it off the bar downwards
* (it fades) and let go to take it out; the arrow keys move the chosen one, Delete removes it. A
* gradient keeps at least two stops, so the last two cannot be pulled off.
*
* The bar runs left to right in every language, like the gradient preview
* it carries, so it sets its own direction (studio-paint.css).
*/
/** How far below the bar a stop is pulled before letting go removes it, in pixels. */
const PULL_OFF = 28;
export function GradientStopBar({
paint,
selected,
onSelect,
onChange,
labels,
}: {
paint: BookGradientPaint;
selected: number;
onSelect: (index: number) => void;
onChange: (paint: BookGradientPaint, done: boolean) => void;
labels: { bar: string; stop: (pos: number) => string };
}) {
const bar = useRef<HTMLDivElement>(null);
const drag = useRef<{ index: number; moved: boolean; off: boolean; paint: BookGradientPaint } | null>(null);
// The stop being pulled off, drawn faded until it is let go, when it goes.
const [leaving, setLeaving] = useState<number | null>(null);
const canRemove = paint.stops.length > 2;
const posOf = (clientX: number) => {
const box = bar.current?.getBoundingClientRect();
if (!box || box.width <= 0) return 0;
return (clientX - box.left) / box.width;
};
const onBarDown = (e: PointerEvent<HTMLDivElement>) => {
if (e.button !== 0 || e.target !== e.currentTarget) return;
const added = addStop(paint, posOf(e.clientX));
if (!added) return;
onSelect(added.index);
onChange(added.paint, true);
};
const onStopDown = (index: number) => (e: PointerEvent<HTMLButtonElement>) => {
if (e.button !== 0) return;
e.stopPropagation();
e.currentTarget.setPointerCapture?.(e.pointerId);
onSelect(index);
drag.current = { index, moved: false, off: false, paint };
};
const onStopMove = (e: PointerEvent<HTMLButtonElement>) => {
const d = drag.current;
if (!d) return;
const box = bar.current?.getBoundingClientRect();
const off = canRemove && !!box && e.clientY > box.bottom + PULL_OFF;
const next = updateStop(d.paint, d.index, { at: posOf(e.clientX) });
drag.current = { ...d, moved: true, off, paint: next };
setLeaving(off ? d.index : null);
onChange(next, false);
};
const onStopUp = () => {
const d = drag.current;
drag.current = null;
setLeaving(null);
if (!d?.moved) return;
if (d.off) {
onSelect(Math.max(0, d.index - 1));
onChange(removeStop(d.paint, d.index), true);
return;
}
onChange(d.paint, true);
};
const onStopKey = (index: number) => (e: KeyboardEvent<HTMLButtonElement>) => {
const stop = paint.stops[index];
if (!stop) return;
if ((e.key === 'Delete' || e.key === 'Backspace') && canRemove) {
e.preventDefault();
onSelect(Math.max(0, index - 1));
onChange(removeStop(paint, index), true);
return;
}
const step = e.shiftKey ? 0.1 : 0.01;
const by = e.key === 'ArrowRight' ? step : e.key === 'ArrowLeft' ? -step : 0;
if (!by) return;
e.preventDefault();
onChange(updateStop(paint, index, { at: stop.at + by }), true);
};
return (
<div className="st-stopbar-wrap">
<div ref={bar} className="st-stopbar" title={labels.bar} aria-label={labels.bar} onPointerDown={onBarDown}>
<span
className="st-stopbar-fill"
style={{ backgroundImage: `linear-gradient(90deg, ${cssStops(paint.stops)})` }}
aria-hidden
/>
{paint.stops.map((stop, i) => (
<button
type="button"
// The index is the stop: two stops may share a colour and a position.
key={i}
className={`st-stop ${i === selected ? 'is-on' : ''} ${leaving === i ? 'is-leaving' : ''}`}
style={{ ['--at' as string]: `${Math.round(stop.at * 1000) / 10}%`, ['--stop' as string]: stop.color }}
aria-label={labels.stop(Math.round(stop.at * 100))}
aria-pressed={i === selected}
onPointerDown={onStopDown(i)}
onPointerMove={onStopMove}
onPointerUp={onStopUp}
onPointerCancel={onStopUp}
onKeyDown={onStopKey(i)}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,60 @@
import type { BookPaint, BookShapeElement } from '@trek/shared';
import { useMemo, type CSSProperties } from 'react';
import { HOLED_SHAPES } from '../shapes';
import { shapeOutline, strokeDash } from './paintGeometry';
import { PaintedPath } from './paintRender';
/**
* A shape that carries a paint, drawn wholly in SVG.
*
* The shapes from before paint stay as SpreadView draws them, the rectangle
* and the ellipse as CSS boxes, so no book that exists changes by a pixel.
* A painted shape is one path whatever its kind: a gradient and a pattern
* need an outline to lie over and to be cut to, and a rectangle with a radius
* is as much an outline as a heart is.
*
* As in SpreadView, the path is drawn into the frame inset by half the
* stroke, so the whole stroke sits inside the frame the editor snapped.
*/
export function PaintedShapeView({
el,
paint,
frameStyle,
}: {
el: BookShapeElement;
paint: BookPaint;
frameStyle: CSSProperties;
}) {
const sw = el.stroke ? el.strokeWidth : 0;
const w = Math.max(0.01, el.frame.w - sw);
const h = Math.max(0.01, el.frame.h - sw);
const d = useMemo(() => shapeOutline(el, w, h, sw / 2), [el, w, h, sw]);
const box = useMemo(() => ({ x: 0, y: 0, w, h }), [w, h]);
return (
<div style={frameStyle}>
<svg
width={`${el.frame.w}mm`}
height={`${el.frame.h}mm`}
viewBox={`0 0 ${el.frame.w} ${el.frame.h}`}
style={{ display: 'block', overflow: 'visible' }}
>
<g transform={sw ? `translate(${sw / 2} ${sw / 2})` : undefined}>
<PaintedPath
d={d}
paint={paint}
box={box}
idPrefix={el.id}
fillRule={HOLED_SHAPES.has(el.shape) ? 'evenodd' : undefined}
stroke={el.stroke ?? undefined}
strokeWidth={sw || undefined}
strokeDasharray={strokeDash(el.strokeStyle, sw)}
strokeLinecap={el.strokeStyle === 'dotted' ? 'round' : undefined}
strokeLinejoin="round"
/>
</g>
</svg>
</div>
);
}
@@ -0,0 +1,61 @@
import { BOOK_PATTERNS, type BookPattern, type BookPatternPaint } from '@trek/shared';
import { useMemo } from 'react';
import { PATTERN_ANGLE } from './paintEdit';
import { patternGeometry } from './patterns';
/**
* The twelve repeats, each drawn as a tile in the paint's own colours.
*
* Drawn by the same generator the page uses, at a smaller scale, so a tile
* is a true preview of what choosing it does rather than an icon of it, and
* a repeat that looks wrong in the tile looks wrong on the page too.
*/
const TILE = 24;
function PatternTile({ pattern, fg, bg }: { pattern: BookPattern; fg: string; bg: string | null }) {
const geo = useMemo(
() =>
patternGeometry({ pattern, scale: 0.42, angle: PATTERN_ANGLE[pattern] ?? 0 }, { x: 0, y: 0, w: TILE, h: TILE }),
[pattern]
);
return (
<svg viewBox={`0 0 ${TILE} ${TILE}`} className="st-pattern-tile-art" aria-hidden>
{bg && <rect width={TILE} height={TILE} fill={bg} />}
<g transform={geo.transform}>
{geo.layers.map((layer, i) => (
<path key={i} d={layer.d} fill={fg} fillOpacity={layer.opacity < 1 ? layer.opacity : undefined} />
))}
</g>
</svg>
);
}
export function PatternSwatches({
value,
onPick,
label,
}: {
value: BookPatternPaint;
onPick: (pattern: BookPattern) => void;
label: (pattern: BookPattern) => string;
}) {
return (
<div className="st-pattern-grid" role="radiogroup">
{BOOK_PATTERNS.map((pattern) => (
<button
type="button"
key={pattern}
role="radio"
aria-checked={value.pattern === pattern}
aria-label={label(pattern)}
title={label(pattern)}
className={`st-pattern-tile ${value.pattern === pattern ? 'is-on' : ''} ${value.bg ? '' : 'is-clear'}`}
onClick={() => onPick(pattern)}
>
<PatternTile pattern={pattern} fg={value.fg} bg={value.bg} />
</button>
))}
</div>
);
}
@@ -0,0 +1,86 @@
import type { BookGradientPaint } from '@trek/shared';
import { useRef, type KeyboardEvent, type PointerEvent } from 'react';
import { clampUnit, cssStops } from './paintEdit';
/**
* Where a radial gradient starts: a small preview of it with its centre on
* it, to drag. The preview is the gradient itself, so the point being moved
* is the bright spot or the dark one people are actually placing.
*
* Like the stop bar it is a picture, laid out left to right in every
* language (studio-paint.css), with the arrow keys for a step of 2 %.
*/
export function RadialPad({
paint,
onChange,
label,
}: {
paint: Extract<BookGradientPaint, { type: 'radial' }>;
onChange: (paint: BookGradientPaint, done: boolean) => void;
label: string;
}) {
const pad = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
const last = useRef(paint);
const at = (e: PointerEvent<HTMLDivElement>) => {
const box = pad.current?.getBoundingClientRect();
if (!box || box.width <= 0 || box.height <= 0) return paint;
return {
...paint,
cx: clampUnit((e.clientX - box.left) / box.width),
cy: clampUnit((e.clientY - box.top) / box.height),
};
};
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.currentTarget.setPointerCapture?.(e.pointerId);
dragging.current = true;
last.current = at(e);
onChange(last.current, false);
};
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
last.current = at(e);
onChange(last.current, false);
};
const onPointerUp = () => {
if (!dragging.current) return;
dragging.current = false;
onChange(last.current, true);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const dx = e.key === 'ArrowRight' ? 0.02 : e.key === 'ArrowLeft' ? -0.02 : 0;
const dy = e.key === 'ArrowDown' ? 0.02 : e.key === 'ArrowUp' ? -0.02 : 0;
if (!dx && !dy) return;
e.preventDefault();
onChange({ ...paint, cx: clampUnit(paint.cx + dx), cy: clampUnit(paint.cy + dy) }, true);
};
return (
<div
ref={pad}
className="st-radial-pad"
role="slider"
tabIndex={0}
aria-label={label}
aria-valuetext={`${Math.round(paint.cx * 100)}% ${Math.round(paint.cy * 100)}%`}
style={{
backgroundImage: `radial-gradient(circle at ${paint.cx * 100}% ${paint.cy * 100}%, ${cssStops(paint.stops)})`,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
>
<span
className="st-radial-dot"
style={{ ['--cx' as string]: `${paint.cx * 100}%`, ['--cy' as string]: `${paint.cy * 100}%` }}
aria-hidden
/>
</div>
);
}
@@ -0,0 +1,101 @@
import { bookColours, type BookDocument, type BookSpread, type PaletteSwatch } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { useStudioStore } from '../../../store/studioStore';
import { photoSrc } from '../bookRender';
import {
StudioPaletteContext,
readRecent,
spreadPhotoIds,
withRecent,
writeRecent,
type StudioPaletteValue,
} from './paletteModel';
import { photoSwatches, readPhotoPalette } from './photoPalette';
/**
* The colours every swatch row in the inspector offers beside its fixed inks:
* the book's own, the ones in the page's photographs, and the ones this
* person picked lately.
*
* A context rather than props, because the swatch rows sit at every depth of
* the inspector, the travel elements' panels included, and each of them
* should offer the same three rows without every caller passing them down.
*/
/** The palettes of the page's photos, read once per photo for as long as the inspector is open. */
function usePhotoColours(photoIds: readonly number[]): string[] {
const [read, setRead] = useState<ReadonlyMap<number, PaletteSwatch[]>>(() => new Map());
// The ids travel as one string, so a new array holding the same photos reads nothing again.
const key = photoIds.join(',');
useEffect(() => {
const ids = key ? key.split(',').map(Number) : [];
const missing = ids.filter((id) => !read.has(id));
if (!missing.length) return;
const ctrl = new AbortController();
void Promise.all(
missing.map(async (id) => [id, await readPhotoPalette(photoSrc(id, false), ctrl.signal)] as const)
).then((found) => {
if (ctrl.signal.aborted) return;
setRead((prev) => new Map([...prev, ...found]));
});
return () => ctrl.abort();
}, [key, read]);
return useMemo(() => {
const ids = key ? key.split(',').map(Number) : [];
return photoSwatches(ids.map((id) => read.get(id) ?? []));
}, [key, read]);
}
export function StudioPaletteProvider({
doc,
spread,
labels,
children,
}: {
doc: BookDocument | null;
spread: BookSpread | undefined;
labels: StudioPaletteValue['labels'];
children: ReactNode;
}) {
const book = useMemo(() => bookColours(doc), [doc]);
const photos = usePhotoColours(useMemo(() => spreadPhotoIds(spread), [spread]));
const [recent, setRecent] = useState(readRecent);
const remember = useCallback((hex: string) => {
setRecent((list) => {
const next = withRecent(list, hex);
writeRecent(next);
return next;
});
}, []);
const value = useMemo(() => ({ book, photos, recent, remember, labels }), [book, photos, recent, remember, labels]);
return <StudioPaletteContext.Provider value={value}>{children}</StudioPaletteContext.Provider>;
}
/** The provider for the open book: the inspector of the page being edited sits inside it. */
export function StudioPaletteScope({
spreadIndex,
t,
children,
}: {
spreadIndex: number;
t: (k: string) => string;
children: ReactNode;
}) {
const doc = useStudioStore((s) => s.doc);
const labels = useMemo(
() => ({
book: t('journey.studio.paint.bookColours'),
photos: t('journey.studio.paint.photoColours'),
recent: t('journey.studio.paint.recentColours'),
opacity: t('journey.studio.paint.opacity'),
}),
[t]
);
return (
<StudioPaletteProvider doc={doc} spread={doc?.spreads[spreadIndex]} labels={labels}>
{children}
</StudioPaletteProvider>
);
}
@@ -0,0 +1,105 @@
import {
MAX_SPREAD_ELEMENTS,
bookPhotoElementSchema,
spreadPaint,
withSpreadPaint,
type BookDocument,
type BookElement,
type BookPageSetup,
type BookPhotoElement,
type BookSpread,
} from '@trek/shared';
/**
* The page background's two moves beyond its paint: a photo laid under the
* whole page, and one page's background given to every page.
*
* A background photo is an ordinary photo element, not a field of the page:
* full bleed across the spread, at the back, and locked, so it does not come
* away under a click meant for what is on top of it. Being an element, it
* prints, crops, filters and undoes like every other picture, and the
* assistant reaches it with the operations it already has (add, fill, lock).
*/
/** The frame of a photo that covers the whole sheet, bleed included. */
export function bleedFrame(spread: BookSpread, page: BookPageSetup) {
const pages = spread.role === 'inner' ? 2 : 1;
return {
x: -page.bleed,
y: -page.bleed,
w: page.pageWidth * pages + page.bleed * 2,
h: page.pageHeight + page.bleed * 2,
};
}
const near = (a: number, b: number) => Math.abs(a - b) < 0.6;
/** The page's background photo: the locked picture at the back that covers the whole sheet. Null when it has none. */
export function backgroundPhotoOf(spread: BookSpread | undefined, page: BookPageSetup): BookPhotoElement | null {
const back = spread?.elements[0];
if (!spread || !back || back.kind !== 'photo' || !back.locked || back.photoId == null) return null;
const want = bleedFrame(spread, page);
const f = back.frame;
return near(f.x, want.x) && near(f.y, want.y) && near(f.w, want.w) && near(f.h, want.h) && !back.rotation
? back
: null;
}
/**
* The page with `photoId` as its background: the one it had is replaced, a
* new one goes in at the back. Null when the page is already full.
*/
export function withBackgroundPhoto(
spread: BookSpread,
page: BookPageSetup,
photoId: number,
newId: () => string
): BookSpread | null {
const old = backgroundPhotoOf(spread, page);
const rest = old ? spread.elements.slice(1) : spread.elements;
if (rest.length >= MAX_SPREAD_ELEMENTS) return null;
const photo = bookPhotoElementSchema.parse({
id: old?.id ?? newId(),
kind: 'photo',
frame: bleedFrame(spread, page),
locked: true,
photoId,
fit: 'cover',
});
return { ...spread, elements: [photo as BookElement, ...rest] };
}
/** The page without its background photo. */
export function withoutBackgroundPhoto(spread: BookSpread, page: BookPageSetup): BookSpread {
return backgroundPhotoOf(spread, page) ? { ...spread, elements: spread.elements.slice(1) } : spread;
}
const isCover = (spread: BookSpread) => spread.role === 'cover' || spread.role === 'back';
/**
* Every page given the background of page `from`: its paint and colour,
* nothing else. "Every page" is every page of its kind: an inside page
* gives its background to the inside of the book, a cover to the covers,
* since a cover is designed on its own and an inside page's paper is rarely
* what the cover wants.
*/
export function backgroundToAll(doc: BookDocument, from: number): BookDocument {
const source = doc.spreads[from];
if (!source) return doc;
const paint = spreadPaint(source);
const kind = isCover(source);
return {
...doc,
spreads: doc.spreads.map((sp, i) => (i === from || isCover(sp) !== kind ? sp : withSpreadPaint(sp, paint))),
};
}
/** The pictures offered as a background: the page's own first, then its entry's, then the gallery's. */
export function backgroundCandidates(
spreadPhotos: readonly number[],
entryPhotos: readonly number[],
gallery: readonly number[],
limit = 8
): number[] {
return [...new Set([...spreadPhotos, ...entryPhotos, ...gallery])].slice(0, limit);
}
@@ -0,0 +1,200 @@
import {
MAX_PAINT_STOPS,
PATTERN_SCALE,
RADIAL_RADIUS,
paintColours,
parseHex,
sortedStops,
stopAlpha,
toHex,
type BookGradientPaint,
type BookPaint,
type BookPaintStop,
type BookPattern,
} from '@trek/shared';
/**
* The arithmetic of the paint picker, kept out of the components so it can
* be tested on its own: switching between kinds without losing the colour,
* stops added where they are clicked in the colour already there, angles
* read off a pointer and snapped to the ones people mean.
*/
export type PaintKind = 'none' | 'solid' | 'gradient' | 'pattern';
const WHITE = '#ffffff';
const INK = '#111111';
export const kindOf = (paint: BookPaint | null): PaintKind =>
!paint ? 'none' : paint.type === 'linear' || paint.type === 'radial' ? 'gradient' : paint.type;
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const r3 = (v: number) => Math.round(v * 1000) / 1000;
/** Two colours mixed, `t` of the way from `a` to `b`. */
export function mixHex(a: string, b: string, t: number): string {
const ca = parseHex(a);
const cb = parseHex(b);
if (!ca || !cb) return a.toLowerCase();
const m = (x: number, y: number) => x + (y - x) * t;
return toHex({ r: m(ca.r, cb.r), g: m(ca.g, cb.g), b: m(ca.b, cb.b) });
}
/** The colour a paint leads with: what a solid keeps when it becomes a gradient, and back. */
export function leadColour(paint: BookPaint | null, fallback = INK): string {
return paintColours(paint)[0] ?? fallback;
}
/** A lighter partner for a colour, for the second stop of a fresh gradient or a pattern's ground. */
export function partnerOf(color: string, t = 0.65): string {
const rgb = parseHex(color);
if (!rgb) return WHITE;
// Light colours get a darker partner, so the gradient never runs white into white.
const light = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000 > 200;
return mixHex(color, light ? INK : WHITE, light ? 0.35 : t);
}
/** The angle a pattern looks right at when first picked: stripes on the diagonal, the rest square. */
export const PATTERN_ANGLE: Partial<Record<BookPattern, number>> = { stripes: 45, pinstripe: 0 };
/**
* The paint of another kind, built from the one there is. `memory` holds the
* last paint of each kind the picker showed, so going to gradient and back to
* pattern returns the pattern as it was rather than a fresh default.
*/
export function paintAs(
kind: PaintKind,
from: BookPaint | null,
memory: Partial<Record<PaintKind, BookPaint>> = {}
): BookPaint | null {
if (kind === 'none') return null;
const remembered = memory[kind];
if (remembered) return remembered;
const lead = leadColour(from);
switch (kind) {
case 'solid':
return { type: 'solid', color: lead };
case 'gradient':
return {
type: 'linear',
angle: 180,
stops: [
{ at: 0, color: lead },
{ at: 1, color: partnerOf(lead) },
],
};
case 'pattern':
return { type: 'pattern', pattern: 'stripes', fg: lead, bg: partnerOf(lead, 0.82), scale: 1, angle: 45 };
}
}
/** The colour a gradient shows at a point, alpha included, for a stop added there. */
export function colourAt(stops: readonly BookPaintStop[], at: number): { color: string; alpha: number } {
const list = sortedStops(stops);
const first = list[0];
const last = list[list.length - 1];
if (!first || !last) return { color: INK, alpha: 1 };
if (at <= first.at) return { color: first.color.toLowerCase(), alpha: stopAlpha(first) };
if (at >= last.at) return { color: last.color.toLowerCase(), alpha: stopAlpha(last) };
for (let i = 1; i < list.length; i++) {
const a = list[i - 1];
const b = list[i];
if (!a || !b || at > b.at) continue;
const t = b.at === a.at ? 0 : (at - a.at) / (b.at - a.at);
return { color: mixHex(a.color, b.color, t), alpha: r3(stopAlpha(a) + (stopAlpha(b) - stopAlpha(a)) * t) };
}
return { color: last.color.toLowerCase(), alpha: stopAlpha(last) };
}
const stopOf = (at: number, color: string, alpha: number): BookPaintStop =>
alpha < 1 ? { at, color, alpha } : { at, color };
/** A stop added at `at` in the colour the gradient already has there. Null when the gradient is full. */
export function addStop(paint: BookGradientPaint, at: number): { paint: BookGradientPaint; index: number } | null {
if (paint.stops.length >= MAX_PAINT_STOPS) return null;
const pos = r3(clamp(at, 0, 1));
const { color, alpha } = colourAt(paint.stops, pos);
const stops = [...paint.stops, stopOf(pos, color, alpha)];
return { paint: { ...paint, stops }, index: stops.length - 1 };
}
/** A gradient without one of its stops. A gradient keeps at least two. */
export function removeStop(paint: BookGradientPaint, index: number): BookGradientPaint {
if (paint.stops.length <= 2 || index < 0 || index >= paint.stops.length) return paint;
return { ...paint, stops: paint.stops.filter((_, i) => i !== index) };
}
/** One stop changed; its position kept between 0 and 1 and to three decimals. */
export function updateStop(paint: BookGradientPaint, index: number, patch: Partial<BookPaintStop>): BookGradientPaint {
const stops = paint.stops.map((s, i) => {
if (i !== index) return s;
const next = { ...s, ...patch, at: r3(clamp(patch.at ?? s.at, 0, 1)) };
if (next.alpha !== undefined && next.alpha >= 1) delete next.alpha;
return next;
});
return { ...paint, stops };
}
/** The gradient run the other way. */
export function reverseStops(paint: BookGradientPaint): BookGradientPaint {
return { ...paint, stops: paint.stops.map((s) => ({ ...s, at: r3(1 - s.at) })) };
}
/** Linear or radial, keeping the stops. */
export function gradientAs(paint: BookGradientPaint, type: 'linear' | 'radial'): BookGradientPaint {
if (paint.type === type) return paint;
return type === 'linear'
? { type: 'linear', angle: 180, stops: paint.stops }
: { type: 'radial', cx: 0.5, cy: 0.5, r: 1, stops: paint.stops };
}
/** A whole-degree angle between 0 and 359. */
export function normAngle(deg: number): number {
const a = Math.round(deg) % 360;
return a < 0 ? a + 360 : a;
}
/**
* The angle a pointer at (`dx`, `dy`) from the dial's centre points at, 0 up
* and clockwise like the paint itself. Within a few degrees of a multiple of
* 45 it lands on it, since those are the angles people mean; `fine` (a held
* modifier) turns the snap off.
*/
export function dialAngle(dx: number, dy: number, fine = false): number {
const raw = normAngle((Math.atan2(dx, -dy) * 180) / Math.PI);
if (fine) return raw;
const near = Math.round(raw / 45) * 45;
return Math.abs(raw - near) <= 4 ? normAngle(near) : raw;
}
export const clampScale = (v: number) => r3(clamp(v, PATTERN_SCALE.min, PATTERN_SCALE.max));
export const clampRadius = (v: number) => r3(clamp(v, RADIAL_RADIUS.min, RADIAL_RADIUS.max));
export const clampUnit = (v: number) => r3(clamp(v, 0, 1));
/** A CSS gradient for the picker's own previews (the stop bar, the tab swatches): not for the page. */
export function cssStops(stops: readonly BookPaintStop[]): string {
return sortedStops(stops)
.map((s) => {
const rgb = parseHex(s.color);
const c = rgb ? `rgb(${rgb.r} ${rgb.g} ${rgb.b} / ${stopAlpha(s)})` : s.color;
return `${c} ${Math.round(s.at * 1000) / 10}%`;
})
.join(', ');
}
/** A paint as a CSS background, for a preview chip in the picker. */
export function cssPaint(paint: BookPaint | null): string | undefined {
if (!paint) return undefined;
switch (paint.type) {
case 'solid': {
const rgb = parseHex(paint.color);
return rgb ? `rgb(${rgb.r} ${rgb.g} ${rgb.b} / ${stopAlpha(paint)})` : paint.color;
}
case 'linear':
return `linear-gradient(${paint.angle}deg, ${cssStops(paint.stops)})`;
case 'radial':
return `radial-gradient(circle at ${paint.cx * 100}% ${paint.cy * 100}%, ${cssStops(paint.stops)})`;
case 'pattern':
return paint.bg ?? undefined;
}
}
@@ -0,0 +1,60 @@
import type { BookShapeElement } from '@trek/shared';
import { SHAPE_PATHS, scalePath } from '../shapes';
import type { PatternBox } from './patterns';
/** The outlines and lines paint is laid along, in millimetres. */
/** Hundredths of a millimetre, and never minus zero, which would print as "-0". */
export const r2 = (v: number) => Math.round(v * 100) / 100 || 0;
/**
* Where a linear gradient's line runs over a box, the way CSS places it: through
* the centre at `angle` (0 towards the top, clockwise), long enough that the
* first and last stop land on the box's far corners.
*/
export function linearLine(angle: number, box: PatternBox): { x1: number; y1: number; x2: number; y2: number } {
const a = (angle * Math.PI) / 180;
const dx = Math.sin(a);
const dy = -Math.cos(a);
const half = (Math.abs(box.w * dx) + Math.abs(box.h * dy)) / 2;
const cx = box.x + box.w / 2;
const cy = box.y + box.h / 2;
return { x1: r2(cx - dx * half), y1: r2(cy - dy * half), x2: r2(cx + dx * half), y2: r2(cy + dy * half) };
}
/** A rectangle's outline as path data, with its corners rounded to `radius`. */
export function roundedRectPath(w: number, h: number, radius: number): string {
const r = Math.max(0, Math.min(radius, w / 2, h / 2));
if (r <= 0) return `M0 0H${r2(w)}V${r2(h)}H0Z`;
const a = `A${r2(r)} ${r2(r)} 0 0 1`;
return (
`M${r2(r)} 0H${r2(w - r)}${a} ${r2(w)} ${r2(r)}V${r2(h - r)}${a} ${r2(w - r)} ${r2(h)}` +
`H${r2(r)}${a} 0 ${r2(h - r)}V${r2(r)}${a} ${r2(r)} 0Z`
);
}
/** An ellipse filling a box, as path data. */
export function ellipsePath(w: number, h: number): string {
const rx = r2(w / 2);
const ry = r2(h / 2);
return `M0 ${ry}A${rx} ${ry} 0 1 0 ${r2(w)} ${ry}A${rx} ${ry} 0 1 0 0 ${ry}Z`;
}
/** The dash a stroke style draws, proportional to the stroke so its rhythm holds at any width. */
export function strokeDash(style: BookShapeElement['strokeStyle'], sw: number): string | undefined {
if (style === 'dashed') return `${sw * 3} ${sw * 2}`;
if (style === 'dotted') return `${sw * 0.01} ${sw * 2}`;
return undefined;
}
/** The outline of a shape in a box of `w` × `h` millimetres. */
export function shapeOutline(
el: Pick<BookShapeElement, 'shape' | 'radius'>,
w: number,
h: number,
inset: number
): string {
if (el.shape === 'rect') return roundedRectPath(w, h, Math.max(0, el.radius - inset));
if (el.shape === 'ellipse') return ellipsePath(w, h);
return scalePath(SHAPE_PATHS[el.shape], w, h);
}
@@ -0,0 +1,216 @@
import { sortedStops, stopAlpha, type BookPaint, type BookPaintStop } from '@trek/shared';
import { useMemo, type ReactNode, type SVGProps } from 'react';
import { linearLine, r2 } from './paintGeometry';
import { patternGeometry, type PatternBox } from './patterns';
import { usePaintId } from './usePaintId';
/**
* Paint, drawn: the one renderer for a shape's fill and a page's background.
*
* Every paint becomes SVG the print path already knows: a fill with
* `fill-opacity`, a `linearGradient` or `radialGradient` in user space, or a
* pattern drawn out as geometry (patterns.ts) under a `clipPath` in user
* space. Nothing here is lazy, measured or raster, so what the editor shows
* is what headless Chromium prints.
*
* ── Why the ids come from useId ──────────────────────────────────────────
*
* Page mode draws each double spread twice, once per half (bookSheets.ts),
* and a thumbnail draws it again beside the canvas. An id taken from the
* element alone would then exist twice in one document, and `url(#id)`
* resolves to the first, so the second half would borrow the first half's
* gradient, and a pattern clipped to the first copy's path would vanish from
* the second. React's useId is unique per mounted instance and the same on
* every render of it, which is exactly the property a defs id needs; the
* element's own id is kept in front only to make the markup readable.
*/
function Stops({ stops }: { stops: readonly BookPaintStop[] }) {
return (
<>
{sortedStops(stops).map((s, i) => (
<stop
// Two stops may share an offset (a hard edge), so the index keys them.
key={i}
offset={r2(s.at)}
stopColor={s.color}
stopOpacity={stopAlpha(s) < 1 ? stopAlpha(s) : undefined}
/>
))}
</>
);
}
/** What a painted outline needs: the defs to put in the SVG, and how to fill the outline. */
interface PaintParts {
defs: ReactNode;
fill: string;
fillOpacity?: number;
/** A pattern's geometry, to draw clipped to the outline over its fill. */
pattern: { clipId: string; layers: { d: string; opacity: number }[]; transform?: string; ink: string } | null;
}
function paintParts(paint: BookPaint, box: PatternBox, id: string): PaintParts {
switch (paint.type) {
case 'solid':
return {
defs: null,
fill: paint.color,
fillOpacity: stopAlpha(paint) < 1 ? stopAlpha(paint) : undefined,
pattern: null,
};
case 'linear': {
const line = linearLine(paint.angle, box);
return {
defs: (
<linearGradient id={id} gradientUnits="userSpaceOnUse" {...line}>
<Stops stops={paint.stops} />
</linearGradient>
),
fill: `url(#${id})`,
pattern: null,
};
}
case 'radial':
return {
defs: (
<radialGradient
id={id}
gradientUnits="userSpaceOnUse"
cx={r2(box.x + paint.cx * box.w)}
cy={r2(box.y + paint.cy * box.h)}
r={r2((paint.r * Math.hypot(box.w, box.h)) / 2)}
>
<Stops stops={paint.stops} />
</radialGradient>
),
fill: `url(#${id})`,
pattern: null,
};
case 'pattern': {
const geo = patternGeometry(paint, box);
return {
defs: null,
fill: paint.bg ?? 'none',
pattern: { clipId: `${id}-clip`, layers: geo.layers, transform: geo.transform, ink: paint.fg },
};
}
}
}
type OutlineProps = Omit<SVGProps<SVGPathElement>, 'fill' | 'd'>;
/**
* An outline filled with a paint, inside an `<svg>` whose user units are
* millimetres. `box` is the outline's box in that space, which a gradient and
* a pattern are laid over. The stroke, when the outline has one, is drawn on
* top of a pattern rather than under it, so the edge stays crisp.
*/
export function PaintedPath({
d,
paint,
box,
idPrefix,
fillRule,
stroke,
...rest
}: {
d: string;
paint: BookPaint | null;
box: PatternBox;
/** The element's id, to make the markup readable; uniqueness comes from useId. */
idPrefix: string;
fillRule?: 'evenodd' | 'nonzero';
} & OutlineProps) {
const id = usePaintId(`p-${idPrefix}`);
// The caller holds `box` steady between renders (a memo of the frame), so a re-render
// that moved nothing does not lay the pattern out again.
const parts = useMemo(() => (paint ? paintParts(paint, box, id) : null), [paint, box, id]);
const outline = { d, fillRule, clipRule: fillRule };
if (!parts) return <path {...outline} fill="none" stroke={stroke} {...rest} />;
const pattern = parts.pattern;
return (
<>
{(parts.defs || pattern) && (
<defs>
{parts.defs}
{pattern && (
<clipPath id={pattern.clipId} clipPathUnits="userSpaceOnUse">
<path {...outline} />
</clipPath>
)}
</defs>
)}
<path
{...outline}
fill={parts.fill}
fillOpacity={parts.fillOpacity}
stroke={pattern ? undefined : stroke}
{...(pattern ? {} : rest)}
/>
{pattern && (
<>
<g clipPath={`url(#${pattern.clipId})`}>
<g transform={pattern.transform}>
{pattern.layers.map((layer, i) => (
<path
key={i}
d={layer.d}
fill={pattern.ink}
fillOpacity={layer.opacity < 1 ? layer.opacity : undefined}
/>
))}
</g>
</g>
{stroke && <path {...outline} fill="none" stroke={stroke} {...rest} />}
</>
)}
</>
);
}
/**
* A page's paint, drawn under everything on it and out into the bleed.
*
* Positioned in the spread's own millimetres from minus the bleed, so print,
* whose window shows the bleed, gets paint to the cut, and the editor, which
* clips at the trim, shows the same paint up to it.
*/
export function SpreadPaint({
paint,
width,
height,
bleed,
idPrefix,
}: {
paint: BookPaint;
width: number;
height: number;
bleed: number;
idPrefix: string;
}) {
const w = r2(width + bleed * 2);
const h = r2(height + bleed * 2);
const box = useMemo(() => ({ x: 0, y: 0, w, h }), [w, h]);
return (
<svg
aria-hidden
width={`${w}mm`}
height={`${h}mm`}
viewBox={`0 0 ${w} ${h}`}
// A page is laid out left to right in every language; its own direction makes the start its left edge.
style={{
position: 'absolute',
direction: 'ltr',
insetInlineStart: `${-bleed}mm`,
top: `${-bleed}mm`,
display: 'block',
pointerEvents: 'none',
}}
>
<PaintedPath d={`M0 0H${w}V${h}H0Z`} paint={paint} box={box} idPrefix={`bg-${idPrefix}`} />
</svg>
);
}
@@ -0,0 +1,63 @@
import type { BookSpread } from '@trek/shared';
import { createContext, useContext } from 'react';
/** The palette rows' state and the pure parts of it (StudioPalette.tsx holds the provider). */
export interface StudioPaletteValue {
/** The colours the book is drawn in, most used first. */
book: string[];
/** The colours of the photos on the page being edited. */
photos: string[];
/** What this person picked lately, newest first. */
recent: string[];
remember: (hex: string) => void;
/** The labels the rows carry, in the reader's language. */
labels: { book: string; photos: string; recent: string; opacity: string };
}
export const StudioPaletteContext = createContext<StudioPaletteValue | null>(null);
/** The palette rows, or null outside a provider (a swatch row then shows its fixed inks only). */
export function useStudioPalette(): StudioPaletteValue | null {
return useContext(StudioPaletteContext);
}
export const RECENT_KEY = 'trek.studio.recentColours';
export const MAX_RECENT = 8;
/** The recent colours as stored. Storage can be missing or refuse; that is an empty list, not an error. */
export function readRecent(): string[] {
try {
const raw = window.localStorage.getItem(RECENT_KEY);
const list: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(list)
? list.filter((c): c is string => typeof c === 'string' && /^#[0-9a-f]{6}$/.test(c)).slice(0, MAX_RECENT)
: [];
} catch {
return [];
}
}
/** A colour put first in the recent list, which keeps each colour once. */
export function withRecent(list: readonly string[], hex: string): string[] {
const c = hex.toLowerCase();
return [c, ...list.filter((x) => x !== c)].slice(0, MAX_RECENT);
}
export function writeRecent(list: readonly string[]) {
try {
window.localStorage.setItem(RECENT_KEY, JSON.stringify(list));
} catch {
// A private window or a full quota: the list lives for this session only.
return;
}
}
/** The photo ids a page shows, placed or parked, in paint order. */
export function spreadPhotoIds(spread: BookSpread | undefined): number[] {
if (!spread) return [];
const ids = [...spread.elements, ...spread.parked].flatMap((e) =>
e.kind === 'photo' && e.photoId != null ? [e.photoId] : []
);
return [...new Set(ids)].slice(0, 8);
}
@@ -0,0 +1,358 @@
import type { BookPattern } from '@trek/shared';
/**
* The twelve repeats a pattern paint draws, as plain geometry.
*
* Not SVG `<pattern>`: a pattern tile has never been through this print path,
* and a RIP that rasterises tiles at its own resolution prints seams. Each
* repeat is drawn out in full instead, as a handful of `<path>` elements whose
* `d` holds every primitive, and clipped to the shape by the caller. The DOM
* stays a few nodes per element whatever the scale.
*
* Everything is in millimetres in the caller's user space, rounded to
* hundredths as the document stores its own numbers, and every repeat is
* capped at `MAX_PATTERN_PRIMITIVES`: past that the cell grows rather than the
* page, so a tiny scale on a whole spread costs no more than it looks worth.
*/
export const MAX_PATTERN_PRIMITIVES = 2000;
/** The cell of a pattern at scale 1. */
export const PATTERN_CELL_MM = 8;
export interface PatternBox {
x: number;
y: number;
w: number;
h: number;
}
/** One colour layer: every primitive of it in one path, at a share of the ink's opacity. */
interface PatternLayer {
d: string;
opacity: number;
}
interface PatternGeometry {
layers: PatternLayer[];
/** How many primitives the layers hold together. */
count: number;
/** Turns the drawing about the box's centre; absent when it is not turned. */
transform?: string;
/** The cell the repeat was drawn at, after the cap had its say. */
cell: number;
}
/** Hundredths of a millimetre, and never minus zero, which would print as "-0". */
const r2 = (v: number) => Math.round(v * 100) / 100 || 0;
const f = (v: number) => String(r2(v));
/**
* How many primitives a repeat needs over a region: `area` primitives per
* cell squared, `lin` per cell along the width and the height. Read before
* drawing, so the cap moves the cell once instead of drawing twice, and on
* the high side, so the cap grows the cell rather than leaving a corner of
* the box undrawn.
*/
const DENSITY: Record<BookPattern, { area: number; lin: number }> = {
checker: { area: 2, lin: 0 },
gingham: { area: 0, lin: 1 },
stripes: { area: 0, lin: 1 },
pinstripe: { area: 0, lin: 2 },
dots: { area: 4, lin: 0 },
polka: { area: 2, lin: 0 },
zigzag: { area: 2, lin: 0 },
waves: { area: 2, lin: 0 },
grid: { area: 0, lin: 2 },
scales: { area: 2, lin: 0 },
sunburst: { area: 0, lin: 0 },
confetti: { area: 1.2, lin: 0 },
};
/** A primitive count estimate for a repeat over a region at a cell size. */
export function estimatePrimitives(pattern: BookPattern, cell: number, w: number, h: number): number {
if (pattern === 'sunburst') return sunburstRays(cell);
const d = DENSITY[pattern];
// The repeats that need room to tuck in start a cell before the region and end a cell after it.
const pw = w + cell * 2;
const ph = h + cell * 2;
return (d.area * pw * ph) / (cell * cell) + (d.lin * (pw + ph)) / cell;
}
/** The cell a repeat is drawn at: its own, or larger where the cap would be passed. */
export function fittedCell(
pattern: BookPattern,
cell: number,
w: number,
h: number,
cap = MAX_PATTERN_PRIMITIVES
): number {
let c = cell;
// Two rounds cover the linear term, which grows only with the square root.
for (let i = 0; i < 3; i++) {
const n = estimatePrimitives(pattern, c, w, h);
if (n <= cap * 0.95) break;
c *= Math.sqrt(n / (cap * 0.95)) * 1.02;
}
return c;
}
/** Collects primitives into a path, refusing any past the cap. */
class Pen {
parts: string[] = [];
count = 0;
constructor(private readonly cap: number) {}
get full(): boolean {
return this.count >= this.cap;
}
private push(d: string) {
if (this.full) return;
this.parts.push(d);
this.count++;
}
rect(x: number, y: number, w: number, h: number) {
this.push(`M${f(x)} ${f(y)}h${f(w)}v${f(h)}h${f(-w)}Z`);
}
circle(cx: number, cy: number, r: number) {
this.push(`M${f(cx - r)} ${f(cy)}a${f(r)} ${f(r)} 0 1 0 ${f(r * 2)} 0a${f(r)} ${f(r)} 0 1 0 ${f(-r * 2)} 0Z`);
}
polygon(points: readonly (readonly [number, number])[]) {
if (points.length < 3) return;
this.push(`M${points.map(([x, y]) => `${f(x)} ${f(y)}`).join('L')}Z`);
}
/** Raw path data counted as `n` primitives, for a band drawn as one outline. */
raw(d: string, n: number) {
if (this.full) return;
this.parts.push(d);
this.count += n;
}
get d(): string {
return this.parts.join('');
}
}
interface Region {
x0: number;
y0: number;
x1: number;
y1: number;
}
/** Values of `start + k * step` from at or below `from` up to `to`, aligned to the region's origin. */
function steps(from: number, to: number, step: number, offset = 0): number[] {
const out: number[] = [];
const first = Math.floor((from - offset) / step) * step + offset;
for (let v = first; v < to && out.length < 4000; v += step) out.push(v);
return out;
}
type Draw = (pen: Pen, region: Region, cell: number, alt: Pen) => void;
const DRAW: Record<Exclude<BookPattern, 'sunburst'>, Draw> = {
checker: (pen, g, s) => {
const half = s / 2;
steps(g.y0, g.y1, half).forEach((y, j) => {
steps(g.x0, g.x1, half).forEach((x, i) => {
if ((i + j) % 2 === 0) pen.rect(x, y, half, half);
});
});
},
// Two layers of half-opaque bands, so where they cross reads darker: the weave.
gingham: (pen, g, s, alt) => {
for (const y of steps(g.y0, g.y1, s)) pen.rect(g.x0, y, g.x1 - g.x0, s / 2);
for (const x of steps(g.x0, g.x1, s)) alt.rect(x, g.y0, s / 2, g.y1 - g.y0);
},
stripes: (pen, g, s) => {
for (const x of steps(g.x0, g.x1, s)) pen.rect(x, g.y0, s / 2, g.y1 - g.y0);
},
pinstripe: (pen, g, s) => {
const w = Math.max(0.2, s * 0.06);
for (const x of steps(g.x0, g.x1, s / 2)) pen.rect(x, g.y0, w, g.y1 - g.y0);
},
dots: (pen, g, s) => {
const p = s / 2;
for (const y of steps(g.y0, g.y1 + p, p))
for (const x of steps(g.x0, g.x1 + p, p)) pen.circle(x + p / 2, y + p / 2, s * 0.07);
},
// A square lattice with a second one in its gaps: the polka of a sundress.
polka: (pen, g, s) => {
const r = s * 0.17;
for (const y of steps(g.y0 - s, g.y1 + s, s)) {
for (const x of steps(g.x0 - s, g.x1 + s, s)) {
pen.circle(x, y, r);
pen.circle(x + s / 2, y + s / 2, r);
}
}
},
zigzag: (pen, g, s) => {
const half = s / 2;
const amp = s / 4;
const t = s * 0.14;
for (const y of steps(g.y0 - s, g.y1 + s, s)) {
const xs = steps(g.x0 - s, g.x1 + s, half);
const top = xs.map((x, i) => [x, y + (i % 2 ? amp : 0)] as const);
const bottom = [...top].reverse().map(([x, yy]) => [x, yy + t] as const);
pen.raw(`M${[...top, ...bottom].map(([x, yy]) => `${f(x)} ${f(yy)}`).join('L')}Z`, Math.max(1, xs.length - 1));
}
},
waves: (pen, g, s) => {
const half = s / 2;
const amp = s * 0.18;
const t = s * 0.12;
for (const y of steps(g.y0 - s, g.y1 + s, s)) {
const xs = steps(g.x0 - s, g.x1 + s, half);
if (xs.length < 2) continue;
const curve = (dy: number, list: number[], dir: 1 | -1) =>
list
.slice(1)
.map((x, i) => {
const from = list[i] ?? x;
const k = dir === 1 ? i : list.length - 2 - i;
const bend = k % 2 ? amp : -amp;
return `Q${f((from + x) / 2)} ${f(y + dy + bend)} ${f(x)} ${f(y + dy)}`;
})
.join('');
const back = [...xs].reverse();
const first = xs[0] ?? 0;
const last = back[0] ?? 0;
pen.raw(`M${f(first)} ${f(y)}${curve(0, xs, 1)}L${f(last)} ${f(y + t)}${curve(t, back, -1)}Z`, xs.length - 1);
}
},
grid: (pen, g, s) => {
const w = Math.max(0.2, s * 0.05);
for (const x of steps(g.x0, g.x1, s)) pen.rect(x, g.y0, w, g.y1 - g.y0);
for (const y of steps(g.y0, g.y1, s)) pen.rect(g.x0, y, g.x1 - g.x0, w);
},
// Lower half rings in rows half a cell apart, every other row shifted, so each scale tucks under the next.
scales: (pen, g, s) => {
const r = s / 2;
const t = Math.max(0.2, s * 0.08);
steps(g.y0 - s, g.y1 + s, s / 2).forEach((y, row) => {
for (const x of steps(g.x0 - s, g.x1 + s, s, row % 2 ? r : 0)) {
const cx = x + r;
pen.raw(
`M${f(cx - r)} ${f(y)}A${f(r)} ${f(r)} 0 0 0 ${f(cx + r)} ${f(y)}` +
`L${f(cx + r - t)} ${f(y)}A${f(r - t)} ${f(r - t)} 0 0 1 ${f(cx - r + t)} ${f(y)}Z`,
1
);
}
});
},
confetti: (pen, g, s) => {
const rand = mulberry32(Math.round(s * 1000) + 17);
const area = (g.x1 - g.x0) * (g.y1 - g.y0);
const n = Math.round((area / (s * s)) * 1.2);
for (let i = 0; i < n && !pen.full; i++) {
const x = g.x0 + rand() * (g.x1 - g.x0);
const y = g.y0 + rand() * (g.y1 - g.y0);
const turn = rand() * Math.PI;
if (rand() < 0.35) {
pen.circle(x, y, s * (0.04 + rand() * 0.03));
continue;
}
const len = s * (0.12 + rand() * 0.1);
const wid = s * 0.05;
const cos = Math.cos(turn);
const sin = Math.sin(turn);
const corner = (u: number, v: number) => [x + u * cos - v * sin, y + u * sin + v * cos] as const;
pen.polygon([
corner(-len / 2, -wid / 2),
corner(len / 2, -wid / 2),
corner(len / 2, wid / 2),
corner(-len / 2, wid / 2),
]);
}
},
};
/** A seeded generator, so a confetti page draws the same scatter on every render and in print. */
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** How many rays a sunburst has at a cell size: more as the cell shrinks, between 8 and 72. */
export function sunburstRays(cell: number): number {
const n = Math.round((24 * PATTERN_CELL_MM) / Math.max(0.1, cell));
return Math.max(8, Math.min(72, n - (n % 2)));
}
function sunburst(pen: Pen, box: PatternBox, cell: number, angle: number) {
const cx = box.x + box.w / 2;
const cy = box.y + box.h / 2;
// Past the corners whichever way it turns.
const reach = Math.hypot(box.w, box.h);
const n = sunburstRays(cell);
const step = (Math.PI * 2) / n;
const start = (angle * Math.PI) / 180 - Math.PI / 2;
for (let i = 0; i < n; i += 2) {
const a = start + i * step;
pen.polygon([
[cx, cy],
[cx + Math.cos(a) * reach, cy + Math.sin(a) * reach],
[cx + Math.cos(a + step) * reach, cy + Math.sin(a + step) * reach],
]);
}
}
/**
* A repeat over a box. The geometry covers the box however it is turned: the
* region drawn is the square around the box's circumscribed circle, and the
* caller turns it with `transform` and clips it to the shape.
*/
export function patternGeometry(
paint: { pattern: BookPattern; scale: number; angle: number },
box: PatternBox,
cap = MAX_PATTERN_PRIMITIVES
): PatternGeometry {
const empty = { layers: [], count: 0, cell: PATTERN_CELL_MM * paint.scale };
if (!(box.w > 0 && box.h > 0) || !Number.isFinite(paint.scale) || paint.scale <= 0) return empty;
const turned = ((paint.angle % 360) + 360) % 360 !== 0;
const cx = box.x + box.w / 2;
const cy = box.y + box.h / 2;
const radius = Math.hypot(box.w, box.h) / 2;
const region: Region =
turned && paint.pattern !== 'sunburst'
? { x0: cx - radius, y0: cy - radius, x1: cx + radius, y1: cy + radius }
: { x0: box.x, y0: box.y, x1: box.x + box.w, y1: box.y + box.h };
const rw = region.x1 - region.x0;
const rh = region.y1 - region.y0;
const cell = fittedCell(paint.pattern, PATTERN_CELL_MM * paint.scale, rw, rh, cap);
const pen = new Pen(cap);
if (paint.pattern === 'sunburst') {
sunburst(pen, box, cell, paint.angle);
return { layers: [{ d: pen.d, opacity: 1 }], count: pen.count, cell };
}
// The second layer only ever holds gingham's cross bands; the cell fitted above keeps both under the cap together.
const alt = new Pen(cap);
DRAW[paint.pattern](pen, region, cell, alt);
const count = pen.count + alt.count;
const layers =
paint.pattern === 'gingham'
? [
{ d: pen.d, opacity: 0.5 },
{ d: alt.d, opacity: 0.5 },
]
: [{ d: pen.d, opacity: 1 }];
return {
layers: layers.filter((l) => l.d),
count,
cell,
...(turned ? { transform: `rotate(${f(paint.angle)} ${f(cx)} ${f(cy)})` } : {}),
};
}
@@ -0,0 +1,150 @@
import { clusterPalettes, parseHex, toHex, toHsl, type PaletteSwatch } from '@trek/shared';
/**
* The colours of a photograph, read in the browser while editing.
*
* The server keeps no palette for a photo the client can read, and the page
* being edited is the one whose photos matter, so the swatches are read from
* the pictures themselves: a thumbnail drawn small onto a canvas, its pixels
* sorted into a few clusters by k-means. Small on purpose, 48 pixels across is
* plenty to say a picture is mostly sea and sand, and it costs nothing next to
* the thumbnail the page has already loaded.
*
* The canvas is only read here, never drawn into the book: what prints stays
* the vector page.
*/
/** How many clusters one photo is read into. */
const PHOTO_CLUSTERS = 5;
/** The side of the thumbnail the pixels are read from. */
const SAMPLE = 48;
type Rgb = { r: number; g: number; b: number };
const dist2 = (a: Rgb, b: Rgb) => (a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2;
/** The opaque pixels of an RGBA buffer, every `stride`-th one. */
export function samplePixels(data: ArrayLike<number>, stride = 1): Rgb[] {
const out: Rgb[] = [];
for (let i = 0; i + 3 < data.length; i += 4 * stride) {
if ((data[i + 3] ?? 0) < 128) continue;
out.push({ r: data[i] ?? 0, g: data[i + 1] ?? 0, b: data[i + 2] ?? 0 });
}
return out;
}
/**
* k-means over pixels, seeded by farthest points from the mean so the same
* picture always gives the same answer: no random start, no flicker as the
* inspector re-renders. Clusters come back largest first.
*/
export function kmeansPalette(pixels: readonly Rgb[], k = PHOTO_CLUSTERS, rounds = 8): PaletteSwatch[] {
if (!pixels.length || k < 1) return [];
const mean = pixels.reduce((m, p) => ({ r: m.r + p.r, g: m.g + p.g, b: m.b + p.b }), { r: 0, g: 0, b: 0 });
const first = { r: mean.r / pixels.length, g: mean.g / pixels.length, b: mean.b / pixels.length };
const centres: Rgb[] = [first];
while (centres.length < k) {
let best: Rgb | null = null;
let bestD = 0;
for (const p of pixels) {
const d = Math.min(...centres.map((c) => dist2(c, p)));
if (d > bestD) {
bestD = d;
best = p;
}
}
if (!best) break;
centres.push({ ...best });
}
let sizes: number[] = centres.map(() => 0);
for (let round = 0; round < rounds; round++) {
const sums = centres.map(() => ({ r: 0, g: 0, b: 0, n: 0 }));
for (const p of pixels) {
let at = 0;
let near = Infinity;
centres.forEach((c, i) => {
const d = dist2(c, p);
if (d < near) {
near = d;
at = i;
}
});
const s = sums[at];
if (!s) continue;
s.r += p.r;
s.g += p.g;
s.b += p.b;
s.n++;
}
sums.forEach((s, i) => {
if (s.n) centres[i] = { r: s.r / s.n, g: s.g / s.n, b: s.b / s.n };
});
sizes = sums.map((s) => s.n);
}
return centres
.map((c, i) => ({ hex: toHex(c), share: (sizes[i] ?? 0) / pixels.length }))
.filter((s) => s.share > 0)
.sort((a, b) => b.share - a.share || a.hex.localeCompare(b.hex));
}
/**
* The swatches several photos offer together: the clusters they share, most
* common first, with near-greys and near-duplicates thinned so the row is a
* choice of colours rather than five shades of the same overcast sky.
*/
export function photoSwatches(palettes: readonly PaletteSwatch[][], limit = 6): string[] {
const out: string[] = [];
let greys = 0;
for (const swatch of clusterPalettes(palettes, 10)) {
const rgb = parseHex(swatch.hex);
if (!rgb) continue;
const grey = toHsl(rgb).s < 0.12;
// One grey is a useful neutral; a second is the same colour again.
if (grey && greys >= 1) continue;
if (grey) greys++;
out.push(swatch.hex);
if (out.length >= limit) break;
}
return out;
}
/** Read one picture's palette. Resolves empty when the picture cannot be read, never rejects. */
export function readPhotoPalette(src: string, signal?: AbortSignal): Promise<PaletteSwatch[]> {
return new Promise((resolve) => {
if (signal?.aborted || typeof document === 'undefined') {
resolve([]);
return;
}
const img = new Image();
img.decoding = 'async';
const done = (out: PaletteSwatch[]) => {
signal?.removeEventListener('abort', abort);
resolve(out);
};
const abort = () => {
img.src = '';
done([]);
};
signal?.addEventListener('abort', abort, { once: true });
img.onerror = () => done([]);
img.onload = () => done(paletteOfImage(img));
img.src = src;
});
}
function paletteOfImage(img: HTMLImageElement): PaletteSwatch[] {
const canvas = document.createElement('canvas');
canvas.width = SAMPLE;
canvas.height = SAMPLE;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return [];
try {
ctx.drawImage(img, 0, 0, SAMPLE, SAMPLE);
return kmeansPalette(samplePixels(ctx.getImageData(0, 0, SAMPLE, SAMPLE).data, 2));
} catch {
// A picture from another origin taints the canvas; it simply offers no swatches.
return [];
}
}
@@ -0,0 +1,53 @@
import type { BookDocument, BookPaint } from '@trek/shared';
import { useLayoutEffect, useMemo, useRef } from 'react';
import { useStudioStore } from '../../../store/studioStore';
/**
* How the paint picker writes: a value set while a control is dragged is
* `live`, and the one it is let go on is `commit`.
*
* A drag of a gradient stop or the angle dial passes through dozens of
* values, and each would otherwise be an undo step. So the first live value
* opens a gesture on the store, every live value after it moves the document
* inside that gesture, and the commit closes it: one drag, one undo. A click
* that is a commit with no live value before it is one ordinary step.
*/
export interface PaintChannel {
live: (paint: BookPaint | null) => void;
commit: (paint: BookPaint | null) => void;
}
export function usePaintChannel(write: (paint: BookPaint | null) => (doc: BookDocument) => BookDocument): PaintChannel {
const begin = useStudioStore((s) => s.beginGesture);
const end = useStudioStore((s) => s.endGesture);
const apply = useStudioStore((s) => s.apply);
const commit = useStudioStore((s) => s.commit);
const open = useRef(false);
// The writer is a fresh closure on every render; the channel reads the latest one.
const writer = useRef(write);
useLayoutEffect(() => {
writer.current = write;
});
return useMemo(
() => ({
live: (paint) => {
if (!open.current) {
begin();
open.current = true;
}
apply(writer.current(paint));
},
commit: (paint) => {
if (!open.current) {
commit(writer.current(paint));
return;
}
apply(writer.current(paint));
end();
open.current = false;
},
}),
[begin, end, apply, commit]
);
}
@@ -0,0 +1,11 @@
import { useId } from 'react';
/**
* Defs ids for paint, unique per mounted copy of a page (paintRender.tsx says why).
*/
/** A defs id unique to this mounted instance, safe inside `url(#...)`. */
export function usePaintId(prefix: string): string {
const raw = useId();
return `${prefix}-${raw.replace(/[^\w-]/g, '')}`;
}
@@ -1,190 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
coversAny,
cssWeight,
faceRulesOf,
faceUsesOf,
parseFaceRule,
parseUnicodeRange,
pickFaceRules,
snapshotFontCss,
type FaceRule,
} from './snapshotFonts';
/**
* The faces a page snapshot carries (FE-STUDIO-SNAPFONT): what the page draws
* is read off the DOM, the browser's choice of rule is repeated, and only the
* faces it would pick go into the image, each with its file inline.
*/
const BASE = 'https://trek.example/assets/app.css';
const LATIN = 'U+0000-00FF, U+0131, U+0152-0153';
const EXT = 'U+0100-02BA, U+1E00-1E9F';
const rule = (family: string, weight: number, italic: boolean, range: string, file: string): FaceRule => ({
family,
weight,
italic,
unicodeRange: range,
url: `https://trek.example/assets/${file}`,
});
const RULES = [
rule('Lora', 400, false, LATIN, 'lora-latin-400-normal.woff2'),
rule('Lora', 400, false, EXT, 'lora-latin-ext-400-normal.woff2'),
rule('Lora', 700, false, LATIN, 'lora-latin-700-normal.woff2'),
rule('Lora', 400, true, LATIN, 'lora-latin-400-italic.woff2'),
rule('Caveat', 400, false, LATIN, 'caveat-latin-400-normal.woff2'),
rule('Anton', 400, false, LATIN, 'anton-latin-400-normal.woff2'),
];
const chars = (s: string) => new Set([...s].map((c) => c.codePointAt(0) ?? 0));
const files = (picked: FaceRule[]) => picked.map((r) => r.url.split('/').pop()).sort();
function page(html: string): HTMLElement {
const root = document.createElement('div');
root.innerHTML = html;
document.body.appendChild(root);
return root;
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('reading a rule', () => {
it('FE-STUDIO-SNAPFONT-001: reads family, weight, slant, range and the woff2 file of an @font-face rule', () => {
const css =
"@font-face { font-family: 'Lora'; font-style: italic; font-display: swap; font-weight: 700; " +
'src: url(./files/lora-latin-700-italic.woff2) format("woff2"), url(./files/lora-latin-700-italic.woff) format("woff"); ' +
`unicode-range: ${LATIN}; }`;
expect(parseFaceRule(css, BASE)).toEqual({
family: 'Lora',
weight: 700,
italic: true,
unicodeRange: LATIN,
url: 'https://trek.example/assets/files/lora-latin-700-italic.woff2',
});
const plain = '@font-face { font-family: "Anton"; src: url("/assets/anton.woff") format("woff"); }';
expect(parseFaceRule(plain, BASE)).toMatchObject({
weight: 400,
italic: false,
unicodeRange: '',
url: 'https://trek.example/assets/anton.woff',
});
expect(parseFaceRule('.x { color: red; }', BASE)).toBeNull();
expect(parseFaceRule('@font-face { font-family: "NoFile"; }', BASE)).toBeNull();
expect(parseFaceRule('@font-face { src: url(a.woff2); }', BASE)).toBeNull();
});
it('FE-STUDIO-SNAPFONT-002: reads weights and unicode ranges as CSS does', () => {
expect([cssWeight('bold'), cssWeight('normal'), cssWeight('300'), cssWeight('5000')]).toEqual([700, 400, 300, 400]);
expect(parseUnicodeRange('U+0000-00FF, U+0131, U+4??')).toEqual([
[0, 0xff],
[0x131, 0x131],
[0x400, 0x4ff],
]);
expect(parseUnicodeRange('U+zz, ')).toEqual([]);
expect(coversAny(LATIN, chars('Lisboa'))).toBe(true);
expect(coversAny(EXT, chars('Lisboa'))).toBe(false);
expect(coversAny(EXT, chars('Łódź'))).toBe(true);
expect(coversAny('', chars('anything'))).toBe(true);
});
});
describe('choosing the faces', () => {
it('FE-STUDIO-SNAPFONT-003: picks one rule per subset the text reaches, at the weight and slant it is set in', () => {
const uses = new Map([
['lora|700|false', chars('Porto')],
['caveat|400|false', chars('travel')],
]);
expect(files(pickFaceRules(RULES, uses))).toEqual(['caveat-latin-400-normal.woff2', 'lora-latin-700-normal.woff2']);
// Polish needs the extended subset beside the basic one.
const polish = new Map([['lora|400|false', chars('Kraków Łódź')]]);
expect(files(pickFaceRules(RULES, polish))).toEqual([
'lora-latin-400-normal.woff2',
'lora-latin-ext-400-normal.woff2',
]);
});
it('FE-STUDIO-SNAPFONT-004: falls back the way the browser does: the nearest weight, the upright face, nothing for an unknown family', () => {
expect(files(pickFaceRules(RULES, new Map([['lora|600|false', chars('a')]])))).toEqual([
'lora-latin-700-normal.woff2',
]);
expect(files(pickFaceRules(RULES, new Map([['lora|400|true', chars('a')]])))).toEqual([
'lora-latin-400-italic.woff2',
]);
expect(files(pickFaceRules(RULES, new Map([['anton|400|true', chars('a')]])))).toEqual([
'anton-latin-400-normal.woff2',
]);
expect(pickFaceRules(RULES, new Map([['georgia|400|false', chars('a')]]))).toEqual([]);
});
});
describe('reading the page', () => {
it('FE-STUDIO-SNAPFONT-005: reads every face the text under the root is set in, with its characters', () => {
const root = page(
'<p style="font-family: \'Lora\', Georgia, serif; font-weight: 700">Porto</p>' +
'<svg><text style="font-family: Caveat, cursive; font-style: italic">ab</text></svg>' +
'<p style="font-family: Lora; font-weight: bold">Té</p>' +
'<p style="font-family: Anton"> </p>'
);
const uses = faceUsesOf(root);
expect([...uses.keys()].sort()).toEqual(['caveat|400|true', 'lora|700|false']);
expect(uses.get('lora|700|false')).toEqual(chars('PortoTé'));
});
it('FE-STUDIO-SNAPFONT-006: reads the face rules of the stylesheets it may read, inside grouping rules too', () => {
const face = (family: string) => ({
cssText: `@font-face { font-family: "${family}"; src: url(/assets/${family}.woff2) format("woff2"); }`,
});
const fake = {
baseURI: 'https://trek.example/',
styleSheets: [
{
href: null,
cssRules: [face('Lora'), { cssText: '.x{}' }, { cssText: '@media print {}', cssRules: [face('Anton')] }],
},
{
href: 'https://cdn.example/a.css',
get cssRules(): never {
throw new DOMException('cross origin', 'SecurityError');
},
},
],
} as unknown as Document;
expect(faceRulesOf(fake).map((r) => [r.family, r.url])).toEqual([
['Lora', 'https://trek.example/assets/Lora.woff2'],
['Anton', 'https://trek.example/assets/Anton.woff2'],
]);
});
});
describe('the snapshot css', () => {
it('FE-STUDIO-SNAPFONT-007: inlines exactly the faces the page draws, and leaves out one that will not load', async () => {
const root = page(
'<p style="font-family: Lora; font-weight: 700">Porto</p><p style="font-family: Caveat">travel</p>'
);
const load = vi.fn(async (url: string) =>
url.includes('caveat') ? null : new Blob(['font'], { type: 'font/woff2' })
);
const css = await snapshotFontCss(root, load, RULES);
expect(load.mock.calls.map(([url]) => url.split('/').pop()).sort()).toEqual([
'caveat-latin-400-normal.woff2',
'lora-latin-700-normal.woff2',
]);
expect(css).toContain('@font-face{font-family:"Lora";font-style:normal;font-weight:700;unicode-range:U+0000-00FF');
expect(css).toContain('src:url(data:font/woff2;base64,');
expect(css).not.toContain('Caveat');
});
it('FE-STUDIO-SNAPFONT-008: answers null when the page draws no bundled face or none of its files load, so the library looks for itself', async () => {
const root = page('<p style="font-family: Georgia">Porto</p>');
expect(await snapshotFontCss(root, vi.fn(), RULES)).toBeNull();
const lora = page('<p style="font-family: Lora">Porto</p>');
const failing = vi.fn(async () => {
throw new Error('offline');
});
expect(await snapshotFontCss(lora, failing, RULES)).toBeNull();
});
});
@@ -1,233 +0,0 @@
/**
* The typefaces a page snapshot for the assistant carries, and only those.
*
* modern-screenshot draws the page as an SVG image, and an SVG image cannot
* reach the document's fonts, so every face it shows has to travel inside it.
* Left to itself the library embeds every @font-face rule of every family the
* page names: all nine weights of Montserrat, both styles, every script
* subset, woff and woff2. With thirty four book families that is megabytes of
* fonts per page for a picture whose text uses one weight of two families.
*
* So the snapshot reads what the page really draws (family, weight, slant and
* the characters set in it), picks the one rule per subset the browser would
* pick for that, and hands the library exactly those faces as data URLs. A
* page set in Lora and Caveat embeds the latin files of Lora and Caveat, and
* nothing else.
*/
/** One @font-face rule, as far as the choice of face goes. */
export interface FaceRule {
family: string;
weight: number;
italic: boolean;
/** The CSS unicode-range, or '' for a face that covers everything. */
unicodeRange: string;
/** The woff2 file, else the first file the rule names, resolved to a full URL. */
url: string;
}
/** What the page draws in one face: the characters set in it. */
type FaceUses = Map<string, Set<number>>;
const faceKey = (family: string, weight: number, italic: boolean) => `${family.toLowerCase()}|${weight}|${italic}`;
const unquote = (v: string) => v.trim().replace(/^["']|["']$/g, '');
/** A rule's descriptors by name, read from its text: `font-weight: 700` and the like. */
function descriptors(css: string): Map<string, string> {
const body = css.slice(css.indexOf('{') + 1, css.lastIndexOf('}'));
const out = new Map<string, string>();
for (const part of body.split(';')) {
const at = part.indexOf(':');
if (at > 0) out.set(part.slice(0, at).trim().toLowerCase(), part.slice(at + 1).trim());
}
return out;
}
/** A CSS weight as a number: the keywords as CSS reads them, anything else as 400. */
export function cssWeight(value: string): number {
if (value === 'bold') return 700;
const n = Number.parseInt(value, 10);
return Number.isFinite(n) && n >= 1 && n <= 1000 ? n : 400;
}
/** The file a rule's src names, the woff2 one first, resolved against where the stylesheet came from. */
function sourceOf(src: string, base: string): string | null {
const urls = [...src.matchAll(/url\((["']?)([^"')]+)\1\) ?(?:format\(["']?([\w-]+)["']?\))?/g)];
const pick = urls.find((m) => m[3] === 'woff2') ?? urls[0];
if (!pick?.[2]) return null;
try {
return new URL(pick[2], base).href;
} catch {
// A source no URL can be made of is a face the snapshot does without.
return null;
}
}
/** One @font-face rule read from its text, or null for any other rule or one that names no file. */
export function parseFaceRule(css: string, base: string): FaceRule | null {
if (!css.trimStart().toLowerCase().startsWith('@font-face')) return null;
const d = descriptors(css);
const family = unquote(d.get('font-family') ?? '');
const url = sourceOf(d.get('src') ?? '', base);
if (!family || !url) return null;
return {
family,
weight: cssWeight(d.get('font-weight') || '400'),
italic: /italic|oblique/i.test(d.get('font-style') ?? ''),
unicodeRange: d.get('unicode-range') ?? '',
url,
};
}
/** A unicode-range as code point spans: `U+0000-00FF, U+0131, U+4??` all read. */
export function parseUnicodeRange(range: string): [number, number][] {
return range
.split(',')
.map((part) => part.trim().replace(/^u\+/i, ''))
.filter(Boolean)
.flatMap((part): [number, number][] => {
if (part.includes('?')) {
return [[Number.parseInt(part.replace(/\?/g, '0'), 16), Number.parseInt(part.replace(/\?/g, 'F'), 16)]];
}
const [from, to] = part.split('-');
const a = Number.parseInt(from ?? '', 16);
const b = to === undefined ? a : Number.parseInt(to, 16);
return Number.isFinite(a) && Number.isFinite(b) ? [[a, b]] : [];
});
}
/** Whether a rule's range takes any of the characters; a rule without one takes them all. */
export function coversAny(range: string, chars: ReadonlySet<number>): boolean {
if (!range) return true;
const spans = parseUnicodeRange(range);
for (const c of chars) if (spans.some(([a, b]) => c >= a && c <= b)) return true;
return false;
}
/**
* The rules the browser would draw these uses with: per family and slant, the
* nearest weight it has (the renderer only ever asks for weights a family
* ships, so this is the exact one in practice), an upright face where there is
* no italic, and of those only the subsets whose range the text reaches.
*/
export function pickFaceRules(rules: readonly FaceRule[], uses: FaceUses): FaceRule[] {
const picked = new Map<string, FaceRule>();
for (const [key, chars] of uses) {
const [family = '', weightText = '400', italicText = 'false'] = key.split('|');
const weight = Number(weightText);
const ofFamily = rules.filter((r) => r.family.toLowerCase() === family);
const slanted = ofFamily.filter((r) => r.italic === (italicText === 'true'));
const styled = slanted.length ? slanted : ofFamily.filter((r) => !r.italic);
if (!styled.length) continue;
const nearest = styled.reduce((best, r) =>
Math.abs(r.weight - weight) < Math.abs(best.weight - weight) ? r : best
).weight;
for (const rule of styled) {
if (rule.weight === nearest && coversAny(rule.unicodeRange, chars)) picked.set(rule.url, rule);
}
}
return [...picked.values()];
}
/** The faces the text under `root` is set in, each with the characters it sets. */
export function faceUsesOf(root: Element): FaceUses {
const uses: FaceUses = new Map();
const view = root.ownerDocument.defaultView;
if (!view) return uses;
const walker = root.ownerDocument.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const words = node.textContent ?? '';
const parent = node.parentElement;
if (!parent || !words.trim()) continue;
const style = view.getComputedStyle(parent);
const family = unquote(style.fontFamily.split(',')[0] ?? '');
if (!family) continue;
const key = faceKey(family, cssWeight(style.fontWeight), /italic|oblique/.test(style.fontStyle));
const chars = uses.get(key) ?? new Set<number>();
for (const ch of words) {
const code = ch.codePointAt(0);
if (code !== undefined && code > 32) chars.add(code);
}
uses.set(key, chars);
}
return uses;
}
/** Every @font-face rule the document's stylesheets hold, inside grouping rules too. */
export function faceRulesOf(doc: Document): FaceRule[] {
const out: FaceRule[] = [];
const walk = (list: CSSRuleList, base: string) => {
for (const rule of Array.from(list)) {
const face = parseFaceRule(rule.cssText, base);
if (face) out.push(face);
else if ('cssRules' in rule) walk((rule as CSSGroupingRule).cssRules, base);
}
};
for (const sheet of Array.from(doc.styleSheets)) {
let rules: CSSRuleList;
try {
rules = sheet.cssRules;
} catch {
// A stylesheet from another origin cannot be read, and holds no book face.
continue;
}
walk(rules, sheet.href ?? doc.baseURI);
}
return out;
}
/** A file as a data URL. */
function asDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error ?? new Error('font read failed'));
reader.readAsDataURL(blob);
});
}
type FetchFont = (url: string) => Promise<Blob | null>;
/** A face file from this server, out of the cache when the page has already drawn it. */
const fetchFont: FetchFont = async (url) => {
if (new URL(url).origin !== window.location.origin) return null;
const res = await fetch(url, {
credentials: 'same-origin',
cache: 'force-cache',
signal: AbortSignal.timeout(8_000),
});
return res.ok ? res.blob() : null;
};
/** One face as a rule the snapshot can carry: its file inline. */
async function inlineRule(rule: FaceRule, load: FetchFont): Promise<string | null> {
try {
const blob = await load(rule.url);
if (!blob) return null;
const range = rule.unicodeRange ? `unicode-range:${rule.unicodeRange};` : '';
const family = rule.family.replace(/"/g, '');
const style = rule.italic ? 'italic' : 'normal';
return `@font-face{font-family:"${family}";font-style:${style};font-weight:${rule.weight};${range}src:url(${await asDataUrl(blob)});}`;
} catch {
// A face that cannot be fetched is drawn in its fallback; the page still goes.
return null;
}
}
/**
* The @font-face rules for the faces the page under `root` draws, with their
* files inline, ready for modern-screenshot's `font.cssText`. Null when there
* is nothing to carry, so the caller lets the library look for itself.
*/
export async function snapshotFontCss(
root: Element,
load: FetchFont = fetchFont,
available: readonly FaceRule[] = faceRulesOf(root.ownerDocument)
): Promise<string | null> {
const rules = pickFaceRules(available, faceUsesOf(root));
if (!rules.length) return null;
const inlined = await Promise.all(rules.map((rule) => inlineRule(rule, load)));
const css = inlined.filter((r): r is string => r !== null);
return css.length ? css.join('\n') : null;
}
-174
View File
@@ -1,174 +0,0 @@
/* ============================================================================
TREK Studio: the typeface picker and the weight slider.
Its own file beside studio.css, which is at its size ceiling. Same rules as
there: every selector is scoped under `.trek-dash` (dashboard.css resets
`button` at a specificity a bare class loses to), and every colour is one of
the dashboard tokens, so the picker follows the user's theme and accent.
The family names are set in their own faces and at the user's text size,
which is why they scale with --fs-scale-body rather than sitting at a fixed
pixel size: a picker is read, and a name too small to read is a name that
tells you nothing about its face.
============================================================================ */
.trek-dash .st-fontpick {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.trek-dash .st-fontpick-current,
.trek-dash .st-fontpick-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
text-align: start;
color: var(--ink);
transition:
background 0.14s,
border-color 0.14s,
color 0.14s;
}
.trek-dash .st-fontpick-current {
height: 40px;
padding-inline: 10px 8px;
border-radius: 8px;
border: 1px solid var(--line-2);
background: var(--surface);
}
.trek-dash .st-fontpick-current:hover {
border-color: color-mix(in oklch, var(--accent) 40%, var(--line-2));
}
.trek-dash .st-fontpick-current:focus-visible,
.trek-dash .st-fontpick-row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.trek-dash .st-fontpick.is-open .st-fontpick-current {
border-color: var(--accent);
}
.trek-dash .st-fontpick-face {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.25;
font-size: calc(16px * var(--fs-scale-body, 1));
}
.trek-dash .st-fontpick-span {
flex: none;
font-size: calc(11px * var(--fs-scale-caption, 1));
color: var(--ink-3);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.trek-dash .st-fontpick-caret {
flex: none;
color: var(--ink-3);
transition: transform 0.18s;
}
.trek-dash .st-fontpick.is-open .st-fontpick-caret {
transform: rotate(180deg);
}
/* The open list. It sits in the inspector's flow and scrolls on its own, so
the inspector around it keeps its place. */
.trek-dash .st-fontpick-panel {
display: flex;
flex-direction: column;
border-radius: 12px;
border: 1px solid var(--line);
background: color-mix(in oklch, var(--surface) 70%, transparent);
overflow: hidden;
}
.trek-dash .st-fontpick-search {
margin: 8px 8px 4px;
}
.trek-dash .st-fontpick-list {
max-height: 320px;
overflow-y: auto;
overscroll-behavior: contain;
padding: 2px 4px 6px;
}
.trek-dash .st-fontpick-group + .st-fontpick-group {
margin-top: 4px;
}
.trek-dash .st-fontpick-label {
position: sticky;
top: 0;
z-index: 1;
padding: 8px 8px 4px;
font-size: calc(10.5px * var(--fs-scale-caption, 1));
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-3);
background: color-mix(in oklch, var(--surface) 92%, transparent);
}
.trek-dash .st-fontpick-row {
min-height: 38px;
padding-inline: 8px;
border-radius: 8px;
color: var(--ink-2);
}
.trek-dash .st-fontpick-row:hover {
background: var(--surface);
color: var(--ink);
}
.trek-dash .st-fontpick-row.is-on {
color: var(--ink);
background: color-mix(in oklch, var(--accent) 10%, transparent);
}
.trek-dash .st-fontpick-tick {
flex: none;
width: 14px;
display: grid;
place-items: center;
color: var(--accent);
}
.trek-dash .st-fontpick-none {
padding: 10px 8px;
margin: 0;
}
/* ----------------- weight ----------------- */
.trek-dash .st-weight {
width: 100%;
}
.trek-dash .st-weight > .st-weight-value,
.trek-dash .st-weight > .st-weight-only {
font-size: calc(11.5px * var(--fs-scale-caption, 1));
color: var(--ink-2);
white-space: nowrap;
}
.trek-dash .st-weight > .st-weight-value {
flex: none;
min-width: 84px;
text-align: end;
font-variant-numeric: tabular-nums;
}
.trek-dash .st-weight > .st-weight-only {
flex: 1;
color: var(--ink-3);
}
@media (prefers-reduced-motion: reduce) {
.trek-dash .st-fontpick-caret,
.trek-dash .st-fontpick-current,
.trek-dash .st-fontpick-row {
transition: none;
}
}
+641
View File
@@ -0,0 +1,641 @@
/* ============================================================================
TREK Studio: the paint picker, the swatch rows and the page background.
Its own sheet beside studio.css (which is past the size ceiling), imported by
the components that use it. Scoped under `.trek-dash` for the same reason
every Studio rule is: dashboard.css resets `button` at (0,1,1).
Chrome only. The colours a swatch, a stop or a preview shows are the book's
and arrive inline; everything around them resolves against the dashboard
tokens, so the picker follows the user's theme and accent.
============================================================================ */
/* The chequerboard under anything see-through, drawn in the ink so it reads in
light and dark alike. */
.trek-dash .st-paint,
.trek-dash .st-swatch-stack {
--st-check: color-mix(in oklch, var(--ink) 9%, transparent);
--st-check-bg: conic-gradient(var(--st-check) 25%, transparent 0 50%, var(--st-check) 0 75%, transparent 0) 0 0 / 8px
8px;
}
/* ----------------- swatch rows ----------------- */
.trek-dash .st-swatch-stack {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.trek-dash .st-swatch {
position: relative;
}
.trek-dash .st-swatch:focus {
outline: none;
}
.trek-dash .st-swatch:focus-visible {
box-shadow:
0 0 0 2px var(--accent-soft),
0 0 0 3px var(--accent);
}
.trek-dash .st-swatch-row {
display: flex;
align-items: center;
gap: 6px;
padding-top: 6px;
border-top: 1px dashed var(--line);
}
.trek-dash .st-swatch-row-icon {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 22px;
color: var(--ink-3);
}
.trek-dash .st-swatch-row .st-swatches {
flex: 1;
min-width: 0;
}
/* ----------------- opacity ----------------- */
.trek-dash .st-alpha {
display: flex;
align-items: center;
gap: 10px;
padding-top: 2px;
}
.trek-dash .st-alpha-track {
position: relative;
flex: 1;
height: 14px;
border-radius: 999px;
background: var(--st-check-bg), var(--surface);
box-shadow: inset 0 0 0 1px var(--line-2);
/* The colour fades in left to right in every language, like the slider it is. */
direction: ltr;
}
.trek-dash .st-alpha-track::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(90deg, transparent, var(--st-alpha-colour));
}
.trek-dash .st-alpha-value,
.trek-dash .st-paint-value {
flex: none;
min-width: 34px;
text-align: end;
font-size: calc(11px * var(--fs-scale-caption, 1));
font-variant-numeric: tabular-nums;
color: var(--ink-2);
}
/* A range input with nothing of the browser's left but the thumb. */
.trek-dash .st-alpha input[type='range'],
.trek-dash .st-paint-slider input[type='range'] {
-webkit-appearance: none;
appearance: none;
display: block;
width: 100%;
margin: 0;
background: transparent;
cursor: pointer;
}
.trek-dash .st-alpha input[type='range'] {
position: relative;
height: 14px;
}
.trek-dash .st-paint-slider input[type='range'] {
height: 16px;
/* The track is drawn as the input's own background so the thumb sits on it. */
background: linear-gradient(var(--line-2), var(--line-2)) center / 100% 3px no-repeat;
border-radius: 999px;
}
.trek-dash .st-alpha input[type='range']::-webkit-slider-thumb,
.trek-dash .st-paint-slider input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--surface);
box-shadow:
0 0 0 1px var(--line-2),
var(--sh-sm);
transition: transform 0.14s;
}
.trek-dash .st-alpha input[type='range']::-moz-range-thumb,
.trek-dash .st-paint-slider input[type='range']::-moz-range-thumb {
width: 14px;
height: 14px;
border: none;
border-radius: 50%;
background: var(--surface);
box-shadow:
0 0 0 1px var(--line-2),
var(--sh-sm);
}
.trek-dash .st-alpha input[type='range']:active::-webkit-slider-thumb,
.trek-dash .st-paint-slider input[type='range']:active::-webkit-slider-thumb {
transform: scale(1.12);
}
.trek-dash .st-alpha input[type='range']:focus {
outline: none;
}
.trek-dash .st-alpha input[type='range']:focus-visible::-webkit-slider-thumb,
.trek-dash .st-paint-slider input[type='range']:focus-visible::-webkit-slider-thumb {
box-shadow:
0 0 0 2px var(--accent-soft),
0 0 0 3px var(--accent);
}
/* ----------------- the picker ----------------- */
.trek-dash .st-paint {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
/* Four kinds, each with a chip of what it gives: read at a glance, not a menu. */
.trek-dash .st-paint-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 2px;
padding: 2px;
border-radius: 10px;
background: color-mix(in oklch, var(--ink) 5%, transparent);
}
.trek-dash .st-paint-tab {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 0;
padding: 6px 2px 5px;
border-radius: 8px;
color: var(--ink-3);
transition:
background 0.16s,
color 0.16s,
box-shadow 0.16s;
}
.trek-dash .st-paint-tab:hover {
color: var(--ink);
}
.trek-dash .st-paint-tab.is-on {
background: var(--surface);
color: var(--ink);
box-shadow:
var(--sh-xs),
0 0 0 1px var(--line);
}
.trek-dash .st-paint-tab:focus {
outline: none;
}
.trek-dash .st-paint-tab:focus-visible {
box-shadow:
0 0 0 2px var(--accent-soft),
0 0 0 3px var(--accent);
}
.trek-dash .st-paint-tab-chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 14px;
border-radius: 4px;
box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--ink) 14%, transparent);
background-color: var(--surface);
color: var(--ink-3);
transition: transform 0.16s;
}
.trek-dash .st-paint-tab.is-on .st-paint-tab-chip {
transform: scale(1.06);
}
.trek-dash .st-paint-tab-chip.is-none {
background: transparent;
}
/* A pattern's chip is its ground with a few diagonal strokes of ink, a sign rather than the repeat. */
.trek-dash .st-paint-tab-chip.is-pattern {
background-image: repeating-linear-gradient(
45deg,
color-mix(in oklch, var(--ink) 55%, transparent) 0 1.5px,
transparent 1.5px 4px
);
}
.trek-dash .st-paint-tab-label {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: calc(10.5px * var(--fs-scale-caption, 1));
font-weight: 500;
}
.trek-dash .st-paint-body {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.trek-dash .st-paint-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.trek-dash .st-paint-row.is-dial {
justify-content: flex-start;
gap: 12px;
}
.trek-dash .st-paint-row.is-dial > .st-num,
.trek-dash .st-paint-row.is-dial > .st-paint-slider {
flex: 1;
min-width: 0;
}
.trek-dash .st-paint-field {
display: flex;
flex-direction: column;
gap: 7px;
min-width: 0;
}
.trek-dash .st-paint-caption {
font-size: calc(11px * var(--fs-scale-caption, 1));
color: var(--ink-3);
line-height: 1.25;
font-variant-numeric: tabular-nums;
}
.trek-dash .st-paint-stop {
display: flex;
flex-direction: column;
gap: 8px;
padding: 9px 10px 10px;
border-radius: 10px;
background: color-mix(in oklch, var(--ink) 3.5%, transparent);
}
.trek-dash .st-chip:disabled {
opacity: 0.4;
cursor: default;
}
.trek-dash .st-chip:disabled:hover {
background: color-mix(in oklch, var(--ink) 5%, transparent);
color: var(--ink-2);
}
/* A small segmented choice inside the picker, quieter than the tabs above it. */
.trek-dash .st-seg {
display: inline-flex;
padding: 2px;
gap: 2px;
border-radius: 8px;
background: color-mix(in oklch, var(--ink) 5%, transparent);
}
.trek-dash .st-seg-item {
height: 23px;
padding: 0 10px;
border-radius: 6px;
font-size: calc(11.5px * var(--fs-scale-caption, 1));
font-weight: 500;
color: var(--ink-3);
transition:
background 0.16s,
color 0.16s;
}
.trek-dash .st-seg-item:hover {
color: var(--ink);
}
.trek-dash .st-seg-item.is-on {
background: var(--surface);
color: var(--ink);
box-shadow: var(--sh-xs);
}
.trek-dash .st-seg-item:focus {
outline: none;
}
.trek-dash .st-seg-item:focus-visible {
box-shadow: 0 0 0 2px var(--accent);
}
/* ----------------- the stop bar ----------------- */
/* Room below the bar for the handles, which hang under it like a ruler's marks. */
.trek-dash .st-stopbar-wrap {
padding: 0 8px 14px;
}
.trek-dash .st-stopbar {
position: relative;
height: 24px;
border-radius: 7px;
background: var(--st-check-bg), var(--surface);
box-shadow: inset 0 0 0 1px var(--line-2);
cursor: copy;
/* A picture of the gradient, left to right in every language. */
direction: ltr;
touch-action: none;
}
.trek-dash .st-stopbar-fill {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--ink) 10%, transparent);
}
.trek-dash .st-stop {
position: absolute;
inset-inline-start: var(--at);
top: 13px;
width: 16px;
height: 22px;
margin-inline-start: -8px;
padding: 0;
border-radius: 5px 5px 8px 8px;
background: var(--stop);
box-shadow:
0 0 0 2px var(--surface),
0 0 0 3px var(--line-2),
var(--sh-sm);
cursor: grab;
touch-action: none;
transition:
box-shadow 0.14s,
transform 0.14s,
opacity 0.14s;
}
/* The tip that points up into the bar at the stop's exact position. */
.trek-dash .st-stop::before {
content: '';
position: absolute;
inset-inline-start: 50%;
top: -6px;
width: 2px;
height: 6px;
margin-inline-start: -1px;
border-radius: 1px;
background: var(--surface);
box-shadow: 0 0 0 1px var(--line-2);
}
.trek-dash .st-stop:hover {
transform: translateY(-1px);
}
.trek-dash .st-stop:active {
cursor: grabbing;
}
.trek-dash .st-stop.is-on {
box-shadow:
0 0 0 2px var(--surface),
0 0 0 4px var(--accent),
var(--sh-md);
z-index: 1;
}
.trek-dash .st-stop.is-leaving {
opacity: 0.35;
transform: translateY(6px) scale(0.9);
}
.trek-dash .st-stop:focus {
outline: none;
}
.trek-dash .st-stop:focus-visible {
box-shadow:
0 0 0 2px var(--surface),
0 0 0 4px var(--accent);
}
/* ----------------- the angle dial ----------------- */
.trek-dash .st-dial {
position: relative;
flex: none;
width: 40px;
height: 40px;
border-radius: 50%;
background:
radial-gradient(circle, var(--surface) 0 55%, transparent 56%),
repeating-conic-gradient(
from -1deg,
color-mix(in oklch, var(--ink) 30%, transparent) 0 2deg,
transparent 2deg 45deg
),
var(--surface);
box-shadow:
inset 0 0 0 1px var(--line-2),
var(--sh-xs);
cursor: grab;
touch-action: none;
/* A compass: 0 is up and 90 is to the right whichever way the text runs. */
direction: ltr;
transition: box-shadow 0.14s;
}
.trek-dash .st-dial:active {
cursor: grabbing;
}
.trek-dash .st-dial:focus {
outline: none;
}
.trek-dash .st-dial:focus-visible {
box-shadow:
inset 0 0 0 1px var(--line-2),
0 0 0 2px var(--accent-soft),
0 0 0 3px var(--accent);
}
.trek-dash .st-dial-needle {
position: absolute;
inset: 0;
pointer-events: none;
transition: transform 0.08s ease-out;
}
.trek-dash .st-dial-knob {
position: absolute;
top: 4px;
inset-inline-start: 50%;
width: 8px;
height: 8px;
margin-inline-start: -4px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 2px var(--surface);
}
/* The needle runs from the centre to the knob. */
.trek-dash .st-dial-needle::before {
content: '';
position: absolute;
top: 10px;
bottom: 50%;
inset-inline-start: 50%;
width: 1.5px;
margin-inline-start: -0.75px;
border-radius: 1px;
background: color-mix(in oklch, var(--accent) 70%, transparent);
}
.trek-dash .st-dial-needle::after {
content: '';
position: absolute;
top: 50%;
inset-inline-start: 50%;
width: 5px;
height: 5px;
margin: -2.5px 0 0 -2.5px;
border-radius: 50%;
background: var(--ink-2);
}
/* ----------------- the radial centre ----------------- */
.trek-dash .st-radial-pad {
position: relative;
flex: none;
width: 64px;
height: 44px;
border-radius: 8px;
background-color: var(--surface);
box-shadow: inset 0 0 0 1px var(--line-2);
cursor: crosshair;
direction: ltr;
touch-action: none;
}
.trek-dash .st-radial-pad:focus {
outline: none;
}
.trek-dash .st-radial-pad:focus-visible {
box-shadow:
inset 0 0 0 1px var(--line-2),
0 0 0 2px var(--accent);
}
.trek-dash .st-radial-dot {
position: absolute;
inset-inline-start: var(--cx);
top: var(--cy);
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
border-radius: 50%;
background: var(--surface);
box-shadow:
0 0 0 1.5px var(--accent),
var(--sh-sm);
pointer-events: none;
}
/* ----------------- sliders ----------------- */
.trek-dash .st-paint-slider {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.trek-dash .st-paint-slider-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
/* ----------------- pattern tiles ----------------- */
.trek-dash .st-pattern-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 5px;
}
.trek-dash .st-pattern-tile {
aspect-ratio: 1;
padding: 0;
overflow: hidden;
border-radius: 8px;
background-color: var(--surface);
box-shadow: inset 0 0 0 1px var(--line-2);
transition:
transform 0.14s,
box-shadow 0.14s;
}
.trek-dash .st-pattern-tile.is-clear {
background: var(--st-check-bg), var(--surface);
}
.trek-dash .st-pattern-tile:hover {
transform: translateY(-1px);
box-shadow:
inset 0 0 0 1px var(--line-2),
var(--sh-sm);
}
.trek-dash .st-pattern-tile.is-on {
box-shadow: 0 0 0 2px var(--accent);
}
.trek-dash .st-pattern-tile:focus {
outline: none;
}
.trek-dash .st-pattern-tile:focus-visible {
box-shadow:
0 0 0 2px var(--accent-soft),
0 0 0 3px var(--accent);
}
.trek-dash .st-pattern-tile-art {
display: block;
width: 100%;
height: 100%;
}
/* ----------------- page background ----------------- */
.trek-dash .st-paint-apply {
align-self: flex-start;
margin-top: 10px;
}
.trek-dash .st-paint-apply.is-done {
background: color-mix(in oklch, var(--accent) 14%, transparent);
color: var(--ink);
}
.trek-dash .st-paint-field + .st-paint-field,
.trek-dash .st-paint-apply + .st-paint-field {
margin-top: 14px;
}
.trek-dash .st-paint-hint {
padding: 0;
font-size: calc(11.5px * var(--fs-scale-caption, 1));
line-height: 1.5;
}
.trek-dash .st-bgphoto-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 5px;
}
.trek-dash .st-bgphoto {
position: relative;
aspect-ratio: 1;
padding: 0;
overflow: hidden;
border-radius: 8px;
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--line);
transition:
transform 0.14s,
box-shadow 0.14s;
}
.trek-dash .st-bgphoto img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.trek-dash .st-bgphoto:hover {
transform: translateY(-1px);
box-shadow: var(--sh-sm);
}
.trek-dash .st-bgphoto.is-on {
box-shadow: 0 0 0 2px var(--accent);
}
.trek-dash .st-bgphoto:focus {
outline: none;
}
.trek-dash .st-bgphoto:focus-visible {
box-shadow:
0 0 0 2px var(--accent-soft),
0 0 0 3px var(--accent);
}
@media (prefers-reduced-motion: reduce) {
.trek-dash .st-paint-tab,
.trek-dash .st-paint-tab-chip,
.trek-dash .st-stop,
.trek-dash .st-dial-needle,
.trek-dash .st-pattern-tile,
.trek-dash .st-bgphoto {
transition: none;
}
}
+31 -1
View File
@@ -1583,7 +1583,37 @@
flex: none;
}
/* The typeface picker lives in studio-fonts.css. */
/* ----------------- font picker ----------------- *
* Each family set in itself. A row of chips reading "Poppins / Lora / Bebas" in
* the interface font tells you their names and nothing about how they look,
* which is the only thing anyone is choosing between.
*/
.trek-dash .st-fonts {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
}
.trek-dash .st-font {
height: 32px;
padding: 0 8px;
border-radius: 8px;
border: 1px solid var(--line);
background: color-mix(in oklch, var(--surface) 55%, transparent);
color: var(--ink-2);
font-size: 14px;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
transition: background .14s, border-color .14s, color .14s;
}
.trek-dash .st-font:hover { background: var(--surface); color: var(--ink); }
.trek-dash .st-font.is-on {
color: var(--ink);
border-color: color-mix(in oklch, var(--accent) 50%, transparent);
background: color-mix(in oklch, var(--accent) 10%, transparent);
}
.trek-dash .st-chip:disabled { opacity: .32; cursor: default; }
/* ----------------- save state ----------------- *
-10
View File
@@ -3,13 +3,3 @@
/** The version this bundle was built as (client/package.json), set by `define` in vite.config.js. */
declare const __TREK_UI_VERSION__: string;
/**
* Big Shoulders Display's weights, imported for their stylesheets alone
* (components/Studio/bookFontFaces.ts). Its package maps `./*` to `./*.css`
* and has no `./*.css` entry, so the import names `100` rather than
* `100.css`, and vite/client's `*.css` declaration does not cover a bare
* name. Declared for this one package only, so a mistyped import of any
* other module still fails the check.
*/
declare module '@fontsource/big-shoulders-display/*';
@@ -1,93 +0,0 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { APP_FACES, bookFontFile, keepInPrecache, withoutRuntimeFonts } from '../../../bookFontPrecache.js';
/**
* Which font files the service worker installs (FE-SW-FONTS): the app's own
* faces as before, the latin woff2 of the seven book families that shipped
* first, and nothing else of the book's catalogue, which waits in the
* book-fonts runtime cache for a book that uses it.
*/
// Vitest runs with the client package as its root, so cwd is stable here.
const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), 'utf8');
const asset = (name: string) => `assets/${name}`;
describe('reading a font file name', () => {
it('FE-SW-FONTS-001: reads package, subset, weight and style as the build names them, and nothing else as a book face', () => {
expect(bookFontFile(asset('dm-serif-display-latin-ext-400-italic-Ab12_cD3.woff2'))).toEqual({
pkg: 'dm-serif-display',
subset: 'latin-ext',
weight: 400,
italic: true,
ext: 'woff2',
});
expect(bookFontFile(asset('dm-mono-latin-500-normal-xyz.woff'))).toMatchObject({ pkg: 'dm-mono', subset: 'latin' });
expect(bookFontFile(asset('geist-sans-latin-400-normal-abc.woff2'))).toBeNull();
expect(bookFontFile(asset('index-abc.js'))).toBeNull();
expect(bookFontFile(asset('lora.woff2'))).toBeNull();
});
});
describe('the precache', () => {
it('FE-SW-FONTS-002: keeps the faces the app draws, whatever their subset and format', () => {
expect(keepInPrecache(asset('poppins-latin-500-normal-abc.woff'))).toBe(true);
expect(keepInPrecache(asset('poppins-devanagari-700-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('museomoderno-latin-800-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('geist-sans-latin-600-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('index-abc.js'))).toBe(true);
});
it('FE-SW-FONTS-003: keeps only the latin woff2 of the seven book families that shipped first', () => {
expect(keepInPrecache(asset('lora-latin-400-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('bebas-neue-latin-400-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('museomoderno-latin-500-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('lora-latin-400-normal-abc.woff'))).toBe(false);
expect(keepInPrecache(asset('lora-cyrillic-400-normal-abc.woff2'))).toBe(false);
expect(keepInPrecache(asset('lora-latin-400-italic-abc.woff2'))).toBe(false);
expect(keepInPrecache(asset('eb-garamond-latin-800-normal-abc.woff2'))).toBe(false);
expect(keepInPrecache(asset('poppins-latin-900-normal-abc.woff2'))).toBe(false);
});
it('FE-SW-FONTS-004: leaves every file of the new families to the runtime cache', () => {
for (const name of [
'anton-latin-400-normal-abc.woff2',
'caveat-latin-700-normal-abc.woff2',
'fraunces-latin-ext-900-italic-abc.woff',
]) {
expect(keepInPrecache(asset(name)), name).toBe(false);
}
const manifest = [
{ url: asset('index-abc.js'), revision: null },
{ url: asset('anton-latin-400-normal-abc.woff2'), revision: null },
{ url: asset('inter-latin-400-normal-abc.woff2'), revision: null },
];
expect(withoutRuntimeFonts(manifest)).toEqual({ manifest: [manifest[0], manifest[2]], warnings: [] });
});
it('FE-SW-FONTS-005: knows the app faces main.tsx imports, so the chrome never loses one offline', () => {
const imported = [...read('src/main.tsx').matchAll(/@fontsource\/([a-z0-9-]+)\/(\d+)\.css/g)].map(
(m) => `${m[1]}/${m[2]}`
);
const declared = Object.entries(APP_FACES).flatMap(([pkg, weights]) => weights.map((w) => `${pkg}/${w}`));
expect(imported.filter((face) => !face.startsWith('geist-sans/')).sort()).toEqual(declared.sort());
});
it('FE-SW-FONTS-006: is wired into the worker, with the runtime rule the Studio fills', () => {
const config = read('vite.config.js');
expect(config).toContain('manifestTransforms: [withoutRuntimeFonts]');
const rule =
/urlPattern:\s*(\/[^\n]*woff2[^\n]*\/i),\s*\n\s*handler:\s*'CacheFirst',\s*\n\s*options:\s*\{\s*\n\s*cacheName:\s*'book-fonts'/.exec(
config
);
expect(rule).not.toBeNull();
const literal = rule?.[1] ?? '//';
const pattern = new RegExp(literal.slice(1, literal.lastIndexOf('/')), 'i');
expect(pattern.test('https://trek.example/assets/anton-latin-400-normal-abc.woff2')).toBe(true);
expect(pattern.test('https://trek.example/assets/lora-latin-400-normal-abc.woff')).toBe(true);
expect(pattern.test('https://trek.example/assets/index-abc.js')).toBe(false);
expect(pattern.test('https://trek.example/api/photos/1/file.woff2')).toBe(false);
});
});
+9 -19
View File
@@ -23,40 +23,30 @@ const STUDIO = 'src/components/Studio/bookFontFaces.ts'
// Vitest runs with the client package as its root, so cwd is stable here.
const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), 'utf8')
/** Every `@fontsource/<package>/<weight>[-italic][.css]` a file imports (see bookFontFaces.ts for the bare form). */
/** Every `@fontsource/<package>/<weight>.css` a file imports. */
function facesIn(rel: string): string[] {
return [...read(rel).matchAll(/@fontsource\/([a-z0-9-]+)\/(\d+(?:-italic)?)(?:\.css)?'/g)]
return [...read(rel).matchAll(/@fontsource\/([a-z0-9-]+)\/(\d+)\.css/g)]
.map(m => `${m[1]}/${m[2]}`)
}
/** A face as the import names it: the family's package, the weight and the italic. */
function faceOf(id: BookFontId, weight: number, italic = false): string {
return `${BOOK_FONTS[id].pkg}/${weight}${italic ? '-italic' : ''}`
/** @fontsource names its packages after the family, lowercased and hyphenated. */
function faceOf(id: BookFontId, weight: number): string {
return `${BOOK_FONTS[id].name.toLowerCase().replace(/ /g, '-')}/${weight}`
}
/** Every face the catalogue declares: each weight upright and each real italic. */
const declared = BOOK_FONTS_IDS.flatMap(id => [
...BOOK_FONTS[id].weights.map(w => faceOf(id, w)),
...BOOK_FONTS[id].italics.map(w => faceOf(id, w, true)),
])
describe('the bundled faces', () => {
it('loads every family the picker offers, in the weights it offers', () => {
const loaded = [...facesIn(APP), ...facesIn(STUDIO)]
for (const face of declared) {
expect(loaded, face).toContain(face)
}
})
/* @fontsource names its packages after the family, lowercased and hyphenated. */
it('takes every family from the package named after it', () => {
for (const id of BOOK_FONTS_IDS) {
expect(BOOK_FONTS[id].pkg, id).toBe(BOOK_FONTS[id].name.toLowerCase().replace(/ /g, '-'))
for (const weight of BOOK_FONTS[id].weights) {
expect(loaded, `${BOOK_FONTS[id].name} ${weight}`).toContain(faceOf(id, weight))
}
}
})
/* A weight nothing can ask for is a download nobody wanted. */
it('loads no weight the registry does not declare', () => {
const declared = BOOK_FONTS_IDS.flatMap(id => BOOK_FONTS[id].weights.map(w => faceOf(id, w)))
for (const face of facesIn(STUDIO)) {
expect(declared, face).toContain(face)
}
+5 -15
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'
import { BOOK_FONTS_IDS, bookTextElementSchema } from '@trek/shared'
import {
BOOK_FONTS, BOOK_FONT_ORDER, fontStack, fontsIn, hasItalic, hasWeight, nearestWeight,
BOOK_FONTS, BOOK_FONT_ORDER, fontStack, hasWeight, nearestWeight,
} from '../../../src/components/Studio/bookFonts'
/**
@@ -21,13 +21,9 @@ describe('the registry', () => {
}
})
it('offers every family in the picker, in one order, grouped by category', () => {
it('offers every family in the picker, in one order', () => {
expect([...BOOK_FONT_ORDER].sort()).toEqual([...BOOK_FONTS_IDS].sort())
expect(new Set(BOOK_FONT_ORDER).size).toBe(BOOK_FONT_ORDER.length)
expect(BOOK_FONT_ORDER[0]).toBe('sans')
expect(BOOK_FONT_ORDER[BOOK_FONT_ORDER.length - 1]).toBe('mono')
expect(fontsIn('script')).toContain('caveat')
expect(fontsIn('script').every(id => BOOK_FONTS[id].category === 'script')).toBe(true)
})
/*
@@ -38,17 +34,17 @@ describe('the registry', () => {
it('ends every stack in a generic family', () => {
for (const id of BOOK_FONTS_IDS) {
const last = BOOK_FONTS[id].stack.split(',').pop()!.trim()
expect(['sans-serif', 'serif', 'cursive', 'monospace'], `${id}: ${last}`).toContain(last)
expect(['sans-serif', 'serif', 'monospace'], `${id}: ${last}`).toContain(last)
}
})
it('names a bundled family first in every stack', () => {
// The first entry is the one that will actually be used, so it is the one
// that has to be self-hosted.
// bookFontFaces.test.ts holds every family to the package it is loaded from.
const bundled = ['Poppins', 'Inter', 'Lora', 'EB Garamond', 'Playfair Display', 'MuseoModerno', 'Bebas Neue']
for (const id of BOOK_FONTS_IDS) {
const first = BOOK_FONTS[id].stack.split(',')[0].replace(/"/g, '').trim()
expect(first, id).toBe(BOOK_FONTS[id].name)
expect(bundled, `${id}: ${first}`).toContain(first)
}
})
@@ -102,12 +98,6 @@ describe('weights', () => {
it('moves to the nearest weight a family really has', () => {
expect(nearestWeight('bebas', 700)).toBe(400)
expect(nearestWeight('sans', 700)).toBe(700)
expect(nearestWeight('oswald', 900)).toBe(700)
})
it('knows which families have a real italic', () => {
expect(hasItalic('garamond', 400)).toBe(true)
expect(hasItalic('sans', 400)).toBe(false)
})
it('leaves an unknown family alone rather than guessing', () => {
+28 -11
View File
@@ -128,6 +128,13 @@ function pick(c: HTMLElement, label: string) {
fireEvent.click(chip!)
}
/** A kind of paint, from the picker's tabs. */
function tab(c: HTMLElement, label: string) {
const button = [...c.querySelectorAll('.st-paint-tab')].find(b => b.textContent === label)
if (!button) throw new Error(`no ${label} tab`)
fireEvent.click(button)
}
/** On or off: the app switch, on its own line beside the words. */
function toggle(c: HTMLElement, label: string) {
const row = [...c.querySelectorAll('.st-switch')]
@@ -441,36 +448,46 @@ describe('a shape', () => {
/*
* The rule that matters here: a shape with neither fill nor stroke is a
* shape nobody can see or find again, so turning the fill off has to bring
* shape nobody can see or find again, so taking the fill away has to bring
* the outline on with it.
*/
it('cannot be turned invisible by switching the fill off', () => {
it('cannot be turned invisible by taking the fill away', () => {
load([shape({ fill: '#111827', stroke: null, strokeWidth: 0 })])
const { container } = draw()
toggle(container, 'journey.studio.fillOn')
tab(container, 'journey.studio.paint.none')
expect(el()).toMatchObject({ fill: null, stroke: '#141414', strokeWidth: 0.5 })
})
it('brings the fill back when it is switched on again', () => {
it('brings a fill back when one colour is chosen again', () => {
load([shape({ fill: null, stroke: '#141414', strokeWidth: 0.5 })])
const { container } = draw()
toggle(container, 'journey.studio.fillOn')
tab(container, 'journey.studio.paint.solid')
expect(el()).toMatchObject({ fill: '#111111', stroke: '#141414' })
})
it('fades the fill, and takes a direction once it does', () => {
load([shape()])
it('paints a gradient from its own colour, and keeps fill as the colour that stands for it', () => {
load([shape({ fill: '#1e3a8a' })])
const { container } = draw()
toggle(container, 'journey.studio.gradient')
expect(el()).toMatchObject({ gradient: 'down' })
tab(container, 'journey.studio.paint.gradient')
pick(container, 'journey.studio.gradientUp')
expect(el()).toMatchObject({ gradient: 'up' })
const painted = el() as Extract<BookElement, { kind: 'shape' }>
expect(painted.paint).toMatchObject({ type: 'linear', angle: 180, stops: [{ at: 0, color: '#1e3a8a' }, { at: 1 }] })
expect(painted.fill).toMatch(/^#[0-9a-f]{6}$/)
expect(painted.gradient).toBe('none')
})
/* A cover fade from before paint reads as the gradient it draws, so the picker opens on it. */
it('shows an old fade as a gradient', () => {
load([shape({ gradient: 'up' })])
const { container } = draw()
const on = container.querySelector('.st-paint-tab[aria-selected="true"]')
expect(on?.textContent).toBe('journey.studio.paint.gradient')
})
})
@@ -101,20 +101,6 @@ const tick = (name: string) => fireEvent.click(screen.getByRole('checkbox', { na
/** A switch, or any plain button: both answer to their accessible name. */
const press = (name: string) => fireEvent.click(screen.getByRole('button', { name }))
/** Opens the typeface list from its button and picks a family by the name its row is set in. */
function pickFamily(name: string) {
fireEvent.click(screen.getByRole('button', { name: /^journey\.studio\.fonts\.change: / }))
const row = screen.getAllByRole('option').find(o => o.querySelector('.st-fontpick-face')?.textContent === name)
if (!row) throw new Error(`no typeface row named ${name}`)
fireEvent.click(row)
}
/** Drags the weight slider to the stop of a weight among those the family ships. */
function slideWeight(weights: readonly number[], weight: number) {
const slider = screen.getByRole('slider', { name: 'journey.studio.weight' })
fireEvent.change(slider, { target: { value: String(weights.indexOf(weight)) } })
}
/** A section by its heading, since a label can repeat across sections. */
function section(container: HTMLElement, label: string): HTMLElement {
const found = [...container.querySelectorAll<HTMLElement>('.st-section')]
@@ -526,7 +512,7 @@ describe('the tail all five share', () => {
// Bebas ships one weight; a bold it does not have prints as a smeared
// regular, so the request moves rather than being passed through.
pickFamily('Bebas Neue')
press('Bebas Neue')
expect(saved().font).toBe('bebas')
expect(saved().weight).toBe(400)
@@ -536,7 +522,7 @@ describe('the tail all five share', () => {
const { container } = show(statsEl({ font: 'sans', weight: 500 }))
open(container, 'journey.studio.typography')
pickFamily('Lora')
press('Lora')
expect(saved().font).toBe('serif')
expect(saved().weight).toBe(500)
@@ -546,8 +532,7 @@ describe('the tail all five share', () => {
const { container } = show(countriesEl())
open(container, 'journey.studio.typography')
// Poppins ships 100 to 900, so 500 is the fifth stop.
slideWeight([100, 200, 300, 400, 500, 600, 700, 800, 900], 500)
pick('500')
expect(saved().weight).toBe(500)
})
@@ -0,0 +1,248 @@
import { act as hookAct, renderHook } from '@testing-library/react';
import type { BookDocument, BookElement, BookSpread } from '@trek/shared';
import { MAX_SPREAD_ELEMENTS, bookPageSetupSchema, bookShapeElementSchema, withSpreadPaint } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
backgroundCandidates,
backgroundPhotoOf,
backgroundToAll,
bleedFrame,
withBackgroundPhoto,
withoutBackgroundPhoto,
} from '../../../src/components/Studio/paint/backgroundPhoto';
import { usePaintChannel } from '../../../src/components/Studio/paint/usePaintChannel';
import { StudioBackgroundSection } from '../../../src/components/Studio/StudioBackgroundSection';
import type { JourneySource } from '../../../src/components/Studio/StudioSidebar';
import { useStudioStore } from '../../../src/store/studioStore';
import { act, fireEvent, render } from '../../helpers/render';
/** The value, or a failed test saying it was missing. */
function need<T>(value: T | null | undefined): T {
if (value == null) throw new Error('expected a value');
return value;
}
/**
* The page background: its paint, the same background on every page, and a
* photograph under the whole page; and the channel that makes a drag one undo.
*/
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
const frame = { x: 20, y: 20, w: 40, h: 40 };
const shape = (id: string): BookElement => bookShapeElementSchema.parse({ id, kind: 'shape', frame }) as BookElement;
const spread = (id: string, over: Partial<BookSpread> = {}): BookSpread => ({
id,
role: 'inner',
background: null,
elements: [],
parked: [],
entryId: null,
...over,
});
const book = (spreads: BookSpread[]): BookDocument => ({ version: 1, title: 'T', page, spreads }) as BookDocument;
const stripes = {
type: 'pattern' as const,
pattern: 'stripes' as const,
fg: '#112233',
bg: '#ffeedd',
scale: 1,
angle: 45,
};
const source: JourneySource = {
entries: [
{
id: 4,
title: 'Porto',
story: null,
location: null,
date: null,
lat: null,
lng: null,
mood: null,
weather: null,
pros: [],
cons: [],
photoIds: [21, 22],
},
],
photos: [
{ photoId: 21, entryIds: [4] },
{ photoId: 30, entryIds: [] },
],
photoEntries: {},
};
describe('a background photo', () => {
it('covers the whole sheet, bleed included, on a spread and on a single page', () => {
expect(bleedFrame(spread('a'), page)).toEqual({ x: -3, y: -3, w: 426, h: 216 });
expect(bleedFrame(spread('c', { role: 'cover' }), page)).toEqual({ x: -3, y: -3, w: 216, h: 216 });
});
it('goes in at the back, locked, and replaces the one there was', () => {
let n = 0;
const newId = () => `bg${++n}`;
const once = need(withBackgroundPhoto(spread('a', { elements: [shape('s1')] }), page, 12, newId));
expect(once.elements.map((e) => e.id)).toEqual(['bg1', 's1']);
expect(once.elements[0]).toMatchObject({
kind: 'photo',
photoId: 12,
locked: true,
fit: 'cover',
frame: { x: -3, w: 426 },
});
expect(backgroundPhotoOf(once, page)?.photoId).toBe(12);
const twice = need(withBackgroundPhoto(once, page, 13, newId));
expect(twice.elements.map((e) => e.id)).toEqual(['bg1', 's1']);
expect(backgroundPhotoOf(twice, page)?.photoId).toBe(13);
expect(withoutBackgroundPhoto(twice, page).elements.map((e) => e.id)).toEqual(['s1']);
const plain = spread('b', { elements: [shape('s1')] });
expect(withoutBackgroundPhoto(plain, page)).toBe(plain);
});
it('is not a photo that only happens to be at the back: unlocked, turned or smaller does not count', () => {
const placed = need(withBackgroundPhoto(spread('a'), page, 5, () => 'p'));
const back = placed.elements[0] as Extract<BookElement, { kind: 'photo' }>;
expect(backgroundPhotoOf({ ...placed, elements: [{ ...back, locked: false }] }, page)).toBeNull();
expect(backgroundPhotoOf({ ...placed, elements: [{ ...back, rotation: 3 }] }, page)).toBeNull();
expect(backgroundPhotoOf({ ...placed, elements: [{ ...back, frame }] }, page)).toBeNull();
expect(backgroundPhotoOf({ ...placed, elements: [{ ...back, photoId: null }] }, page)).toBeNull();
expect(backgroundPhotoOf(undefined, page)).toBeNull();
});
it('is refused on a full page', () => {
const full = spread('a', { elements: Array.from({ length: MAX_SPREAD_ELEMENTS }, (_, i) => shape(`s${i}`)) });
expect(withBackgroundPhoto(full, page, 1, () => 'x')).toBeNull();
});
it('is offered from the page first, then its entry, then the gallery, each once', () => {
expect(backgroundCandidates([3, 4], [4, 5], [5, 6, 7], 4)).toEqual([3, 4, 5, 6]);
});
});
describe('the same background on every page', () => {
it('copies the paint and its colour to the pages of its kind, the covers to the covers', () => {
const from = withSpreadPaint(spread('a'), stripes);
const doc = book([
spread('cv', { role: 'cover', background: '#202020' }),
from,
spread('b', { background: '#000000' }),
spread('l', { role: 'last' }),
]);
const out = backgroundToAll(doc, 1);
expect(out.spreads[2]).toMatchObject({ backgroundPaint: stripes, background: '#ffeedd' });
expect(out.spreads[3]).toMatchObject({ backgroundPaint: stripes });
expect(out.spreads[0]).toBe(doc.spreads[0]);
expect(backgroundToAll(doc, 9)).toBe(doc);
const covers = backgroundToAll(doc, 0);
expect(covers.spreads[2]).toBe(doc.spreads[2]);
const paper = backgroundToAll(book([spread('a'), spread('b', { background: '#000000' })]), 0);
expect(paper.spreads[1].background).toBeNull();
});
});
describe('the background section', () => {
beforeEach(() => {
useStudioStore.getState().load(book([spread('a', { entryId: 4, elements: [shape('s1')] }), spread('b')]));
});
const draw = () => render(<StudioBackgroundSection spreadIndex={0} page={page} source={source} t={(k) => k} />);
const spreads = () => need(useStudioStore.getState().doc).spreads;
it('paints the page, gives every page the same, and says so for a moment', () => {
vi.useFakeTimers();
try {
const { container } = draw();
fireEvent.click(
need(
[...container.querySelectorAll('.st-paint-tab')].find((b) => b.textContent === 'journey.studio.paint.solid')
)
);
expect(spreads()[0].background).toBe('#111111');
const apply = need(container.querySelector('.st-paint-apply'));
fireEvent.click(apply);
expect(spreads()[1].background).toBe('#111111');
expect(apply.textContent).toBe('journey.studio.paint.appliedAll');
act(() => {
vi.advanceTimersByTime(2000);
});
expect(apply.textContent).toBe('journey.studio.paint.applyAll');
} finally {
vi.useRealTimers();
}
});
it('offers the entry and the gallery photos, lays one under the page, and takes it away again', () => {
const { container } = draw();
const thumbs = container.querySelectorAll('.st-bgphoto');
expect(thumbs).toHaveLength(3);
fireEvent.click(thumbs[1]);
expect(backgroundPhotoOf(spreads()[0], page)?.photoId).toBe(22);
expect(need(container.querySelector('.st-bgphoto[aria-pressed="true"] img')).getAttribute('src')).toContain('22');
fireEvent.click(need(container.querySelector('.st-paint-apply.is-danger')));
expect(backgroundPhotoOf(spreads()[0], page)).toBeNull();
fireEvent.click(thumbs[0]);
fireEvent.click(need(container.querySelector('.st-bgphoto[aria-pressed="true"]')));
expect(spreads()[0].elements.map((e) => e.id)).toEqual(['s1']);
});
it('says how to get a photo when the journey has none', () => {
const { container } = render(
<StudioBackgroundSection
spreadIndex={1}
page={page}
source={{ entries: [], photos: [], photoEntries: {} }}
t={(k) => k}
/>
);
expect(container.querySelector('.st-bgphoto')).toBeNull();
expect(container.textContent).toContain('journey.studio.paint.noPhotos');
});
it('draws nothing for a page that is not there', () => {
const { container } = render(<StudioBackgroundSection spreadIndex={9} page={page} source={source} t={(k) => k} />);
expect(container.innerHTML).toBe('');
});
});
describe('the paint channel', () => {
beforeEach(() => {
useStudioStore.getState().load(book([spread('a')]));
});
it('makes a drag one undo step, and a click one of its own', () => {
const { result } = renderHook(() =>
usePaintChannel((paint) => (doc) => ({
...doc,
spreads: doc.spreads.map((sp) => withSpreadPaint(sp, paint)),
}))
);
const store = useStudioStore.getState;
hookAct(() => {
result.current.live({ type: 'solid', color: '#111111', alpha: 0.2 });
result.current.live({ type: 'solid', color: '#111111', alpha: 0.5 });
result.current.commit({ type: 'solid', color: '#111111', alpha: 0.6 });
});
expect(need(store().doc).spreads[0].backgroundPaint).toEqual({ type: 'solid', color: '#111111', alpha: 0.6 });
expect(store().past).toHaveLength(1);
hookAct(() => {
result.current.commit({ type: 'solid', color: '#222222' });
});
expect(need(store().doc).spreads[0].background).toBe('#222222');
expect(store().past).toHaveLength(2);
hookAct(() => {
store().undo();
});
hookAct(() => {
store().undo();
});
expect(need(store().doc).spreads[0].backgroundPaint).toBeUndefined();
expect(need(store().doc).spreads[0].background).toBeNull();
});
});
+176
View File
@@ -0,0 +1,176 @@
import type { BookGradientPaint, BookPaint } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
addStop,
clampRadius,
clampScale,
clampUnit,
colourAt,
cssPaint,
cssStops,
dialAngle,
gradientAs,
kindOf,
leadColour,
mixHex,
normAngle,
paintAs,
partnerOf,
removeStop,
reverseStops,
updateStop,
} from '../../../src/components/Studio/paint/paintEdit';
/** The value, or a failed test saying it was missing. */
function need<T>(value: T | null | undefined): T {
if (value == null) throw new Error('expected a value');
return value;
}
/** The picker's arithmetic: kinds, stops, angles and the previews it draws of them. */
const grad: BookGradientPaint = {
type: 'linear',
angle: 90,
stops: [
{ at: 0, color: '#000000' },
{ at: 1, color: '#ffffff', alpha: 0.5 },
],
};
describe('kinds', () => {
it('names the kind of a paint, a gradient being linear or radial', () => {
expect(kindOf(null)).toBe('none');
expect(kindOf({ type: 'solid', color: '#000000' })).toBe('solid');
expect(kindOf(grad)).toBe('gradient');
expect(kindOf({ ...grad, type: 'radial', cx: 0.5, cy: 0.5, r: 1 } as BookPaint)).toBe('gradient');
expect(kindOf({ type: 'pattern', pattern: 'dots', fg: '#000000', bg: null, scale: 1, angle: 0 })).toBe('pattern');
});
it('keeps the colour across kinds: a solid becomes a gradient from it and a pattern in it', () => {
const solid: BookPaint = { type: 'solid', color: '#1e3a8a' };
expect(paintAs('none', solid)).toBeNull();
expect(paintAs('solid', grad)).toEqual({ type: 'solid', color: '#000000' });
const g = paintAs('gradient', solid);
expect(g).toMatchObject({ type: 'linear', angle: 180, stops: [{ at: 0, color: '#1e3a8a' }, { at: 1 }] });
const p = paintAs('pattern', solid);
expect(p).toMatchObject({ type: 'pattern', pattern: 'stripes', fg: '#1e3a8a', scale: 1, angle: 45 });
expect(paintAs('solid', null)).toEqual({ type: 'solid', color: '#111111' });
});
it('brings back the paint a kind had when it was left', () => {
const kept: BookPaint = { type: 'pattern', pattern: 'polka', fg: '#c2410c', bg: null, scale: 2, angle: 0 };
expect(paintAs('pattern', grad, { pattern: kept })).toBe(kept);
});
it('reads the lead colour of a paint, with a fallback for none', () => {
expect(leadColour(grad)).toBe('#000000');
expect(leadColour(null, '#abcdef')).toBe('#abcdef');
});
it('finds a partner that is lighter for a dark colour and darker for a light one', () => {
expect(partnerOf('#000000')).toBe(mixHex('#000000', '#ffffff', 0.65));
expect(partnerOf('#ffffff')).toBe(mixHex('#ffffff', '#111111', 0.35));
expect(partnerOf('nope')).toBe('#ffffff');
expect(mixHex('#000000', '#ffffff', 0.5)).toBe('#808080');
expect(mixHex('bad', '#ffffff', 0.5)).toBe('bad');
});
});
describe('stops', () => {
it('reads the colour a gradient has at a point, alpha and all', () => {
expect(colourAt(grad.stops, 0.5)).toEqual({ color: '#808080', alpha: 0.75 });
expect(colourAt(grad.stops, -1)).toEqual({ color: '#000000', alpha: 1 });
expect(colourAt(grad.stops, 2)).toEqual({ color: '#ffffff', alpha: 0.5 });
expect(colourAt([], 0.5)).toEqual({ color: '#111111', alpha: 1 });
expect(
colourAt(
[
{ at: 0.5, color: '#ff0000' },
{ at: 0.5, color: '#0000ff' },
],
0.5
).color
).toBe('#ff0000');
});
it('adds a stop in the colour already there, and refuses a seventh', () => {
const added = need(addStop(grad, 0.25));
expect(added.index).toBe(2);
expect(added.paint.stops[2]).toEqual({ at: 0.25, color: '#404040', alpha: 0.875 });
let full: BookGradientPaint = grad;
for (let i = 0; i < 4; i++) full = need(addStop(full, i / 10)).paint;
expect(full.stops).toHaveLength(6);
expect(addStop(full, 0.5)).toBeNull();
expect(need(addStop(grad, 4)).paint.stops[2].at).toBe(1);
});
it('removes a stop but keeps two, and ignores an index that is not there', () => {
const three = need(addStop(grad, 0.5)).paint;
expect(removeStop(three, 2).stops).toHaveLength(2);
expect(removeStop(grad, 0)).toBe(grad);
expect(removeStop(three, 9)).toBe(three);
});
it('moves and recolours a stop, keeping it on the bar and dropping a full alpha', () => {
const moved = updateStop(grad, 0, { at: 1.4 });
expect(moved.stops[0].at).toBe(1);
const opaque = updateStop(grad, 1, { alpha: 1 });
expect('alpha' in opaque.stops[1]).toBe(false);
expect(updateStop(grad, 1, { color: '#ff0000' }).stops[1]).toEqual({ at: 1, color: '#ff0000', alpha: 0.5 });
});
it('runs a gradient the other way, and turns linear into radial and back with its stops', () => {
expect(reverseStops(grad).stops.map((s) => s.at)).toEqual([1, 0]);
const r = gradientAs(grad, 'radial');
expect(r).toEqual({ type: 'radial', cx: 0.5, cy: 0.5, r: 1, stops: grad.stops });
expect(gradientAs(r, 'linear')).toEqual({ type: 'linear', angle: 180, stops: grad.stops });
expect(gradientAs(grad, 'linear')).toBe(grad);
});
});
describe('angles and ranges', () => {
it('keeps an angle to whole degrees from 0 to 359', () => {
expect(normAngle(360)).toBe(0);
expect(normAngle(-90)).toBe(270);
expect(normAngle(45.4)).toBe(45);
});
it('reads a pointer as an angle, 0 up and clockwise, snapping to the compass unless asked not to', () => {
expect(dialAngle(0, -10)).toBe(0);
expect(dialAngle(10, 0)).toBe(90);
expect(dialAngle(0, 10)).toBe(180);
expect(dialAngle(-10, 0)).toBe(270);
// 47 degrees lands on 45; with the modifier it stays where it was pointed.
const a = (47 * Math.PI) / 180;
expect(dialAngle(Math.sin(a), -Math.cos(a))).toBe(45);
expect(dialAngle(Math.sin(a), -Math.cos(a), true)).toBe(47);
const b = (60 * Math.PI) / 180;
expect(dialAngle(Math.sin(b), -Math.cos(b))).toBe(60);
});
it('holds scale, radius and a unit inside what the document takes', () => {
expect(clampScale(10)).toBe(4);
expect(clampScale(0.1)).toBe(0.25);
expect(clampRadius(0)).toBe(0.05);
expect(clampRadius(3)).toBe(2);
expect(clampUnit(-1)).toBe(0);
expect(clampUnit(0.12345)).toBe(0.123);
});
});
describe('previews', () => {
it('writes stops and paints as CSS for the picker chips', () => {
expect(cssStops(grad.stops)).toBe('rgb(0 0 0 / 1) 0%, rgb(255 255 255 / 0.5) 100%');
expect(cssPaint(null)).toBeUndefined();
expect(cssPaint({ type: 'solid', color: '#ff0000', alpha: 0.2 })).toBe('rgb(255 0 0 / 0.2)');
expect(cssPaint(grad)).toBe(`linear-gradient(90deg, ${cssStops(grad.stops)})`);
expect(cssPaint({ type: 'radial', cx: 0.25, cy: 0.5, r: 1, stops: grad.stops })).toMatch(
/^radial-gradient\(circle at 25% 50%/
);
expect(cssPaint({ type: 'pattern', pattern: 'dots', fg: '#000000', bg: '#ffffff', scale: 1, angle: 0 })).toBe(
'#ffffff'
);
expect(cssPaint({ type: 'pattern', pattern: 'dots', fg: '#000000', bg: null, scale: 1, angle: 0 })).toBeUndefined();
});
});
@@ -0,0 +1,234 @@
import type { BookDocument, BookSpread } from '@trek/shared';
import { bookPhotoElementSchema, bookShapeElementSchema } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
MAX_RECENT,
RECENT_KEY,
readRecent,
spreadPhotoIds,
withRecent,
} from '../../../src/components/Studio/paint/paletteModel';
import {
kmeansPalette,
photoSwatches,
readPhotoPalette,
samplePixels,
} from '../../../src/components/Studio/paint/photoPalette';
import { StudioPaletteProvider } from '../../../src/components/Studio/paint/StudioPalette';
import { AlphaSlider, Swatches } from '../../../src/components/Studio/StudioSwatches';
import { act, fireEvent, render } from '../../helpers/render';
/** The value, or a failed test saying it was missing. */
function need<T>(value: T | null | undefined): T {
if (value == null) throw new Error('expected a value');
return value;
}
/**
* The colours the swatch rows offer beyond the fixed inks: the book's own,
* the photos', and what was picked lately, and the opacity slider under them.
*/
const frame = { x: 0, y: 0, w: 10, h: 10 };
const labels = { book: 'Book', photos: 'Photos', recent: 'Recent', opacity: 'Opacity' };
const spread = (over: Partial<BookSpread> = {}): BookSpread => ({
id: 'sp1',
role: 'inner',
background: '#f4efe6',
elements: [],
parked: [],
entryId: null,
...over,
});
const doc = (spreads: BookSpread[]): BookDocument => ({
version: 1,
title: 'T',
page: {} as BookDocument['page'],
spreads,
});
describe('the photo palette', () => {
it('reads the opaque pixels only, every n-th one', () => {
const data = [255, 0, 0, 255, 0, 255, 0, 0, 0, 0, 255, 255];
expect(samplePixels(data)).toEqual([
{ r: 255, g: 0, b: 0 },
{ r: 0, g: 0, b: 255 },
]);
expect(samplePixels(data, 2)).toEqual([
{ r: 255, g: 0, b: 0 },
{ r: 0, g: 0, b: 255 },
]);
});
it('clusters a picture into its colours, largest first, the same answer every time', () => {
const red = Array.from({ length: 30 }, () => ({ r: 250, g: 10, b: 10 }));
const blue = Array.from({ length: 10 }, () => ({ r: 10, g: 10, b: 240 }));
const out = kmeansPalette([...red, ...blue], 2);
expect(out.map((s) => s.hex)).toEqual(['#fa0a0a', '#0a0af0']);
expect(out[0].share).toBeCloseTo(0.75);
expect(kmeansPalette([...red, ...blue], 2)).toEqual(out);
expect(kmeansPalette([], 3)).toEqual([]);
// Asking for more clusters than the picture has colours gives the colours it has.
expect(kmeansPalette(red, 5).map((s) => s.hex)).toEqual(['#fa0a0a']);
});
it('offers the shared colours of several photos, and one grey at most', () => {
const swatches = photoSwatches([
[
{ hex: '#808080', share: 0.5 },
{ hex: '#c2410c', share: 0.3 },
],
[
{ hex: '#7f7f7f', share: 0.4 },
{ hex: '#202020', share: 0.3 },
{ hex: '#1e3a8a', share: 0.3 },
],
]);
expect(swatches).toContain('#c2410c');
expect(swatches).toContain('#1e3a8a');
expect(swatches.filter((h) => h === '#808080' || h === '#7f7f7f' || h === '#202020').length).toBeLessThanOrEqual(1);
expect(photoSwatches([], 6)).toEqual([]);
});
it('resolves empty, never rejects, for a picture that will not load or a read already called off', async () => {
const ctrl = new AbortController();
ctrl.abort();
await expect(readPhotoPalette('/nope.jpg', ctrl.signal)).resolves.toEqual([]);
const original = globalThis.Image;
class FailingImage {
onerror: (() => void) | null = null;
onload: (() => void) | null = null;
decoding = '';
set src(v: string) {
if (v) queueMicrotask(() => this.onerror?.());
}
}
globalThis.Image = FailingImage as unknown as typeof Image;
try {
await expect(readPhotoPalette('/broken.jpg')).resolves.toEqual([]);
} finally {
globalThis.Image = original;
}
});
});
describe('recent colours', () => {
beforeEach(() => window.localStorage.clear());
afterEach(() => vi.restoreAllMocks());
it('keeps each colour once, newest first, up to the limit', () => {
let list: string[] = [];
for (let i = 0; i < 12; i++) list = withRecent(list, `#00000${i % 10}`);
expect(list).toHaveLength(MAX_RECENT);
expect(withRecent(['#aaaaaa', '#bbbbbb'], '#BBBBBB')).toEqual(['#bbbbbb', '#aaaaaa']);
});
it('reads what storage holds, and an empty list from storage that is broken or refuses', () => {
window.localStorage.setItem(RECENT_KEY, JSON.stringify(['#123456', 'red', 4]));
expect(readRecent()).toEqual(['#123456']);
window.localStorage.setItem(RECENT_KEY, '{oops');
expect(readRecent()).toEqual([]);
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('denied');
});
expect(readRecent()).toEqual([]);
});
it('remembers a picked colour and shows it as a row, where a fresh book shows the inks alone', () => {
const { container } = render(
<StudioPaletteProvider doc={doc([spread()])} spread={undefined} labels={labels}>
<Swatches value="#111111" onPick={() => {}} />
</StudioPaletteProvider>
);
// The book row: the page's own colour.
expect(container.querySelector('[aria-label="Book"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Recent"]')).toBeNull();
act(() => {
fireEvent.click(need(container.querySelector('[aria-label="#0f766e"]')));
});
const recent = container.querySelector('[aria-label="Recent"]');
expect(recent?.querySelector('[aria-label="#0f766e"]')).not.toBeNull();
expect(JSON.parse(need(window.localStorage.getItem(RECENT_KEY)))).toEqual(['#0f766e']);
});
it('carries on without storage when writing is refused', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota');
});
const { container } = render(
<StudioPaletteProvider doc={null} spread={undefined} labels={labels}>
<Swatches value="#111111" onPick={() => {}} />
</StudioPaletteProvider>
);
act(() => {
fireEvent.click(need(container.querySelector('[aria-label="#9f1239"]')));
});
expect(container.querySelector('[aria-label="Recent"] [aria-label="#9f1239"]')).not.toBeNull();
});
});
describe('the swatch rows', () => {
it('lists the photos of a page, placed and parked, once each', () => {
const p = (id: string, photoId: number | null) =>
bookPhotoElementSchema.parse({ id, kind: 'photo', frame, photoId });
const shape = bookShapeElementSchema.parse({ id: 's', kind: 'shape', frame });
expect(
spreadPhotoIds(spread({ elements: [p('a', 3), shape, p('b', null), p('c', 3)], parked: [p('d', 9)] }))
).toEqual([3, 9]);
expect(spreadPhotoIds(undefined)).toEqual([]);
});
it('shows the fixed inks alone outside the book, and hides the rows when asked', () => {
const { container, rerender } = render(<Swatches value="#111111" onPick={() => {}} />);
expect(container.querySelectorAll('.st-swatch-row')).toHaveLength(0);
rerender(
<StudioPaletteProvider doc={doc([spread()])} spread={undefined} labels={labels}>
<Swatches value="#111111" onPick={() => {}} rows={false} />
</StudioPaletteProvider>
);
expect(container.querySelectorAll('.st-swatch-row')).toHaveLength(0);
});
it('remembers the colour the wheel stops on, not every one it passes', () => {
const onPick = vi.fn();
const { container } = render(
<StudioPaletteProvider doc={null} spread={undefined} labels={labels}>
<Swatches value="#111111" onPick={onPick} />
</StudioPaletteProvider>
);
const wheel = need(container.querySelector('input[type="color"]'));
fireEvent.change(wheel, { target: { value: '#123456' } });
fireEvent.change(wheel, { target: { value: '#654321' } });
expect(onPick).toHaveBeenCalledTimes(2);
expect(container.querySelector('[aria-label="Recent"]')).toBeNull();
act(() => {
fireEvent.blur(wheel);
});
expect(container.querySelectorAll('[aria-label="Recent"] .st-swatch')).toHaveLength(1);
});
});
describe('the opacity slider', () => {
it('reports while dragged and once more when let go', () => {
const onChange = vi.fn();
const { container } = render(<AlphaSlider color="#ff0000" value={0.5} onChange={onChange} label="Opacity" />);
const range = container.querySelector('input[type="range"]') as HTMLInputElement;
expect(range.value).toBe('50');
expect(need(container.querySelector('.st-alpha-value')).textContent).toBe('50%');
fireEvent.change(range, { target: { value: '30' } });
expect(onChange).toHaveBeenLastCalledWith(0.3, false);
fireEvent.pointerUp(range);
expect(onChange).toHaveBeenLastCalledWith(0.3, true);
fireEvent.keyUp(range, { key: 'ArrowLeft' });
expect(onChange).toHaveBeenCalledTimes(3);
});
it('sits under the swatches only when the caller takes an alpha', () => {
const { container, rerender } = render(<Swatches value="#111111" onPick={() => {}} />);
expect(container.querySelector('.st-alpha')).toBeNull();
rerender(<Swatches value="#111111" onPick={() => {}} alpha={0.4} onAlpha={() => {}} />);
expect((container.querySelector('.st-alpha input') as HTMLInputElement).value).toBe('40');
});
});
@@ -0,0 +1,158 @@
import { BOOK_PATTERNS, type BookPattern } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
MAX_PATTERN_PRIMITIVES,
PATTERN_CELL_MM,
estimatePrimitives,
fittedCell,
mulberry32,
patternGeometry,
sunburstRays,
} from '../../../src/components/Studio/paint/patterns';
/**
* The pattern generators: the twelve repeats drawn out as geometry. These are
* what prints, so the checks are about the numbers that land in the PDF:
* that every repeat draws, that it covers the box however it is turned, that
* the cap holds on the largest page at the smallest scale, and that the same
* paint always draws the same thing.
*/
const box = { x: 0, y: 0, w: 60, h: 40 };
const spread = { x: -3, y: -3, w: 426, h: 216 };
const draw = (pattern: BookPattern, over: { scale?: number; angle?: number } = {}, at = box) =>
patternGeometry({ pattern, scale: over.scale ?? 1, angle: over.angle ?? 0 }, at);
/** Every number in a path, so its range and precision can be read. */
const numbers = (d: string) => (d.match(/-?\d*\.?\d+/g) ?? []).map(Number);
/** Where each primitive starts: the absolute point after every M. */
const starts = (d: string) =>
[...d.matchAll(/M(-?[\d.]+) (-?[\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])] as const);
describe('every repeat', () => {
it.each(BOOK_PATTERNS.map((p) => [p]))(
'%s draws something, within the cap, at hundredths of a millimetre',
(pattern) => {
const geo = draw(pattern);
expect(geo.count).toBeGreaterThan(0);
expect(geo.count).toBeLessThanOrEqual(MAX_PATTERN_PRIMITIVES);
expect(geo.layers.length).toBeGreaterThan(0);
for (const layer of geo.layers) {
expect(layer.d.startsWith('M')).toBe(true);
expect(layer.opacity).toBeGreaterThan(0);
expect(layer.opacity).toBeLessThanOrEqual(1);
for (const n of numbers(layer.d)) {
expect(Number.isFinite(n)).toBe(true);
expect(Math.round(n * 100) / 100).toBe(n);
}
}
}
);
it.each(BOOK_PATTERNS.map((p) => [p]))('%s draws the same path every time', (pattern) => {
expect(draw(pattern, { scale: 0.8, angle: 30 })).toEqual(draw(pattern, { scale: 0.8, angle: 30 }));
});
it.each(BOOK_PATTERNS.map((p) => [p]))(
'%s holds the cap across a whole double spread at the smallest scale',
(pattern) => {
const geo = draw(pattern, { scale: 0.25, angle: 17 }, spread);
expect(geo.count).toBeGreaterThan(0);
expect(geo.count).toBeLessThanOrEqual(MAX_PATTERN_PRIMITIVES);
}
);
it.each(BOOK_PATTERNS.map((p) => [p]))('%s draws more of itself at a smaller scale, up to the cap', (pattern) => {
if (pattern === 'gingham' || pattern === 'stripes' || pattern === 'pinstripe' || pattern === 'grid') {
expect(draw(pattern, { scale: 0.5 }).count).toBeGreaterThan(draw(pattern, { scale: 2 }).count);
return;
}
expect(draw(pattern, { scale: 0.5 }).count).toBeGreaterThanOrEqual(draw(pattern, { scale: 2 }).count);
});
});
describe('turning', () => {
it('turns the drawing about the box centre and covers the box whichever way it points', () => {
const geo = draw('stripes', { angle: 45 });
expect(geo.transform).toBe('rotate(45 30 20)');
const xs = starts(geo.layers[0].d).map(([x]) => x);
// The region is the square around the box's circle: past both edges by the corner's reach.
const reach = Math.hypot(60, 40) / 2;
expect(Math.min(...xs)).toBeLessThanOrEqual(30 - reach);
expect(Math.max(...xs)).toBeGreaterThanOrEqual(30 + reach - PATTERN_CELL_MM);
});
it('draws an unturned repeat over the box alone, with no transform', () => {
const geo = draw('checker', { angle: 360 });
expect(geo.transform).toBeUndefined();
const values = numbers(geo.layers[0].d);
expect(Math.min(...values)).toBeGreaterThanOrEqual(-PATTERN_CELL_MM);
});
it('turns a sunburst by its rays rather than by a transform', () => {
const straight = draw('sunburst');
const turned = draw('sunburst', { angle: 10 });
expect(turned.transform).toBeUndefined();
expect(turned.layers[0].d).not.toBe(straight.layers[0].d);
expect(turned.count).toBe(straight.count);
});
});
describe('the cap', () => {
it('grows the cell instead of the page', () => {
const cell = fittedCell('dots', 2, 426, 216);
expect(cell).toBeGreaterThan(2);
expect(estimatePrimitives('dots', cell, 426, 216)).toBeLessThanOrEqual(MAX_PATTERN_PRIMITIVES);
// A small box at a fair scale keeps its own cell.
expect(fittedCell('dots', 8, 60, 40)).toBe(8);
});
it('never passes a cap given lower, and reports the cell it used', () => {
const geo = patternGeometry({ pattern: 'confetti', scale: 0.25, angle: 0 }, spread, 50);
expect(geo.count).toBeLessThanOrEqual(50);
expect(geo.cell).toBeGreaterThan(PATTERN_CELL_MM * 0.25);
});
it('counts a sunburst by its rays, between 8 and 72 of them', () => {
expect(sunburstRays(PATTERN_CELL_MM)).toBe(24);
expect(sunburstRays(1000)).toBe(8);
expect(sunburstRays(0.01)).toBe(72);
expect(estimatePrimitives('sunburst', PATTERN_CELL_MM, 10, 10)).toBe(24);
expect(draw('sunburst').count).toBe(12);
});
});
describe('the odd cases', () => {
it('draws nothing for an empty box or a scale that is not a number', () => {
expect(draw('dots', {}, { x: 0, y: 0, w: 0, h: 10 }).count).toBe(0);
expect(patternGeometry({ pattern: 'dots', scale: Number.NaN, angle: 0 }, box).layers).toEqual([]);
expect(patternGeometry({ pattern: 'dots', scale: -1, angle: 0 }, box).count).toBe(0);
});
it('draws gingham as two half-opaque layers, so where the bands cross reads darker', () => {
const geo = draw('gingham');
expect(geo.layers.map((l) => l.opacity)).toEqual([0.5, 0.5]);
});
it('scatters confetti from a seed, the same scatter for the same paint', () => {
const a = mulberry32(7);
const b = mulberry32(7);
const seq = [a(), a(), a()];
expect([b(), b(), b()]).toEqual(seq);
for (const v of seq) {
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(1);
}
expect(draw('confetti', { scale: 1 })).not.toEqual(draw('confetti', { scale: 1.5 }));
});
it('offsets a box that does not start at the origin', () => {
const geo = draw('grid', {}, { x: 100, y: 50, w: 20, h: 20 });
for (const [x, y] of starts(geo.layers[0].d)) {
expect(x).toBeGreaterThanOrEqual(100 - PATTERN_CELL_MM);
expect(y).toBeGreaterThanOrEqual(50 - PATTERN_CELL_MM);
expect(x).toBeLessThan(120);
expect(y).toBeLessThan(70);
}
});
});
@@ -0,0 +1,324 @@
import type { BookPaint } from '@trek/shared';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { StudioPaintPicker } from '../../../src/components/Studio/StudioPaintPicker';
import { AngleDial } from '../../../src/components/Studio/paint/AngleDial';
import { GradientStopBar } from '../../../src/components/Studio/paint/GradientStopBar';
import { RadialPad } from '../../../src/components/Studio/paint/RadialPad';
import type { PaintChannel } from '../../../src/components/Studio/paint/usePaintChannel';
import { fireEvent, render } from '../../helpers/render';
/** The value, or a failed test saying it was missing. */
function need<T>(value: T | null | undefined): T {
if (value == null) throw new Error('expected a value');
return value;
}
/**
* The paint picker, driven the way a person drives it: tabs, swatches, the
* stop bar, the dial. What is asserted is the paint it writes and whether a
* write is a live step inside a drag or the one that ends it.
*/
const t = (k: string, p?: Record<string, string | number>) => (p ? `${k}:${JSON.stringify(p)}` : k);
/** The picker over its own state, as the inspector holds it through the store. */
function Harness({ start, channel }: { start: BookPaint | null; channel: PaintChannel }) {
const [value, setValue] = useState<BookPaint | null>(start);
const write: PaintChannel = {
live: (p) => {
channel.live(p);
setValue(p);
},
commit: (p) => {
channel.commit(p);
setValue(p);
},
};
return <StudioPaintPicker value={value} channel={write} t={t} noneLabel="none" />;
}
const spy = () => ({ live: vi.fn(), commit: vi.fn() });
const last = (fn: ReturnType<typeof vi.fn>) => fn.mock.calls[fn.mock.calls.length - 1]?.[0] as BookPaint | null;
/** A rect for the bar, the dial or the pad: jsdom lays nothing out. */
function sized(el: Element, box = { left: 0, top: 0, width: 200, height: 24 }) {
el.getBoundingClientRect = () =>
({
...box,
right: box.left + box.width,
bottom: box.top + box.height,
x: box.left,
y: box.top,
toJSON: () => ({}),
}) as DOMRect;
}
const tab = (c: HTMLElement, label: string) =>
fireEvent.click(need([...c.querySelectorAll('.st-paint-tab')].find((b) => b.textContent === label)));
describe('the kind tabs', () => {
it('marks the kind the paint is, and offers no fill under its own name', () => {
const { container } = render(<Harness start={null} channel={spy()} />);
const tabs = [...container.querySelectorAll('.st-paint-tab')];
expect(tabs.map((b) => b.textContent)).toEqual([
'none',
'journey.studio.paint.solid',
'journey.studio.paint.gradient',
'journey.studio.paint.pattern',
]);
expect(tabs[0].getAttribute('aria-selected')).toBe('true');
});
it('keeps the colour across kinds, and brings a visited kind back as it was left', () => {
const channel = spy();
const { container } = render(<Harness start={{ type: 'solid', color: '#1e3a8a' }} channel={channel} />);
tab(container, 'journey.studio.paint.pattern');
expect(last(channel.commit)).toMatchObject({ type: 'pattern', fg: '#1e3a8a' });
fireEvent.click(need(container.querySelector('[aria-label="journey.studio.pattern.polka"]')));
expect(last(channel.commit)).toMatchObject({ type: 'pattern', pattern: 'polka' });
tab(container, 'journey.studio.paint.gradient');
expect(last(channel.commit)).toMatchObject({ type: 'linear', stops: [{ color: '#1e3a8a' }, {}] });
tab(container, 'journey.studio.paint.pattern');
expect(last(channel.commit)).toMatchObject({ type: 'pattern', pattern: 'polka' });
tab(container, 'none');
expect(last(channel.commit)).toBeNull();
expect(channel.live).not.toHaveBeenCalled();
});
});
describe('one colour', () => {
it('writes the swatch picked, and its opacity live while dragged and once when let go', () => {
const channel = spy();
const { container } = render(<Harness start={{ type: 'solid', color: '#111111' }} channel={channel} />);
fireEvent.click(need(container.querySelector('[aria-label="#c2410c"]')));
expect(last(channel.commit)).toEqual({ type: 'solid', color: '#c2410c' });
const range = container.querySelector('.st-alpha input') as HTMLInputElement;
fireEvent.change(range, { target: { value: '40' } });
expect(last(channel.live)).toEqual({ type: 'solid', color: '#c2410c', alpha: 0.4 });
fireEvent.pointerUp(range);
expect(last(channel.commit)).toEqual({ type: 'solid', color: '#c2410c', alpha: 0.4 });
fireEvent.change(range, { target: { value: '100' } });
fireEvent.pointerUp(range);
expect(last(channel.commit)).toEqual({ type: 'solid', color: '#c2410c' });
});
});
describe('a gradient', () => {
const start: BookPaint = {
type: 'linear',
angle: 90,
stops: [
{ at: 0, color: '#000000' },
{ at: 1, color: '#ffffff' },
],
};
it('adds a stop where the bar is clicked, recolours the chosen stop and takes it away', () => {
const channel = spy();
const { container } = render(<Harness start={start} channel={channel} />);
const bar = need(container.querySelector('.st-stopbar'));
sized(bar);
fireEvent.pointerDown(bar, { button: 0, clientX: 50 });
const added = last(channel.commit);
expect(added?.type === 'linear' && added.stops).toHaveLength(3);
expect(added?.type === 'linear' && added.stops[2]).toEqual({ at: 0.25, color: '#404040' });
expect(container.querySelectorAll('.st-stop')[2].getAttribute('aria-pressed')).toBe('true');
const stopPanel = need(container.querySelector('.st-paint-stop'));
fireEvent.click(need(stopPanel.querySelector('[aria-label="#0f766e"]')));
expect(last(channel.commit)).toMatchObject({ stops: [{}, {}, { at: 0.25, color: '#0f766e' }] });
fireEvent.click(need(stopPanel.querySelector('[aria-label="journey.studio.paint.removeStop"]')));
const removed = last(channel.commit);
expect(removed?.type === 'linear' && removed.stops).toHaveLength(2);
// Two stops left: the bin is off.
expect(
(container.querySelector('.st-paint-stop [aria-label="journey.studio.paint.removeStop"]') as HTMLButtonElement)
.disabled
).toBe(true);
});
it('reverses, turns radial and back, and sets the angle from the number field', () => {
const channel = spy();
const { container } = render(<Harness start={start} channel={channel} />);
fireEvent.click(need(container.querySelector('[aria-label="journey.studio.paint.reverse"]')));
expect(last(channel.commit)).toMatchObject({ stops: [{ at: 1 }, { at: 0 }] });
const radio = (label: string) =>
need([...container.querySelectorAll('.st-seg-item')].find((b) => b.textContent === label));
fireEvent.click(radio('journey.studio.paint.radial'));
expect(last(channel.commit)).toMatchObject({ type: 'radial', cx: 0.5, cy: 0.5, r: 1 });
expect(container.querySelector('.st-radial-pad')).not.toBeNull();
fireEvent.click(radio('journey.studio.paint.linear'));
expect(last(channel.commit)).toMatchObject({ type: 'linear', angle: 180 });
const field = container.querySelector('.st-num input') as HTMLInputElement;
fireEvent.change(field, { target: { value: '400' } });
fireEvent.blur(field);
expect(last(channel.commit)).toMatchObject({ angle: 359 });
});
});
describe('a pattern', () => {
const start: BookPaint = { type: 'pattern', pattern: 'dots', fg: '#111111', bg: '#ffffff', scale: 1, angle: 0 };
it('draws a tile for each repeat, turns stripes to the diagonal, and lets the ground go', () => {
const channel = spy();
const { container } = render(<Harness start={start} channel={channel} />);
expect(container.querySelectorAll('.st-pattern-tile')).toHaveLength(12);
expect(need(container.querySelector('.st-pattern-tile[aria-checked="true"]')).getAttribute('aria-label')).toBe(
'journey.studio.pattern.dots'
);
fireEvent.click(need(container.querySelector('[aria-label="journey.studio.pattern.stripes"]')));
expect(last(channel.commit)).toMatchObject({ pattern: 'stripes', angle: 45 });
fireEvent.click(need(container.querySelector('[role="switch"]')));
expect(last(channel.commit)).toMatchObject({ bg: null });
expect(container.querySelectorAll('.st-pattern-tile.is-clear')).toHaveLength(12);
fireEvent.click(need(container.querySelector('[role="switch"]')));
expect(last(channel.commit)).toMatchObject({ bg: '#ffffff' });
});
it('sizes the repeat on an even ratio scale, live and then once', () => {
const channel = spy();
const { container } = render(<Harness start={start} channel={channel} />);
const range = container.querySelector('.st-paint-slider input') as HTMLInputElement;
// Scale 1 sits a little under halfway along 0.25 to 4.
expect(Number(range.value)).toBe(50);
fireEvent.change(range, { target: { value: '100' } });
expect(last(channel.live)).toMatchObject({ scale: 4 });
fireEvent.pointerUp(range);
expect(last(channel.commit)).toMatchObject({ scale: 4 });
expect(need(container.querySelector('.st-paint-value')).textContent).toBe('400%');
});
});
describe('the dial', () => {
it('turns with the pointer, snapping to the compass, and is one commit when let go', () => {
const onChange = vi.fn();
const { container } = render(<AngleDial value={0} onChange={onChange} label="Angle" />);
const dial = need(container.querySelector('.st-dial'));
sized(dial, { left: 0, top: 0, width: 40, height: 40 });
fireEvent.pointerDown(dial, { button: 0, clientX: 40, clientY: 20 });
expect(onChange).toHaveBeenLastCalledWith(90, false);
fireEvent.pointerMove(dial, { clientX: 20, clientY: 40 });
expect(onChange).toHaveBeenLastCalledWith(180, false);
fireEvent.pointerUp(dial);
expect(onChange).toHaveBeenLastCalledWith(180, true);
fireEvent.pointerMove(dial, { clientX: 0, clientY: 20 });
expect(onChange).toHaveBeenCalledTimes(3);
});
it('takes the arrow keys, Shift for fifteen at a time', () => {
const onChange = vi.fn();
const { container } = render(<AngleDial value={350} onChange={onChange} label="Angle" />);
const dial = need(container.querySelector('.st-dial'));
expect(dial.getAttribute('aria-valuenow')).toBe('350');
fireEvent.keyDown(dial, { key: 'ArrowRight', shiftKey: true });
expect(onChange).toHaveBeenLastCalledWith(5, false);
fireEvent.keyUp(dial, { key: 'ArrowRight' });
expect(onChange).toHaveBeenLastCalledWith(5, true);
fireEvent.keyDown(dial, { key: 'a' });
expect(onChange).toHaveBeenCalledTimes(2);
});
});
describe('the stop bar', () => {
const paint: BookPaint & { type: 'linear' } = {
type: 'linear',
angle: 0,
stops: [
{ at: 0, color: '#000000' },
{ at: 0.5, color: '#888888' },
{ at: 1, color: '#ffffff' },
],
};
const labels = { bar: 'bar', stop: (pos: number) => `stop ${pos}` };
it('drags a stop along the bar live, and pulls it off to remove it', () => {
const onChange = vi.fn();
const onSelect = vi.fn();
const { container } = render(
<GradientStopBar paint={paint} selected={0} onSelect={onSelect} onChange={onChange} labels={labels} />
);
sized(need(container.querySelector('.st-stopbar')));
const stop = container.querySelectorAll('.st-stop')[1];
fireEvent.pointerDown(stop, { button: 0, clientX: 100 });
expect(onSelect).toHaveBeenCalledWith(1);
fireEvent.pointerMove(stop, { clientX: 150, clientY: 10 });
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({ stops: [paint.stops[0], { at: 0.75, color: '#888888' }, paint.stops[2]] }),
false
);
fireEvent.pointerMove(stop, { clientX: 150, clientY: 90 });
expect(stop.className).toContain('is-leaving');
fireEvent.pointerUp(stop);
const removed = onChange.mock.calls[onChange.mock.calls.length - 1];
expect(removed[1]).toBe(true);
expect(removed[0].stops).toHaveLength(2);
});
it('moves the stop with the arrow keys and removes it with Delete', () => {
const onChange = vi.fn();
const { container } = render(
<GradientStopBar paint={paint} selected={1} onSelect={() => {}} onChange={onChange} labels={labels} />
);
const stop = container.querySelectorAll('.st-stop')[1];
expect(stop.getAttribute('aria-label')).toBe('stop 50');
fireEvent.keyDown(stop, { key: 'ArrowLeft', shiftKey: true });
expect(onChange.mock.calls[0][0].stops[1].at).toBe(0.4);
fireEvent.keyDown(stop, { key: 'Delete' });
expect(onChange.mock.calls[1][0].stops).toHaveLength(2);
fireEvent.keyDown(stop, { key: 'x' });
expect(onChange).toHaveBeenCalledTimes(2);
});
it('does not add a stop past six, or when the click lands on a stop', () => {
const onChange = vi.fn();
const six = { ...paint, stops: [0, 0.2, 0.4, 0.6, 0.8, 1].map((at) => ({ at, color: '#000000' })) };
const { container } = render(
<GradientStopBar paint={six} selected={0} onSelect={() => {}} onChange={onChange} labels={labels} />
);
const bar = need(container.querySelector('.st-stopbar'));
sized(bar);
fireEvent.pointerDown(bar, { button: 0, clientX: 30 });
fireEvent.pointerDown(need(container.querySelector('.st-stop')), { button: 0, clientX: 0 });
fireEvent.pointerUp(need(container.querySelector('.st-stop')));
expect(onChange).not.toHaveBeenCalled();
});
});
describe('the radial centre', () => {
it('moves the centre with the pointer and the keys', () => {
const onChange = vi.fn();
const radial = {
type: 'radial' as const,
cx: 0.5,
cy: 0.5,
r: 1,
stops: [
{ at: 0, color: '#ffffff' },
{ at: 1, color: '#000000' },
],
};
const { container } = render(<RadialPad paint={radial} onChange={onChange} label="Centre" />);
const pad = need(container.querySelector('.st-radial-pad'));
sized(pad, { left: 0, top: 0, width: 100, height: 50 });
fireEvent.pointerDown(pad, { button: 0, clientX: 25, clientY: 10 });
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ cx: 0.25, cy: 0.2 }), false);
fireEvent.pointerMove(pad, { clientX: 200, clientY: -5 });
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ cx: 1, cy: 0 }), false);
fireEvent.pointerUp(pad);
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ cx: 1, cy: 0 }), true);
fireEvent.keyDown(pad, { key: 'ArrowDown' });
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ cx: 0.5, cy: 0.52 }), true);
});
});
@@ -0,0 +1,252 @@
import type { BookDocument, BookElement, BookPaint, BookSpread } from '@trek/shared';
import { bookPageSetupSchema, bookPhotoElementSchema, bookShapeElementSchema } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { BookSheetsView } from '../../../src/components/Studio/BookSheetsView';
import {
ellipsePath,
linearLine,
roundedRectPath,
shapeOutline,
strokeDash,
} from '../../../src/components/Studio/paint/paintGeometry';
import { PaintedPath, SpreadPaint } from '../../../src/components/Studio/paint/paintRender';
import { SpreadView } from '../../../src/components/Studio/SpreadView';
import { render } from '../../helpers/render';
/** The value, or a failed test saying it was missing. */
function need<T>(value: T | null | undefined): T {
if (value == null) throw new Error('expected a value');
return value;
}
/**
* Paint as the page draws it, and so as it prints.
*
* The one assertion this file exists for is the defs ids: page mode draws a
* double spread twice, once per half, and an id that is the element's own
* would then exist twice in the document. `url(#id)` resolves to the first,
* so the second half would borrow the first half's gradient and lose its
* pattern clip. Every id must be unique, and every reference must land on an
* id inside its own copy of the page.
*/
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
const frame = { x: 20, y: 30, w: 80, h: 50 };
const linear: BookPaint = {
type: 'linear',
angle: 90,
stops: [
{ at: 0, color: '#ff0000' },
{ at: 1, color: '#0000ff', alpha: 0.4 },
],
};
const radial: BookPaint = {
type: 'radial',
cx: 0.5,
cy: 0.5,
r: 1,
stops: [
{ at: 0, color: '#ffffff' },
{ at: 1, color: '#000000' },
],
};
const dots: BookPaint = { type: 'pattern', pattern: 'dots', fg: '#112233', bg: '#ffeedd', scale: 1, angle: 30 };
const shape = (id: string, over: Record<string, unknown> = {}): BookElement =>
bookShapeElementSchema.parse({ id, kind: 'shape', frame, ...over }) as BookElement;
const painted = (): BookSpread => ({
id: 'sp1',
role: 'inner',
background: '#c0c0c0',
backgroundPaint: radial,
elements: [
shape('g1', { paint: linear }),
shape('h1', { shape: 'heart', paint: dots, stroke: '#000000', strokeWidth: 1 }),
shape('r1', { shape: 'rect', radius: 4, paint: dots }),
shape('old', { fill: '#336699', gradient: 'down', shape: 'star-5' }),
bookPhotoElementSchema.parse({ id: 'p1', kind: 'photo', frame, photoId: 3, mask: 'heart' }) as BookElement,
],
parked: [],
entryId: null,
});
const ids = (root: ParentNode) => [...root.querySelectorAll('[id]')].map((e) => e.id);
/** Every `url(#...)` a copy of the page points at, in attributes and in inline styles alike. */
const refs = (root: Element) => {
const out: string[] = [];
for (const el of [root, ...root.querySelectorAll('*')]) {
for (const attr of [...el.attributes]) {
for (const m of attr.value.matchAll(/url\(#([^)]+)\)/g)) out.push(m[1]);
}
}
return out;
};
describe('a spread drawn twice', () => {
it('gives every defs id of each copy its own value', () => {
const { container } = render(
<>
<div className="copy">
<SpreadView spread={painted()} page={page} />
</div>
<div className="copy">
<SpreadView spread={painted()} page={page} />
</div>
</>
);
const all = ids(container);
// A gradient, a radial background, two pattern clips, an old fade and a photo mask, per copy.
expect(all.length).toBeGreaterThanOrEqual(12);
expect(new Set(all).size).toBe(all.length);
for (const id of all) expect(id).toMatch(/^[\w-]+$/);
for (const copy of container.querySelectorAll('.copy')) {
const own = new Set(ids(copy));
const pointed = refs(copy);
expect(pointed.length).toBeGreaterThan(0);
for (const ref of pointed) expect(own.has(ref)).toBe(true);
}
});
it('stays unique when page mode prints the two halves of a spread', () => {
const doc = { version: 1, title: 'T', page, spreads: [painted()] } as BookDocument;
const { container } = render(<BookSheetsView doc={doc} mode="pages" marks={false} />);
expect(container.querySelectorAll('.bx-sheet')).toHaveLength(2);
const all = ids(container);
expect(new Set(all).size).toBe(all.length);
for (const sheet of container.querySelectorAll('.bx-sheet')) {
const own = new Set(ids(sheet));
for (const ref of refs(sheet)) expect(own.has(ref)).toBe(true);
}
});
});
describe('the page background', () => {
it('paints the sheet out into the bleed and leaves the paper under it', () => {
const { container } = render(<SpreadView spread={painted()} page={page} />);
const sheet = container.firstElementChild as HTMLElement;
expect(sheet.style.background).toContain('rgb(255, 255, 255)');
const svg = sheet.firstElementChild as SVGSVGElement;
expect(svg.tagName.toLowerCase()).toBe('svg');
expect(svg.getAttribute('width')).toBe('426mm');
expect(svg.getAttribute('height')).toBe('216mm');
expect(svg.style.top).toBe('-3mm');
expect(svg.querySelector('radialGradient')?.getAttribute('r')).toBe(
String(Math.round(Math.hypot(426, 216) * 50) / 100)
);
});
it('draws a page without paint exactly as before: its colour and nothing else', () => {
const plain: BookSpread = { ...painted(), backgroundPaint: undefined, background: '#123456', elements: [] };
const { container } = render(<SpreadView spread={plain} page={page} />);
const sheet = container.firstElementChild as HTMLElement;
expect(sheet.style.background).toContain('rgb(18, 52, 86)');
expect(sheet.querySelector('svg')).toBeNull();
});
it('keeps the bleed window on paper too, when the page has a paint', () => {
const doc = { version: 1, title: 'T', page, spreads: [{ ...painted(), role: 'cover' as const }] } as BookDocument;
const { container } = render(<BookSheetsView doc={doc} mode="spreads" marks={false} />);
const bleedWindow = need(container.querySelector('.bx-sheet')).firstElementChild as HTMLElement;
expect(bleedWindow.style.background).toContain('rgb(255, 255, 255)');
});
});
describe('a painted shape', () => {
it('draws a gradient in user space along the angle, its see-through stop as stop-opacity', () => {
const { container } = render(
<SpreadView spread={{ ...painted(), elements: [shape('g1', { paint: linear })] }} page={page} />
);
const grad = need(container.querySelector('linearGradient'));
expect(grad.getAttribute('gradientUnits')).toBe('userSpaceOnUse');
expect([grad.getAttribute('x1'), grad.getAttribute('x2')]).toEqual(['0', '80']);
const stops = [...grad.querySelectorAll('stop')];
expect(stops.map((s) => s.getAttribute('stop-opacity'))).toEqual([null, '0.4']);
const path = container.querySelector(`path[fill="url(#${grad.id})"]`);
expect(path).not.toBeNull();
});
it('draws a pattern clipped to the outline, over its ground, under its stroke', () => {
const { container } = render(
<SpreadView
spread={{
...painted(),
backgroundPaint: undefined,
elements: [shape('h1', { shape: 'heart', paint: dots, stroke: '#000000', strokeWidth: 1 })],
}}
page={page}
/>
);
const clip = need(container.querySelector('clipPath'));
expect(clip.getAttribute('clipPathUnits')).toBe('userSpaceOnUse');
const group = need(container.querySelector(`g[clip-path="url(#${clip.id})"]`));
expect(need(group.querySelector('g')).getAttribute('transform')).toMatch(/^rotate\(30 /);
const paths = [...container.querySelectorAll('svg path')];
const ground = paths.find((p) => p.getAttribute('fill') === '#ffeedd');
const outline = paths[paths.length - 1];
expect(ground).toBeDefined();
expect([outline.getAttribute('fill'), outline.getAttribute('stroke')]).toEqual(['none', '#000000']);
});
it('draws a painted rectangle as an outline with its corners rounded inside the stroke', () => {
expect(shapeOutline({ shape: 'rect', radius: 4 }, 20, 10, 1)).toBe(roundedRectPath(20, 10, 3));
expect(shapeOutline({ shape: 'ellipse', radius: 0 }, 20, 10, 0)).toBe(ellipsePath(20, 10));
expect(shapeOutline({ shape: 'triangle', radius: 0 }, 20, 10, 0)).toBe('M10 0L20 10L0 10Z');
});
it('leaves a shape from before paint to the old renderer', () => {
const { container } = render(
<SpreadView
spread={{ ...painted(), backgroundPaint: undefined, elements: [shape('o', { fill: '#336699' })] }}
page={page}
/>
);
expect(container.querySelector('svg')).toBeNull();
});
});
describe('the geometry helpers', () => {
const box = { x: 0, y: 0, w: 100, h: 50 };
it('runs a linear gradient the way CSS does: 0 towards the top, 90 towards the right', () => {
expect(linearLine(0, box)).toEqual({ x1: 50, y1: 50, x2: 50, y2: 0 });
expect(linearLine(90, box)).toEqual({ x1: 0, y1: 25, x2: 100, y2: 25 });
expect(linearLine(180, box)).toEqual({ x1: 50, y1: 0, x2: 50, y2: 50 });
const diag = linearLine(45, { x: 0, y: 0, w: 10, h: 10 });
expect(diag).toEqual({ x1: 0, y1: 10, x2: 10, y2: 0 });
});
it('rounds a rectangle only as far as its sides allow, and draws an ellipse in two arcs', () => {
expect(roundedRectPath(10, 6, 0)).toBe('M0 0H10V6H0Z');
expect(roundedRectPath(10, 6, 50)).toContain('A3 3 0 0 1');
expect(ellipsePath(10, 6)).toBe('M0 3A5 3 0 1 0 10 3A5 3 0 1 0 0 3Z');
});
it('dashes in proportion to the stroke', () => {
expect(strokeDash('solid', 1)).toBeUndefined();
expect(strokeDash('dashed', 2)).toBe('6 4');
expect(strokeDash('dotted', 2)).toBe('0.02 4');
});
it('draws a see-through solid with fill-opacity and no paint as an outline alone', () => {
const { container } = render(
<svg>
<PaintedPath d="M0 0H1V1Z" paint={{ type: 'solid', color: '#ff0000', alpha: 0.5 }} box={box} idPrefix="a" />
<PaintedPath d="M0 0H1V1Z" paint={null} box={box} idPrefix="b" stroke="#000000" />
</svg>
);
const [solid, none] = [...container.querySelectorAll('path')];
expect([solid.getAttribute('fill'), solid.getAttribute('fill-opacity')]).toEqual(['#ff0000', '0.5']);
expect([none.getAttribute('fill'), none.getAttribute('stroke')]).toEqual(['none', '#000000']);
expect(container.querySelector('defs')).toBeNull();
});
it('places the page paint from minus the bleed, left to right whatever the language', () => {
const { container } = render(<SpreadPaint paint={radial} width={210} height={210} bleed={5} idPrefix="x" />);
const svg = need(container.querySelector('svg'));
expect([svg.style.direction, svg.style.insetInlineStart, svg.style.top]).toEqual(['ltr', '-5mm', '-5mm']);
});
});
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import { BOOK_SCHEMA_REV, type BookDocument, type BookRecord } from '@trek/shared'
import type { BookDocument, BookRecord } from '@trek/shared'
import { BOOK_SCHEMA_REV } from '@trek/shared'
/**
* Autosaving a Studio book (#1973).
@@ -851,8 +852,8 @@ describe('a book a newer Studio saved', () => {
expect(result.current.state.status).toBe('newer')
})
/* A book goes out stamped with the revision of the Studio that wrote it, so an older one cannot save over it. */
it('FE-STUDIO-REV-006: sends a book stamped with the revision of this build', async () => {
/* A book goes out stamped with the revision this build writes, so an older Studio opens it read-only. */
it('FE-STUDIO-REV-006: sends a book stamped with the revision this build writes', async () => {
const { result } = await mount()
act(() => { result.current.queueSave(doc('a'), 'T') })
-21
View File
@@ -3,7 +3,6 @@ import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
import { visualizer } from 'rollup-plugin-visualizer';
import { rtlTextAlias, plyrSpriteAlias } from './rtlTextAlias.js';
import { withoutRuntimeFonts } from './bookFontPrecache.js';
import { readFileSync } from 'node:fs';
// The version this bundle is built as, baked in at build time. The Docker build
@@ -78,11 +77,6 @@ export default defineConfig(({ mode }) => ({
// matching the globs below is fetched at service-worker install by every
// user, whether or not they ever reach the code.
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
// Except most of the book's typefaces: of thirty four families only the
// latin files of the seven the book first shipped with are installed,
// the rest wait in the book-fonts rule below for a book that uses them
// (bookFontPrecache.js).
manifestTransforms: [withoutRuntimeFonts],
// build:analyze drops a treemap next to the app; it must never end up in a
// precache manifest if someone ships that build by accident.
// The assistant's chat (@assistant-ui, about 110 kB gzip) only runs online, so
@@ -98,21 +92,6 @@ export default defineConfig(({ mode }) => ({
/^\/plugin-frame\//,
],
runtimeCaching: [
{
// The book's typefaces the precache leaves out (bookFontPrecache.js):
// fetched the first time a page is set in them, and up front for
// every face of a book the Studio opens (bookFontWarm.ts), so the
// book still reads in its own type offline. Same-origin build
// assets only; the files are content-hashed, so a cached one never
// goes stale. First in the list so no broader rule takes them.
urlPattern: /\/assets\/[^/?#]+\.woff2?(?:\?.*)?$/i,
handler: 'CacheFirst',
options: {
cacheName: 'book-fonts',
expiration: { maxEntries: 600, maxAgeSeconds: 365 * 24 * 60 * 60 },
cacheableResponse: { statuses: [200] },
},
},
{
// Carto map tiles (default provider)
// maxEntries MUST stay >= MAX_TILES in src/sync/tilePrefetcher.ts
-270
View File
@@ -33,41 +33,14 @@
"hasInstallScript": true,
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/abril-fatface": "^5.3.0",
"@fontsource/alfa-slab-one": "^5.3.0",
"@fontsource/allura": "^5.3.0",
"@fontsource/anton": "^5.3.0",
"@fontsource/archivo-black": "^5.3.0",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/big-shoulders-display": "^5.3.0",
"@fontsource/bodoni-moda": "^5.3.0",
"@fontsource/bricolage-grotesque": "^5.3.0",
"@fontsource/caveat": "^5.3.0",
"@fontsource/cormorant-garamond": "^5.3.0",
"@fontsource/dancing-script": "^5.3.0",
"@fontsource/dm-mono": "^5.3.0",
"@fontsource/dm-serif-display": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/fraunces": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/great-vibes": "^5.3.0",
"@fontsource/homemade-apple": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/kaushan-script": "^5.3.0",
"@fontsource/league-gothic": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/montserrat": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/oswald": "^5.3.0",
"@fontsource/pacifico": "^5.3.0",
"@fontsource/parisienne": "^5.3.0",
"@fontsource/permanent-marker": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.3.0",
"@fontsource/sacramento": "^5.3.0",
"@fontsource/satisfy": "^5.3.0",
"@fontsource/shrikhand": "^5.3.0",
"@fontsource/yellowtail": "^5.3.0",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.3.0",
@@ -3393,51 +3366,6 @@
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@fontsource/abril-fatface": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/abril-fatface/-/abril-fatface-5.3.0.tgz",
"integrity": "sha512-voKCJEhGGZNdlk+OS3uFRSDV/HWbs6TAy7qKe9wXq96Uzwadfksyk5eyXLa1T5FkEazBgpHRMRSFBKfA3rbKbg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/alfa-slab-one": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/alfa-slab-one/-/alfa-slab-one-5.3.0.tgz",
"integrity": "sha512-J/ANFZ7S9CbZClr/QhMjmBtpFmmKipVkEXHj20aurR5uqJPCBChVPJn1/kx6vtrwnfnDY0kQaYSTYasSt4GruA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/allura": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/allura/-/allura-5.3.0.tgz",
"integrity": "sha512-UqloNNVxl03+psvtIXCYuo7/v2mD9XmX2HGiczhK6NmDDwbFJJDjrbHZ5tpNuVXKsMEQPc46tZu3hewYN82/YA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/anton": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/anton/-/anton-5.3.0.tgz",
"integrity": "sha512-OR1D5n124n2XRs66SbX7IjcOSptljEJE8FcDNnlni2y1/M6KpTx7cU2PIWzTSsBj4WFkHPw24On8qw3qkQZuTw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/archivo-black": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/archivo-black/-/archivo-black-5.3.0.tgz",
"integrity": "sha512-7EsTIQ+Blf96ktpzTUpI7msi+u9/AJxM/GZxOSkEubVd1jSTrFSTNxMgfpX/8rQsjZ+19a+qvRbtcTEp5J/72g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/bebas-neue": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/bebas-neue/-/bebas-neue-5.3.0.tgz",
@@ -3447,78 +3375,6 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/big-shoulders-display": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/big-shoulders-display/-/big-shoulders-display-5.3.0.tgz",
"integrity": "sha512-d4+Tcsq/WfoKeqWfB6wFdeNDsSYYnilJbSNNpf5w53MlWrGHAc9YvkAjC6id6PgFDiGaFKsXfP0RFAQtpt0bog==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/bodoni-moda": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/bodoni-moda/-/bodoni-moda-5.3.0.tgz",
"integrity": "sha512-Wt2XBCHivYxkTP3ydq/Ax+7VshwsOh/6ayA5om2t+yClCkGwLPj2Mi6OnyMANjyLcqDUWs4rlOHnc8SBLf6/pA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/bricolage-grotesque": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/bricolage-grotesque/-/bricolage-grotesque-5.3.0.tgz",
"integrity": "sha512-MdOVb/5in11IfN/IQJOExMuu6AroCIQbirl0yX7NUvKk+h0HYIKfGaVAXRS/gCPil4aFxWhjZnOw28tUwv7tFw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/caveat": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/caveat/-/caveat-5.3.0.tgz",
"integrity": "sha512-eHTfzQxFSW9ABERRLNruHoNJmKed2J7pqTUc5lp4a4POMgXGKpxmWj3d9Hz10kmAg6iuTTLe6UagbA4bnsmfQw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/cormorant-garamond": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/cormorant-garamond/-/cormorant-garamond-5.3.0.tgz",
"integrity": "sha512-weuGsCirGVWBWqpt6YUp0yLThTe4G8YO45noq8wxeJCRvEpq5lFrxNMFSTxcyOyV5k5otzJ8guDzYegdYlcLMQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/dancing-script": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/dancing-script/-/dancing-script-5.3.0.tgz",
"integrity": "sha512-/FFbz4ZCNN+eG2a6phkZB41iKmyCC6rq2blA/kG9OFeI3/tpzLNazND1bBdEnogLhpfg7o4NfauTbEEYSpSUGg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/dm-mono": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/dm-mono/-/dm-mono-5.3.0.tgz",
"integrity": "sha512-OINjI8C1S/wpchhQxl7njZdMn4+hnDCpQ4YtvvOpKNARo+0J8O1x1IcrChxNjHOhfVv1by8C/FQoy3hXK+C1Ug==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/dm-serif-display": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/dm-serif-display/-/dm-serif-display-5.3.0.tgz",
"integrity": "sha512-ecRclUcYeto9fB7ghkB36H4MtUoaUYFjjornWYYeLCMHzaWEDPk2kIWgZOq0mKBNRxclRSieKcFeLonlMYDTKg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/eb-garamond": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/eb-garamond/-/eb-garamond-5.3.0.tgz",
@@ -3528,15 +3384,6 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/fraunces": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/fraunces/-/fraunces-5.3.0.tgz",
"integrity": "sha512-LEHTOBiZufQzAvnAe2rZ8ZknbDS28Q9kzwb0L+gZJEmjLGr8hhZ/5NuHXwDuHBWhVXYoiGGXmrb0H6Ko/RrHTg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/geist-sans": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/geist-sans/-/geist-sans-5.3.0.tgz",
@@ -3546,24 +3393,6 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/great-vibes": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/great-vibes/-/great-vibes-5.3.0.tgz",
"integrity": "sha512-1w60cXzXnC9V4y943po8044O1mqRQBiTTK75h7OpZE+1VQtx+KlAAzoIyEE5KNzu05gUpxaCQg7cbL7ITSwegQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/homemade-apple": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/homemade-apple/-/homemade-apple-5.3.0.tgz",
"integrity": "sha512-awJSYTbSY3SroC8nirEhDauHvERVvm0LI3FpUHsPZXl9Tht1xrqhiI8ro4yf3ROcvQ7CfFJ/2+DLcOHBOXhJKw==",
"license": "Apache-2.0",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz",
@@ -3573,24 +3402,6 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/kaushan-script": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/kaushan-script/-/kaushan-script-5.3.0.tgz",
"integrity": "sha512-Kdcvr76wOGp2jgKkkmAOETmPcw/a34RU/qcGW2+gSKzkKyr03vEaLWSbSABoVKWuyuoqWoulcmC4571vq3BYvg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/league-gothic": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/league-gothic/-/league-gothic-5.3.0.tgz",
"integrity": "sha512-gqedetX+UlaiJgLiPnOzWGB1sY3OtNhS8YMjTy2D73AcsagIqpdIizssr1MStcUYf3D/zzanECuXawIgMGZxDg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/lora": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/lora/-/lora-5.3.0.tgz",
@@ -3600,15 +3411,6 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/montserrat": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.3.0.tgz",
"integrity": "sha512-iQPDtZOnmM5ih4JBGaMSXisRap0ixb9bAUw2hDtY7EjVfaqiiNlKtVuJl9XclxUwtwMMHNcWXfQR6zI9x05+Tg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/museomoderno": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/museomoderno/-/museomoderno-5.3.0.tgz",
@@ -3618,42 +3420,6 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/oswald": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/oswald/-/oswald-5.3.0.tgz",
"integrity": "sha512-16r2aHActe0pH+9FLdk/74/YxXM7HpGak05kAzx3RvSToiJwGvyhv6GvckPz+YuZmvL1nxWbEop2jAXRcXYPRg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/pacifico": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/pacifico/-/pacifico-5.3.0.tgz",
"integrity": "sha512-bWv0fHR1n36p7scRn+maaEaVeQCOq9yvfVfjyAeUzKrK4Y/3qiRAEj4hJgfxj/sjgZZGkeHyKPrbWdDaZtpCVw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/parisienne": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/parisienne/-/parisienne-5.3.0.tgz",
"integrity": "sha512-YbfjZDYy7FMTVE0HRgejkGX3T5iFfgagOYtAAAymq9GklviFAZ1GqIdlyviNe23TiR20f2K7zxsL4AECtWnkEQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/permanent-marker": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/permanent-marker/-/permanent-marker-5.3.0.tgz",
"integrity": "sha512-ZO/NtYW4SCc/iY8N3dmjWyDITcXJ/vhWZFgnwRcvzwCKKnScMrM/a2/6VHlsjCVuIgmPzNrCFbedEYhv+/jJBg==",
"license": "Apache-2.0",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/playfair-display": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/playfair-display/-/playfair-display-5.3.0.tgz",
@@ -3672,42 +3438,6 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/sacramento": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/sacramento/-/sacramento-5.3.0.tgz",
"integrity": "sha512-KkqN52aTsJ0pSBva/NREqFljs0M1TwMIpNcqdSQc2EKR/mVU1CvM2dazf9eG19rHMTIssjbia83nGG705SlD1w==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/satisfy": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/satisfy/-/satisfy-5.3.0.tgz",
"integrity": "sha512-dQ+Cy85XdwYnwlzWcbkmA2Qkpxam0ZHDaGm/qoGHlyHKRVgM/BJN8RLHpRm31R0REFBQe5Yl8Ke5oTdpcKdPIA==",
"license": "Apache-2.0",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/shrikhand": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/shrikhand/-/shrikhand-5.3.0.tgz",
"integrity": "sha512-AWCCj1oNNpMK5hXfFo4JO5KyLhPrdBmpbA0wyIl4IUB05WTU0CGP2UgGX7Lcq/N8nS2+hcxzCPMSQ1R55FuMPg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/yellowtail": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/yellowtail/-/yellowtail-5.3.0.tgz",
"integrity": "sha512-atz4CIdbP7otppxRaObJiygnkyJzIuLFpPRjWspXvXhBWLB0sk35cCu5r6gSkDomRtEWakJ8//bWbyBjJbilGg==",
"license": "Apache-2.0",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
@@ -28,8 +28,8 @@ The energy decides the rest:
- 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 a script (caveat, pacifico, vibes, marker) or the contrasting face (playfair italic), 1.2 to 1.6 times its cap height, with contrast, an outline or a shadow.
- Body 9 to 11 pt in a face whose role is body; captions 6.5 to 8 pt with tracking 0.05 to 0.1; a display face, a body face and one more at most, a script or a mono line not counted; a family sets only the weights its font id lists.
- 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.
+1 -1
View File
@@ -177,7 +177,7 @@ export class JourneyBookMcp {
@Tool({
name: 'edit_book_page',
description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid, saved layout, or auto to lay the page out again from its entry), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes), background (null is the paper), add (a new photo frame, text, shape, icon, map, figures, country outlines, mark or list; the journey's own values such as the route, figures and countries come from the journey, never from you), style (the look of one element of its kind: opacity, colours, fonts, strokes and the like), arrange (to front, forward, backward, to back), align and distribute (several elements by their edges or centres, against each other, the page or the safe area), lock, duplicate, fill (a photo to the bleed of its page or the spread, behind everything), copy_from (elements of another page), refresh_travel (travel elements to the journey's current figures) and add_grid (a photo grid as empty frames in a box). A locked element refuses any operation that would change it but lock. The road a map draws between stops is a straight or bowed line; roads are not routed. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads, and a digest of each page as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid, saved layout, or auto to lay the page out again from its entry), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes), background (a colour, or a paint: a colour with alpha, a linear or radial gradient, or one of twelve patterns; null is the paper), add (a new photo frame, text, shape, icon, map, figures, country outlines, mark or list; the journey's own values such as the route, figures and countries come from the journey, never from you), style (the look of one element of its kind: opacity, colours, a shape's paint, fonts, strokes and the like), arrange (to front, forward, backward, to back), align and distribute (several elements by their edges or centres, against each other, the page or the safe area), lock, duplicate, fill (a photo to the bleed of its page or the spread, behind everything), copy_from (elements of another page), refresh_travel (travel elements to the journey's current figures) and add_grid (a photo grid as empty frames in a box). A locked element refuses any operation that would change it but lock. The road a map draws between stops is a straight or bowed line; roads are not routed. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads, and a digest of each page as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
inputSchema: { journeyId: idSchema, ...editBookPageArgs },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: journeyAddonOn,
+8 -7
View File
@@ -125,9 +125,6 @@ async function journeyWithBook() {
return { owner, editor, viewer, journey, inner, everyone: [owner.id, editor.id, viewer.id].sort((a, b) => a - b) };
}
/** A revision one past what this build reads. */
const NEWER = BOOK_SCHEMA_REV + 1;
describe('Studio book over MCP and REST', () => {
it('JBE2E-001: an MCP write is what GET reads next, and a viewer is refused in the words a tool answers', async () => {
const { editor, viewer, journey, inner } = await journeyWithBook();
@@ -212,7 +209,7 @@ describe('Studio book over MCP and REST', () => {
.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: NEWER });
const newer = JSON.stringify({ ...JSON.parse(String(row?.document)), schemaRev: BOOK_SCHEMA_REV + 1 });
await updateRows(orm, JourneyBooks, { journey: journey.id }, { document: newer });
const saved = await request(app.getHttpServer())
@@ -223,7 +220,7 @@ describe('Studio book over MCP and REST', () => {
expect(saved.body).toEqual({
error: 'Book was saved by a newer version of TREK Studio',
code: 'book_schema_newer',
schemaRev: NEWER,
schemaRev: BOOK_SCHEMA_REV + 1,
});
const tool = await callTool(owner.id, 'edit_book_page', {
@@ -252,12 +249,16 @@ describe('Studio book over MCP and REST', () => {
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: NEWER }, baseVersion: 1 });
.send({
title: before.body.book.title,
document: { ...document, schemaRev: BOOK_SCHEMA_REV + 1 },
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: NEWER,
schemaRev: BOOK_SCHEMA_REV + 1,
});
expect(sent()).toEqual([]);
const after = await findRow(orm, JourneyBooks, { journey: journey.id });
@@ -28,7 +28,6 @@ import {
undoReadsOf,
} from '../../../../../../src/nest/assistant/cards/registry';
import {
BOOK_FONTS_IDS,
bookDesignCardSchema,
MAX_CARD_CHARS,
type AssistantChangePreview,
@@ -251,11 +250,7 @@ describe('the designer in the Studio', () => {
// 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');
for (const face of ['caveat', 'pacifico', 'vibes', 'marker', 'playfair']) {
expect(BOOK_FONTS_IDS, face).toContain(face);
expect(BOOK_DESIGN_PROMPT, face).toContain(face);
}
expect(BOOK_DESIGN_PROMPT).toContain('a script or a mono line not counted');
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);
@@ -631,9 +631,6 @@ describe('editBook', () => {
* of a server that cannot read the book whole.
*/
describe('format revisions', () => {
/** A revision one past what this build reads. */
const NEWER = BOOK_SCHEMA_REV + 1;
/** A journey whose stored book says it was written by revision `schemaRev`. */
async function storedAt(schemaRev: number) {
const { user } = createUser(testDb);
@@ -651,7 +648,7 @@ describe('format revisions', () => {
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(NEWER);
const { user, journey } = await storedAt(2);
const rec = await recordSends();
try {
const thrown = await catchDomainError(() =>
@@ -660,11 +657,11 @@ describe('format revisions', () => {
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: NEWER,
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', NEWER]);
expect([stored?.version, stored?.document.title, stored?.document.schemaRev]).toEqual([1, 'newer', 2]);
} finally {
rec.restore();
}
@@ -702,7 +699,7 @@ describe('format revisions', () => {
});
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(NEWER);
const { user, journey } = await storedAt(BOOK_SCHEMA_REV + 1);
const rec = await recordSends();
try {
const edit = vi.fn(() => ({ title: 'T', document: normalizeBookDocument(doc('tool')), result: null }));
@@ -721,7 +718,7 @@ describe('format revisions', () => {
const { user, journey } = await storedAt(1);
const rec = await recordSends();
try {
const ahead = normalizeBookDocument({ ...doc('ahead'), schemaRev: NEWER });
const ahead = normalizeBookDocument({ ...doc('ahead'), schemaRev: BOOK_SCHEMA_REV + 1 });
const thrown = await catchDomainError(() =>
books.editBook(journey.id, user.id, () => ({ title: 'T', document: ahead, result: null })),
);
+4 -2
View File
@@ -25,9 +25,9 @@ import {
bookStrokeStyleSchema,
bookTextAlignSchema,
bookTypesetFontSchema,
bookWeightSchema,
} from './book-op-enums';
import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './book-op-ids';
import { bookPaintInSchema } from './book-paint.schema';
import {
MAX_BOOK_COUNTRIES,
bookBadgeElementSchema,
@@ -100,7 +100,7 @@ const PHOTO_PROPS = {
export const TEXT_STYLE_FIELDS = {
font: bookFontIdSchema.optional(),
size: text.size.unwrap().optional(),
weight: bookWeightSchema.optional(),
weight: text.weight.unwrap().optional(),
italic: text.italic.unwrap().optional(),
align: bookTextAlignSchema.optional(),
leading: text.leading.unwrap().optional(),
@@ -112,6 +112,8 @@ const SHAPE_PROPS = {
...COMMON,
fill: shape.fill.unwrap().optional(),
gradient: bookShapeGradientSchema.optional(),
/** Overrides fill and gradient when given; null takes the fill away. A plain fill or gradient clears it. */
paint: bookPaintInSchema.nullable().optional(),
stroke: shape.stroke.unwrap().optional(),
strokeWidth: z.number().finite().min(0).max(20).optional(),
strokeStyle: bookStrokeStyleSchema.optional(),
@@ -140,12 +140,6 @@ describe('book level and the page in words', () => {
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([]);
// A script laid over a title and a mono line on a ticket dress a page; they are not a fourth voice.
const dressed = ['caveat', 'mono'].map((font, i) => [
text(`d-${i}`, 'Hi', { x: 230, y: 40, w: 60, h: 10 }, { font }),
]);
expect(mixOf([...fonts.slice(0, 3), ...dressed])).toEqual([]);
expect(mixOf([...fonts, ...dressed])[0]?.message).toMatch(/4 typefaces \(bebas, playfair, sans, serif\)/);
});
it('BOOKQA-010: notes colours outside the book own and the same page three times running', () => {
+14 -12
View File
@@ -1,6 +1,5 @@
import { compareCodeUnits } from '../common/compare';
import { contrastOfLuminance, deltaE, greyLuminance, luminance, parseHex, requiredContrast } from './book-colour';
import { bookFontCountsInMix, bookFontsUsed } from './book-fonts';
import type { BookPhotoInfo } from './book-ops-core';
import { BOOK_OP_LIMITS, type BookFinding } from './book-ops.schema';
import type { BookDocument, BookElement, BookFrame, BookPageSetup, BookPhotoElement, BookSpread } from './book.schema';
@@ -307,13 +306,16 @@ export function designFindings(
return sortBookFindings(ctx.out);
}
/**
* The typefaces that make a book's voice: its texts, its travel elements and
* its page numbers when shown, leaving out the scripts and mono faces, which
* dress a page rather than set it.
*/
/** The typefaces a book is set in: its texts, its travel elements and its page numbers when shown. */
function familiesOf(doc: BookDocument): string[] {
return bookFontsUsed(doc).filter(bookFontCountsInMix).sort(compareCodeUnits);
const used = new Set<string>();
for (const spread of doc.spreads) {
for (const el of spread.elements) {
if ('font' in el && (el.kind !== 'text' || el.text.trim())) used.add(el.font);
}
}
if (doc.page.pageNumbers.show) used.add(doc.page.pageNumbers.font);
return [...used].sort(compareCodeUnits);
}
/** The colours a book paints with: its type and its filled shapes, with how often each is used. */
@@ -340,10 +342,10 @@ function signatureOf(spread: BookSpread): string {
/**
* 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 for figures or a subtitle. 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. Script and mono faces do not
* count towards it (`bookFontCountsInMix`).
* 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;
@@ -356,7 +358,7 @@ export function bookLevelFindings(doc: BookDocument): BookFinding[] {
code: 'font_mix',
severity: 'warn',
elementIds: [],
message: `the book is set in ${families.length} typefaces (${families.join(', ')}); keep to three: a display face, a body face and one more; scripts and mono faces do not count`,
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]));
+11 -4
View File
@@ -1,5 +1,4 @@
import { bookVisiblePart, sortBookFindings } from './book-findings-design';
import { bookFontFace } from './book-fonts';
import type { BookPhotoInfo } from './book-ops-core';
import type { BookFinding } from './book-ops.schema';
import type { BookElement, BookPageSetup, BookPhotoElement, BookSpread, BookTextElement } from './book.schema';
@@ -67,8 +66,16 @@ export function photoDpi(el: BookPhotoElement, info: BookPhotoInfo | undefined):
return Math.round(25.4 / mmPerPx);
}
/** Average letter width per em at weight 400, per family: the catalogue's measure (book-fonts.data.ts). */
const letterEm = (font: string) => bookFontFace(font)?.letterEm ?? 0.5;
/** Average letter width per em at weight 400, per family: measured on story text as the Studio sets each family. */
const LETTER_EM: Record<BookTextElement['font'], number> = {
sans: 0.507,
inter: 0.478,
serif: 0.469,
garamond: 0.383,
playfair: 0.454,
display: 0.492,
bebas: 0.339,
};
/**
* The headroom on letter width the design engine sizes a text frame with. A
@@ -114,7 +121,7 @@ function wrappedLines(para: string, perLine: number): number {
export function textHeightNeeded(el: BookTextElement, headroom = 1): number {
const sizeMm = (orDefault(el.size, 11) * 25.4) / 72;
const tracking = orDefault(el.tracking, 0);
const letter = sizeMm * (letterEm(el.font) * (el.weight >= 600 ? 1.06 : 1) * headroom + tracking);
const letter = sizeMm * ((LETTER_EM[el.font] ?? 0.5) * (el.weight >= 600 ? 1.06 : 1) * headroom + tracking);
const perLine = Math.max(1, Math.floor(el.frame.w / Math.max(letter, 0.1)));
const lines = el.text.split('\n').reduce((sum, para) => sum + wrappedLines(para.trimEnd(), perLine), 0);
return lines * sizeMm * orDefault(el.leading, 1.45);
-481
View File
@@ -1,481 +0,0 @@
/**
* The typefaces a book may be set in, and what each of them is.
*
* One catalogue for every reader of a book: the schema takes its ids, the
* Studio draws its picker and its stacks from it, the findings count its
* roles, the design engine checks its weights, and the assistant reads the
* same lines in the tool schemas. A family named in one of those places and
* missing from another is how five of the first seven fonts shipped without
* their faces (#2183), so nothing here is copied anywhere else.
*
* Every family is self-hosted through @fontsource and licensed under the SIL
* Open Font License 1.1 or the Apache License 2.0 (the typefaces in NOTICE.md),
* which both allow embedding in a printed or exported book.
*
* This file holds data only, so book.schema.ts can take the ids from it
* without importing the logic in book-fonts.ts.
*/
/** Every weight CSS names. A family offers the ones it really ships. */
export const BOOK_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900] as const;
export type BookWeight = (typeof BOOK_WEIGHTS)[number];
/**
* What a family is for, most suited first.
*
* `display` sets a cover title or a poster word, `heading` a page's title,
* `body` running text at 9 to 11 pt. A `script` is laid over a title or a
* photo, a `mono` sets tickets and coordinates. Neither of the last two
* counts towards the faces a book is set in (`font_mix`), because they are
* ornaments of a page rather than its voice.
*/
export const BOOK_FONT_ROLES = ['display', 'heading', 'body', 'script', 'mono'] as const;
export type BookFontRole = (typeof BOOK_FONT_ROLES)[number];
/** The picker's groups, in the order it shows them. */
export const BOOK_FONT_CATEGORIES = ['sans', 'serif', 'elegant', 'condensed', 'retro', 'script', 'mono'] as const;
export type BookFontCategory = (typeof BOOK_FONT_CATEGORIES)[number];
export interface BookFontFace {
/** What it is called, shown as itself in the picker. */
name: string;
/** The CSS stack. Ends in a generic, never in a font that is not bundled. */
stack: string;
/** The @fontsource package the faces come from. */
pkg: string;
category: BookFontCategory;
/** What it suits, the first being what it is for. */
roles: readonly BookFontRole[];
/**
* Weights actually loaded. Asking for 700 on a family that ships one weight
* gets a synthesised bold, which prints as a smeared version of the regular.
*/
weights: readonly BookWeight[];
/** The weights it has a real italic for. Any other italic is the browser's slant. */
italics: readonly BookWeight[];
/**
* Average letter width per em at weight 400, measured on story text from
* the face's own advance widths, for the text measure in book-findings.ts.
*/
letterEm: number;
/** Set in capitals whatever is typed. */
caps?: true;
}
const ALL = BOOK_WEIGHTS;
const ONE = [400] as const;
const MID = [400, 500, 600, 700] as const;
/**
* The families. `sans`, `serif` and `display` are the three the format started
* with and keep their names, because documents already contain them.
*/
export const BOOK_FONT_FACES = {
// ── sans ──
sans: {
name: 'Poppins',
stack: '"Poppins", system-ui, sans-serif',
pkg: 'poppins',
category: 'sans',
roles: ['heading', 'body', 'display'],
weights: ALL,
italics: [],
letterEm: 0.507,
},
inter: {
name: 'Inter',
stack: '"Inter", system-ui, sans-serif',
pkg: 'inter',
category: 'sans',
roles: ['body', 'heading'],
weights: ALL,
italics: [],
letterEm: 0.478,
},
montserrat: {
name: 'Montserrat',
stack: '"Montserrat", system-ui, sans-serif',
pkg: 'montserrat',
category: 'sans',
roles: ['heading', 'body', 'display'],
weights: ALL,
italics: [],
letterEm: 0.507,
},
// ── serif ──
serif: {
name: 'Lora',
stack: '"Lora", Georgia, serif',
pkg: 'lora',
category: 'serif',
roles: ['body', 'heading'],
weights: MID,
italics: MID,
letterEm: 0.469,
},
garamond: {
name: 'EB Garamond',
stack: '"EB Garamond", Georgia, serif',
pkg: 'eb-garamond',
category: 'serif',
roles: ['body', 'heading'],
weights: [400, 500, 600, 700, 800],
italics: [400, 500, 600, 700, 800],
letterEm: 0.383,
},
// ── elegant display serifs ──
playfair: {
name: 'Playfair Display',
stack: '"Playfair Display", Georgia, serif',
pkg: 'playfair-display',
category: 'elegant',
roles: ['display', 'heading'],
weights: [400, 500, 600, 700, 800, 900],
italics: [400, 500, 600, 700, 800, 900],
letterEm: 0.454,
},
fraunces: {
name: 'Fraunces',
stack: '"Fraunces", Georgia, serif',
pkg: 'fraunces',
category: 'elegant',
roles: ['display', 'heading', 'body'],
weights: ALL,
italics: ALL,
letterEm: 0.467,
},
cormorant: {
name: 'Cormorant Garamond',
stack: '"Cormorant Garamond", Georgia, serif',
pkg: 'cormorant-garamond',
category: 'elegant',
roles: ['display', 'heading'],
weights: [300, 400, 500, 600, 700],
italics: [300, 400, 500, 600, 700],
letterEm: 0.393,
},
dmserif: {
name: 'DM Serif Display',
stack: '"DM Serif Display", Georgia, serif',
pkg: 'dm-serif-display',
category: 'elegant',
roles: ['display'],
weights: ONE,
italics: ONE,
letterEm: 0.448,
},
bodoni: {
name: 'Bodoni Moda',
stack: '"Bodoni Moda", Georgia, serif',
pkg: 'bodoni-moda',
category: 'elegant',
roles: ['display', 'heading'],
weights: [400, 500, 600, 700, 800, 900],
italics: [400, 500, 600, 700, 800, 900],
letterEm: 0.463,
},
// ── condensed and poster ──
bebas: {
name: 'Bebas Neue',
stack: '"Bebas Neue", Impact, sans-serif',
pkg: 'bebas-neue',
category: 'condensed',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.339,
caps: true,
},
anton: {
name: 'Anton',
stack: '"Anton", Impact, sans-serif',
pkg: 'anton',
category: 'condensed',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.409,
},
oswald: {
name: 'Oswald',
stack: '"Oswald", Impact, sans-serif',
pkg: 'oswald',
category: 'condensed',
roles: ['display', 'heading'],
weights: [200, 300, 400, 500, 600, 700],
italics: [],
letterEm: 0.365,
},
league: {
name: 'League Gothic',
stack: '"League Gothic", Impact, sans-serif',
pkg: 'league-gothic',
category: 'condensed',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.266,
},
shoulders: {
name: 'Big Shoulders Display',
stack: '"Big Shoulders Display", Impact, sans-serif',
pkg: 'big-shoulders-display',
category: 'condensed',
roles: ['display', 'heading'],
weights: ALL,
italics: [],
letterEm: 0.304,
},
archivo: {
name: 'Archivo Black',
stack: '"Archivo Black", Impact, sans-serif',
pkg: 'archivo-black',
category: 'condensed',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.557,
},
// ── heavy and retro display ──
display: {
name: 'MuseoModerno',
stack: '"MuseoModerno", "Poppins", system-ui, sans-serif',
pkg: 'museomoderno',
category: 'retro',
roles: ['display', 'heading'],
weights: ALL,
italics: [],
letterEm: 0.492,
},
shrikhand: {
name: 'Shrikhand',
stack: '"Shrikhand", system-ui, sans-serif',
pkg: 'shrikhand',
category: 'retro',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.529,
},
alfa: {
name: 'Alfa Slab One',
stack: '"Alfa Slab One", Georgia, serif',
pkg: 'alfa-slab-one',
category: 'retro',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.54,
},
bricolage: {
name: 'Bricolage Grotesque',
stack: '"Bricolage Grotesque", system-ui, sans-serif',
pkg: 'bricolage-grotesque',
category: 'retro',
roles: ['display', 'heading', 'body'],
weights: [200, 300, 400, 500, 600, 700, 800],
italics: [],
letterEm: 0.471,
},
abril: {
name: 'Abril Fatface',
stack: '"Abril Fatface", Georgia, serif',
pkg: 'abril-fatface',
category: 'retro',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.46,
},
// ── script and handwriting ──
caveat: {
name: 'Caveat',
stack: '"Caveat", cursive',
pkg: 'caveat',
category: 'script',
roles: ['script'],
weights: MID,
italics: [],
letterEm: 0.344,
},
dancing: {
name: 'Dancing Script',
stack: '"Dancing Script", cursive',
pkg: 'dancing-script',
category: 'script',
roles: ['script'],
weights: MID,
italics: [],
letterEm: 0.363,
},
pacifico: {
name: 'Pacifico',
stack: '"Pacifico", cursive',
pkg: 'pacifico',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.419,
},
vibes: {
name: 'Great Vibes',
stack: '"Great Vibes", cursive',
pkg: 'great-vibes',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.284,
},
sacramento: {
name: 'Sacramento',
stack: '"Sacramento", cursive',
pkg: 'sacramento',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.308,
},
kaushan: {
name: 'Kaushan Script',
stack: '"Kaushan Script", cursive',
pkg: 'kaushan-script',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.396,
},
allura: {
name: 'Allura',
stack: '"Allura", cursive',
pkg: 'allura',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.33,
},
parisienne: {
name: 'Parisienne',
stack: '"Parisienne", cursive',
pkg: 'parisienne',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.361,
},
satisfy: {
name: 'Satisfy',
stack: '"Satisfy", cursive',
pkg: 'satisfy',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.382,
},
yellowtail: {
name: 'Yellowtail',
stack: '"Yellowtail", cursive',
pkg: 'yellowtail',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.333,
},
homemade: {
name: 'Homemade Apple',
stack: '"Homemade Apple", cursive',
pkg: 'homemade-apple',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.561,
},
marker: {
name: 'Permanent Marker',
stack: '"Permanent Marker", cursive',
pkg: 'permanent-marker',
category: 'script',
roles: ['script', 'display'],
weights: ONE,
italics: [],
letterEm: 0.516,
},
// ── mono ──
mono: {
name: 'DM Mono',
stack: '"DM Mono", ui-monospace, monospace',
pkg: 'dm-mono',
category: 'mono',
roles: ['mono'],
weights: [300, 400, 500],
italics: [],
letterEm: 0.613,
},
} as const satisfies Record<string, BookFontFace>;
export type BookFontFamily = keyof typeof BOOK_FONT_FACES;
/**
* The families a book may be set in, as the schema takes them. The first
* seven keep the order they shipped in; new ones follow.
*/
export const BOOK_FONTS_IDS = [
'sans',
'inter',
'serif',
'garamond',
'playfair',
'display',
'bebas',
'montserrat',
'fraunces',
'cormorant',
'dmserif',
'bodoni',
'anton',
'oswald',
'league',
'shoulders',
'archivo',
'shrikhand',
'alfa',
'bricolage',
'abril',
'caveat',
'dancing',
'pacifico',
'vibes',
'sacramento',
'kaushan',
'allura',
'parisienne',
'satisfy',
'yellowtail',
'homemade',
'marker',
'mono',
] as const satisfies readonly BookFontFamily[];
/**
* The faces whose latin files the service worker installs with the app, so a
* book set in them opens offline before Studio was ever opened online: the
* seven families and weights the book shipped with before this catalogue grew.
* Every other face is cached the first time a book that uses it is opened.
*/
export const BOOK_FONTS_PRECACHED: Readonly<Partial<Record<BookFontFamily, readonly BookWeight[]>>> = {
sans: MID,
inter: MID,
serif: MID,
garamond: MID,
playfair: MID,
display: MID,
bebas: ONE,
};
-207
View File
@@ -1,207 +0,0 @@
import {
bookFacesUsed,
bookFontCountsInMix,
bookFontFace,
bookFontHasItalic,
bookFontHasWeight,
bookFontIndex,
bookFontRole,
bookFontSuits,
bookFontsUsed,
bookWeightWord,
isBookFontId,
nearestBookWeight,
withRealWeight,
} from './book-fonts';
import { BOOK_FONT_FACES, BOOK_FONTS_IDS, BOOK_FONTS_PRECACHED, BOOK_WEIGHTS } from './book-fonts.data';
import { bookFontIdSchema, bookWeightSchema } from './book-op-enums';
import { applyBookOps } from './book-ops';
import { blankBook } from './book-pages';
import {
bookStatsElementSchema,
bookTextElementSchema,
type BookDocument,
type BookElement,
type BookSpread,
} from './book.schema';
import { describeSpread } from './describe-spread';
import { describe, expect, it } from 'vitest';
// BKFONT-001 to BKFONT-010
/**
* The book's typefaces as one catalogue: the schema's ids, the weights and
* italics each family really ships, what each is for, and the snap the
* Studio and the page operations share, so the assistant never sets a weight
* the editor would not.
*/
const frame = { x: 230, y: 40, w: 120, h: 30 };
const text = (id: string, over: Record<string, unknown> = {}) =>
bookTextElementSchema.parse({ id, kind: 'text', frame, text: 'Lisbon', ...over }) as BookElement;
function book(elements: BookElement[]): BookDocument {
const doc = blankBook({ title: 'Lisbon', newId: (p) => `${p}-x` });
const ids = ['sp-cover', 'sp-first', 'sp-in', 'sp-last', 'sp-back'];
const spreads = doc.spreads.map((s, i) => ({ ...s, id: ids[i] as string }));
const inner = { ...(spreads[2] as BookSpread), role: 'inner' as const, elements };
return { ...doc, spreads: [spreads[0], spreads[1], inner, spreads[3], spreads[4]] as BookSpread[] };
}
let seq = 0;
const run = (doc: BookDocument, ops: Parameters<typeof applyBookOps>[1]['ops']) =>
applyBookOps(doc, { spreadId: 'sp-in', ops }, { newId: (p) => `${p}-n${++seq}` });
const inner = (doc: BookDocument) => doc.spreads[2] as BookSpread;
describe('the catalogue', () => {
it('BKFONT-001: names every family the schema takes, once, with a face, a stack ending in a generic and a weight', () => {
expect([...BOOK_FONTS_IDS].sort()).toEqual(Object.keys(BOOK_FONT_FACES).sort());
expect(new Set(BOOK_FONTS_IDS).size).toBe(BOOK_FONTS_IDS.length);
expect(BOOK_FONTS_IDS.slice(0, 7)).toEqual(['sans', 'inter', 'serif', 'garamond', 'playfair', 'display', 'bebas']);
for (const id of BOOK_FONTS_IDS) {
const face = BOOK_FONT_FACES[id];
expect(face.stack.startsWith(`"${face.name}"`), id).toBe(true);
expect(['sans-serif', 'serif', 'cursive', 'monospace'], id).toContain(face.stack.split(',').pop()?.trim());
expect(face.weights.length, id).toBeGreaterThan(0);
expect(
[...face.weights].sort((a, b) => a - b),
id,
).toEqual([...face.weights]);
for (const w of face.italics) expect(face.weights, `${id} italic ${w}`).toContain(w);
expect(face.letterEm, id).toBeGreaterThan(0.2);
expect(face.letterEm, id).toBeLessThan(0.7);
expect(face.roles.length, id).toBeGreaterThan(0);
}
});
it('BKFONT-002: has real italics for the serifs that used to be slanted by the browser', () => {
for (const id of ['serif', 'garamond', 'playfair', 'cormorant']) {
expect(bookFontHasItalic(id, 400), id).toBe(true);
}
expect(bookFontHasItalic('sans', 400)).toBe(false);
expect(bookFontHasItalic('playfair', 300)).toBe(false);
expect(bookFontHasItalic('nothing', 400)).toBe(false);
});
it('BKFONT-003: installs offline only the faces the book shipped with, each a weight the family has', () => {
for (const [id, weights] of Object.entries(BOOK_FONTS_PRECACHED)) {
for (const w of weights ?? []) expect(bookFontHasWeight(id, w), `${id} ${w}`).toBe(true);
}
expect(Object.keys(BOOK_FONTS_PRECACHED).sort()).toEqual(BOOK_FONTS_IDS.slice(0, 7).sort());
});
it('BKFONT-004: reads a family and its roles, and answers an unknown id without guessing', () => {
expect(bookFontFace('anton')?.name).toBe('Anton');
expect(bookFontFace('toString')).toBeUndefined();
expect([isBookFontId('caveat'), isBookFontId('comic')]).toEqual([true, false]);
expect([bookFontRole('caveat'), bookFontRole('mono'), bookFontRole('anton'), bookFontRole('nothing')]).toEqual([
'script',
'mono',
'display',
'body',
]);
expect([bookFontSuits('garamond', 'body'), bookFontSuits('anton', 'body')]).toEqual([true, false]);
expect([bookFontCountsInMix('caveat'), bookFontCountsInMix('mono'), bookFontCountsInMix('serif')]).toEqual([
false,
false,
true,
]);
});
});
describe('weights', () => {
it('BKFONT-005: snaps to the nearest weight a family has, the lighter on a tie, and leaves an unknown family alone', () => {
expect(nearestBookWeight('anton', 900)).toBe(400);
expect(nearestBookWeight('oswald', 100)).toBe(200);
expect(nearestBookWeight('oswald', 900)).toBe(700);
expect(nearestBookWeight('mono', 700)).toBe(500);
expect(nearestBookWeight('cormorant', 200)).toBe(300);
expect(nearestBookWeight('sans', 900)).toBe(900);
expect(nearestBookWeight('unknown', 600)).toBe(600);
expect([bookFontHasWeight('bebas', 700), bookFontHasWeight('unknown', 650)]).toEqual([false, true]);
expect(BOOK_WEIGHTS.map(bookWeightWord)).toEqual([
'thin',
'extralight',
'light',
'regular',
'medium',
'semibold',
'bold',
'extrabold',
'black',
]);
expect(bookWeightWord(450)).toBe('450');
});
it('BKFONT-006: moves a typeset element onto a real weight and hands every other element back as it was', () => {
const heavy = text('t', { font: 'anton', weight: 700 });
expect(withRealWeight(heavy)).toMatchObject({ weight: 400 });
const fine = text('t', { font: 'sans', weight: 700 });
expect(withRealWeight(fine)).toBe(fine);
const stats = bookStatsElementSchema.parse({ id: 's', kind: 'stats', frame, font: 'league' }) as BookElement;
expect(withRealWeight(stats)).toMatchObject({ weight: 400 });
const shape = { id: 'x', kind: 'shape', frame } as unknown as BookElement;
expect(withRealWeight(shape)).toBe(shape);
});
it('BKFONT-007: takes every weight from 100 to 900 in a document and nothing between them', () => {
expect(bookTextElementSchema.safeParse({ id: 't', kind: 'text', frame, weight: 100 }).success).toBe(true);
expect(bookTextElementSchema.safeParse({ id: 't', kind: 'text', frame, weight: 900 }).success).toBe(true);
expect(bookTextElementSchema.safeParse({ id: 't', kind: 'text', frame, weight: 450 }).success).toBe(false);
expect(bookWeightSchema.safeParse(800).success).toBe(true);
});
});
describe('a book and its faces', () => {
it('BKFONT-008: lists the families a book is set in and the faces it needs drawn, each once', () => {
const doc = book([
text('t1', { font: 'caveat', weight: 700 }),
text('t2', { font: 'serif', italic: true }),
text('t3', { font: 'sans', italic: true }),
text('t4', { font: 'anton', text: ' ' }),
bookStatsElementSchema.parse({ id: 's', kind: 'stats', frame, font: 'oswald' }) as BookElement,
]);
const used = bookFontsUsed(doc);
expect(used).toEqual(expect.arrayContaining(['caveat', 'serif', 'oswald']));
expect(used).not.toContain('anton');
expect(used.indexOf('serif')).toBeLessThan(used.indexOf('caveat'));
const faces = bookFacesUsed(doc);
expect(faces).toContainEqual({ font: 'caveat', weight: 700, italic: false });
expect(faces).toContainEqual({ font: 'serif', weight: 400, italic: true });
// Poppins has no italic file: its slant is the browser's, so the upright face is what loads.
expect(faces).toContainEqual({ font: 'sans', weight: 400, italic: false });
expect(faces).toContainEqual({ font: 'oswald', weight: 700, italic: false });
expect(faces).toContainEqual({ font: 'oswald', weight: 600, italic: false });
const keys = faces.map((f) => `${f.font}|${f.weight}|${f.italic}`);
expect(new Set(keys).size).toBe(keys.length);
});
});
describe('the assistant reads the same type', () => {
it('BKFONT-009: describes every family with its role and weights in the id the tools take', () => {
const index = bookFontIndex();
for (const id of BOOK_FONTS_IDS) expect(index, id).toContain(id);
expect(index).toMatch(/^display: /);
expect(index).toContain('script: caveat 400-700');
expect(index).toContain('bebas 400 caps');
expect(index).toContain('serif Lora 400-700 i');
expect(index).not.toMatch(/[\u2013\u2014\n]/);
expect(String(bookFontIdSchema.description)).toContain(index);
});
it('BKFONT-010: sets a style or a text change at a weight the family has, and says so in the page description', () => {
const doc = book([text('t1')]);
const styled = run(doc, [{ op: 'style', elementId: 't1', set: { kind: 'text', font: 'anton', weight: 900 } }]);
if (!styled.ok) throw new Error(styled.error.message);
expect(inner(styled.doc).elements[0]).toMatchObject({ font: 'anton', weight: 400 });
const retexted = run(doc, [{ op: 'text', elementId: 't1', font: 'mono', weight: 700 }]);
if (!retexted.ok) throw new Error(retexted.error.message);
expect(inner(retexted.doc).elements[0]).toMatchObject({ font: 'mono', weight: 500 });
const page = describeSpread(retexted.doc, 'sp-in')?.text ?? '';
expect(page).toContain('mono (mono) 11 pt medium');
const light = run(doc, [{ op: 'style', elementId: 't1', set: { kind: 'text', font: 'fraunces', weight: 300 } }]);
if (!light.ok) throw new Error(light.error.message);
expect(describeSpread(light.doc, 'sp-in')?.text).toContain('fraunces (display) 11 pt light');
});
});
-188
View File
@@ -1,188 +0,0 @@
import {
BOOK_FONT_CATEGORIES,
BOOK_FONT_FACES,
BOOK_FONT_ROLES,
BOOK_FONTS_IDS,
BOOK_FONTS_PRECACHED,
BOOK_WEIGHTS,
type BookFontCategory,
type BookFontFace,
type BookFontFamily,
type BookFontRole,
type BookWeight,
} from './book-fonts.data';
import type { BookDocument, BookElement } from './book.schema';
/**
* What the book's typefaces can do, read off the catalogue in
* book-fonts.data.ts: which weights and italics a family really has, what it
* is for, and which faces a book uses. Shared by the Studio, the findings,
* the design engine and the page operations, so the assistant and the editor
* snap a weight the same way.
*/
export {
BOOK_FONT_CATEGORIES,
BOOK_FONT_FACES,
BOOK_FONT_ROLES,
BOOK_FONTS_PRECACHED,
BOOK_WEIGHTS,
type BookFontCategory,
type BookFontFace,
type BookFontRole,
type BookWeight,
};
/** The family behind an id, or undefined for an id this build does not know. */
export function bookFontFace(id: string): BookFontFace | undefined {
return Object.prototype.hasOwnProperty.call(BOOK_FONT_FACES, id) ? BOOK_FONT_FACES[id as BookFontFamily] : undefined;
}
/** Whether `id` is a family of the catalogue. */
export const isBookFontId = (id: string): id is BookFontFamily => bookFontFace(id) !== undefined;
/** Whether a family ships the weight. An unknown family is not second-guessed. */
export function bookFontHasWeight(id: string, weight: number): boolean {
const face = bookFontFace(id);
return face ? (face.weights as readonly number[]).includes(weight) : true;
}
/**
* The nearest weight a family really has, the lighter one on a tie.
*
* A synthesised bold is a smeared regular in print, so a family with one
* weight gets that weight rather than a request the renderer has to fake. An
* unknown family keeps what was asked.
*/
export function nearestBookWeight(id: string, weight: number): number {
const face = bookFontFace(id);
if (!face || (face.weights as readonly number[]).includes(weight)) return weight;
return face.weights.reduce<number>(
(best, w) => (Math.abs(w - weight) < Math.abs(best - weight) ? w : best),
face.weights[0] ?? weight,
);
}
/**
* The lighter weight a travel element sets beside its figures (a unit after
* a number, a caption under it): a step under the weight, never under 400
* unless the weight itself is, and one the family has.
*/
export function bookLighterWeight(id: string, weight: number): number {
return nearestBookWeight(id, Math.min(weight, Math.max(400, weight - 100)));
}
/** Whether a family has a real italic at that weight, rather than the browser's slant. */
export function bookFontHasItalic(id: string, weight: number): boolean {
const face = bookFontFace(id);
return !!face && (face.italics as readonly number[]).includes(weight);
}
/** What a family is for: the first of its roles, `body` for an unknown one. */
export const bookFontRole = (id: string): BookFontRole => bookFontFace(id)?.roles[0] ?? 'body';
/** Whether a family suits a role at all, not only as its first. */
export function bookFontSuits(id: string, role: BookFontRole): boolean {
return bookFontFace(id)?.roles.includes(role) ?? false;
}
/**
* Whether a family counts towards the faces a book is set in. A script laid
* over a title and a mono line on a ticket are ornaments, not a third voice.
*/
export function bookFontCountsInMix(id: string): boolean {
const role = bookFontRole(id);
return role !== 'script' && role !== 'mono';
}
/** The element with its weight moved onto one its family has, when it is set in type at all. */
export function withRealWeight<T extends BookElement>(el: T): T {
if (!('font' in el) || !('weight' in el)) return el;
const weight = nearestBookWeight(el.font, el.weight);
return weight === el.weight ? el : { ...el, weight: weight as BookWeight };
}
/** One face a book is set in: the family, the weight the renderer draws and the slant. */
export interface BookFaceUse {
font: BookFontFamily;
weight: number;
italic: boolean;
}
/** The type-setting elements on a book's pages: everything that names a font, an empty text aside. */
function typeset(doc: BookDocument): Extract<BookElement, { font: string }>[] {
const out: Extract<BookElement, { font: string }>[] = [];
for (const spread of doc.spreads) {
for (const el of spread.elements) {
if ('font' in el && (el.kind !== 'text' || el.text.trim())) out.push(el);
}
}
return out;
}
/** The families a book is set in, page numbers included when shown, in catalogue order. */
export function bookFontsUsed(doc: BookDocument): BookFontFamily[] {
const used = new Set<string>(typeset(doc).map((el) => el.font));
if (doc.page.pageNumbers.show) used.add(doc.page.pageNumbers.font);
return BOOK_FONTS_IDS.filter((id) => used.has(id));
}
/**
* Every face a book needs drawn, each once: what the Studio warms into the
* offline cache when a book opens, so the book still reads in its own type
* without a connection. Travel elements set a lighter weight beside their
* figures, which is why both of those are named.
*/
export function bookFacesUsed(doc: BookDocument): BookFaceUse[] {
const seen = new Map<string, BookFaceUse>();
const add = (font: BookFontFamily, weight: number, italic: boolean) => {
const use = { font, weight: nearestBookWeight(font, weight), italic: italic && bookFontHasItalic(font, weight) };
seen.set(`${use.font}|${use.weight}|${use.italic}`, use);
};
for (const el of typeset(doc)) {
add(el.font, el.weight, el.kind === 'text' && el.italic);
if (el.kind !== 'text') add(el.font, bookLighterWeight(el.font, el.weight), false);
}
if (doc.page.pageNumbers.show) add(doc.page.pageNumbers.font, 500, false);
return [...seen.values()];
}
/** A weight as the assistant reads it, the CSS name beside the number. */
const WEIGHT_WORDS: Record<BookWeight, string> = {
100: 'thin',
200: 'extralight',
300: 'light',
400: 'regular',
500: 'medium',
600: 'semibold',
700: 'bold',
800: 'extrabold',
900: 'black',
};
export const bookWeightWord = (weight: number): string => WEIGHT_WORDS[weight as BookWeight] ?? String(weight);
/**
* The catalogue as one line per role, for the font id's description in the
* tool schemas: an id, its face and the weights it has, an i for a real
* italic, so a model picks a script for an overlay and never asks Anton for
* bold.
*/
export function bookFontIndex(): string {
const span = (ws: readonly number[]) => (ws.length === 1 ? `${ws[0]}` : `${ws[0]}-${ws[ws.length - 1]}`);
return BOOK_FONT_ROLES.map((role) => {
const ids = BOOK_FONTS_IDS.filter((id) => bookFontRole(id) === role);
if (!ids.length) return '';
const listed = ids.map((id) => {
const face = BOOK_FONT_FACES[id];
const italic = face.italics.length ? ' i' : '';
const caps = 'caps' in face ? ' caps' : '';
// The face is named only where the id does not already say it, for the size of the tool schemas.
const named = face.name.toLowerCase().startsWith(id) ? '' : ` ${face.name}`;
return `${id}${named} ${span(face.weights)}${italic}${caps}`;
});
return `${role}: ${listed.join(', ')}.`;
})
.filter(Boolean)
.join(' ');
}
+4 -11
View File
@@ -1,4 +1,3 @@
import { bookFontIndex } from './book-fonts';
import { BOOK_ICON_NAMES } from './book-icons';
import {
BOOK_BADGES,
@@ -38,20 +37,14 @@ const text = bookTextElementSchema.shape;
const shape = bookShapeElementSchema.shape;
const map = bookMapElementSchema.shape;
/**
* The book's typefaces by what they are for, each with its face, the weights
* it has and an i where it has a real italic. A weight a family lacks is set
* at its nearest one, as in the Studio.
*/
/** The book's typefaces, with the face each id sets. */
export const bookFontIdSchema = z
.enum(BOOK_FONTS_IDS)
.describe(`Typeface by role; weights a family lacks snap to its nearest. ${bookFontIndex()}`);
.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.');
/** How heavy type is set, 100 thin to 900 black. */
export const bookWeightSchema = bookTextElementSchema.shape.weight
.unwrap()
.describe('Weight 100 thin to 900 black, 400 regular, 700 bold; snapped to one the family has.');
/** Every shape the Studio draws, and so every mask a photo can be cut to. */
export const bookShapeIdSchema = z
+20 -8
View File
@@ -1,7 +1,6 @@
import { relayoutSpread } from './auto/auto-book';
import type { AutoEntry, AutoInput } from './auto/auto-types';
import { bookElementPropsSchema, type BOOK_ELEMENT_OP_SCHEMAS, type BookNewElement } from './book-element-ops.schema';
import { withRealWeight } from './book-fonts';
import { bookGridElements, defaultBookGridBox, findBookGrid } from './book-grids';
import {
bookBoundWords,
@@ -14,6 +13,8 @@ import {
unlockedElement,
type BookOpsContext,
} from './book-ops-core';
import { withShapePaint } from './book-paint';
import type { BookPaint } from './book-paint.schema';
import {
bookBadgeValue,
bookCountryMark,
@@ -34,6 +35,7 @@ import type {
BookFrame,
BookPageSetup,
BookPhotoElement,
BookShapeElement,
BookSpread,
BookTextElement,
} from './book.schema';
@@ -139,20 +141,30 @@ function lookKeys(shape: object): string[] {
return Object.keys(shape).filter((k) => k !== 'kind');
}
/**
* The element with the given look fields set; millimetre fields rounded as the
* document stores them, and the weight moved onto one its family has, as the
* Studio's weight control does.
*/
/** The element with the given look fields set; millimetre fields rounded as the document stores them. */
function withProps(el: BookElement, props: Record<string, unknown>): BookElement {
const keys = PROP_KEYS.get(el.kind) ?? [];
const next: Record<string, unknown> = { ...el };
for (const key of keys) {
const value = props[key];
if (value === undefined) continue;
if (value === undefined || key === 'paint') continue;
next[key] = (key === 'radius' || key === 'strokeWidth') && typeof value === 'number' ? round2(value) : value;
}
return withRealWeight(next as unknown as BookElement);
const made = next as unknown as BookElement;
return made.kind === 'shape' ? withPaintProps(made, props) : made;
}
/**
* A shape's paint, kept in step with its fill: a paint given is written
* through withShapePaint, and a plain fill or fade given without one replaces
* the paint the shape had, since the newer instruction is the one meant.
*/
function withPaintProps(el: BookShapeElement, props: Record<string, unknown>): BookShapeElement {
const paint = props.paint as BookPaint | null | undefined;
if (paint !== undefined) return withShapePaint(el, paint);
if (props.fill === undefined && props.gradient === undefined) return el;
const { paint: _replaced, ...rest } = el;
return rest;
}
// ── add ────────────────────────────────────────────────────────────────────
+9 -2
View File
@@ -11,6 +11,7 @@ import {
bookPhotoMaskSchema,
} from './book-op-enums';
import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './book-op-ids';
import { bookPaintInSchema } from './book-paint.schema';
import {
MAX_BOOK_TITLE,
MAX_SPREADS,
@@ -188,8 +189,14 @@ export const bookPageOpSchema = z.discriminatedUnion('op', [
elementIds: z.array(bookElementIdSchema).min(1).max(BOOK_OP_LIMITS.removePerOp),
park: z.boolean().default(true),
}),
/** The page colour; null is the paper. */
z.strictObject({ op: z.literal('background'), color: bookSpreadSchema.shape.background.unwrap() }),
/** The page colour or paint; null is the paper. Exactly one of the two. */
z
.strictObject({
op: z.literal('background'),
color: bookSpreadSchema.shape.background.unwrap().optional(),
paint: bookPaintInSchema.nullable().optional(),
})
.refine((v) => (v.color === undefined) !== (v.paint === undefined), { message: 'give the color or the paint' }),
...BOOK_ELEMENT_OP_SCHEMAS,
]);
export type BookPageOp = z.infer<typeof bookPageOpSchema>;
+7 -6
View File
@@ -1,4 +1,3 @@
import { withRealWeight } from './book-fonts';
import { bookGridElements, defaultBookGridBox, findBookGrid, type BookGridDef } from './book-grids';
import { bookElementId } from './book-ids';
import { BOOK_TEXT_STYLES, applyBookTemplate, classifyBookTexts, findBookTemplate } from './book-layouts';
@@ -22,6 +21,7 @@ import {
} from './book-ops-core';
import { applyElementOp, autoRelayout } from './book-ops-elements';
import { BOOK_OP_LIMITS, bookPageOpSchema, type BookPageOp } from './book-ops.schema';
import { withSpreadPaint } from './book-paint';
import { applySavedBookLayout, bookLayoutRole } from './book-saved-layouts';
import {
MAX_SPREAD_ELEMENTS,
@@ -119,7 +119,10 @@ function applyOne(spread: BookSpread, op: BookPageOp, env: Env): BookSpread {
case 'remove':
return opRemove(spread, op, env);
case 'background':
return { ...spread, background: op.color };
return withSpreadPaint(
spread,
op.paint !== undefined ? op.paint : op.color ? { type: 'solid', color: op.color } : null,
);
default:
return applyElementOp(spread, op, env);
}
@@ -414,11 +417,9 @@ function opText(spread: BookSpread, op: Op<'text'>, env: Env): BookSpread {
op.elementId !== undefined
? textEl(spread, op.elementId, env)
: classifyBookTexts(roleTexts(spread))[op.role ?? 'body'];
// The weight lands on one the family has, whichever of the two a change names.
if (existing && existing.kind === 'text')
return replaceEl(spread, withRealWeight(retext(unlocked(existing, env), op, env)));
if (existing && existing.kind === 'text') return replaceEl(spread, retext(unlocked(existing, env), op, env));
withRoom(spread, 1, env);
return { ...spread, elements: [...spread.elements, withRealWeight(newText(spread, op, env))] };
return { ...spread, elements: [...spread.elements, newText(spread, op, env)] };
}
function retext(el: BookTextElement, op: Op<'text'>, env: Env): BookTextElement {
+109
View File
@@ -0,0 +1,109 @@
import { z } from 'zod';
/**
* Paint: what fills a shape or a page when one flat colour is not enough.
*
* Four kinds, and each is something a printed page can carry as vector:
*
* - `solid`: one colour, with an alpha of its own. The colour stays `#rrggbb`
* and the alpha is a separate number, so every reader that knows hex keeps
* reading the colour, and print gets `fill-opacity` rather than a colour
* format some RIP does not know.
* - `linear`: two to six stops along a line at `angle` degrees. The angle
* reads the way CSS reads it: 0 runs towards the top, 90 towards the right.
* - `radial`: the same stops from a centre (`cx`, `cy` as fractions of the
* box) out to `r`, a fraction of half the box's diagonal, so 1 reaches the
* corners from the middle whatever the box's proportions.
* - `pattern`: one of the twelve repeats, `fg` drawn over `bg` (null lets what
* is underneath show through), its cell `scale` times the natural 8 mm and
* turned by `angle`.
*
* Paint is additive. A shape keeps its `fill` and `gradient` and a page its
* `background`, set to the colour that stands for the paint (book-paint.ts),
* so a reader from before paint existed still draws something close and a
* finding that measures contrast still has a colour to measure.
*/
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
const alpha = z.number().min(0).max(1);
const angle = z.number().finite().min(-360).max(360);
/** The repeats a pattern paint draws, each as explicit geometry clipped to the box. */
export const BOOK_PATTERNS = [
'checker',
'gingham',
'stripes',
'pinstripe',
'dots',
'polka',
'zigzag',
'waves',
'grid',
'scales',
'sunburst',
'confetti',
] as const;
export type BookPattern = (typeof BOOK_PATTERNS)[number];
/** A gradient has at least two stops and at most this many. */
export const MAX_PAINT_STOPS = 6;
/** The scale range of a pattern, against its natural 8 mm cell. */
export const PATTERN_SCALE = { min: 0.25, max: 4 } as const;
/** The radius range of a radial gradient, as a fraction of half the box's diagonal. */
export const RADIAL_RADIUS = { min: 0.05, max: 2 } as const;
type ObjectFactory = typeof z.object | typeof z.strictObject;
/**
* The union, built once for the document (plain objects, which drop a field
* they do not know, as the rest of the document does) and once for the
* operations (strict, as every operation is), so the two cannot drift.
*/
function paintSchemas(obj: ObjectFactory) {
const stop = obj({ at: z.number().min(0).max(1), color: hex, alpha: alpha.optional() });
const stops = z.array(stop).min(2).max(MAX_PAINT_STOPS);
const paint = z.discriminatedUnion('type', [
obj({ type: z.literal('solid'), color: hex, alpha: alpha.optional() }),
obj({ type: z.literal('linear'), angle, stops }),
obj({
type: z.literal('radial'),
cx: z.number().min(0).max(1),
cy: z.number().min(0).max(1),
r: z.number().min(RADIAL_RADIUS.min).max(RADIAL_RADIUS.max),
stops,
}),
obj({
type: z.literal('pattern'),
pattern: z.enum(BOOK_PATTERNS),
fg: hex,
bg: hex.nullable(),
scale: z.number().min(PATTERN_SCALE.min).max(PATTERN_SCALE.max),
angle,
}),
]);
return { stop, paint };
}
const docSchemas = paintSchemas(z.object);
export const bookPaintStopSchema = docSchemas.stop;
export const bookPaintSchema = docSchemas.paint;
export type BookPaint = z.infer<typeof bookPaintSchema>;
export type BookPaintStop = z.infer<typeof bookPaintStopSchema>;
export type BookGradientPaint = Extract<BookPaint, { type: 'linear' | 'radial' }>;
export type BookPatternPaint = Extract<BookPaint, { type: 'pattern' }>;
/**
* Paint as the page operations take it. Described once, here, because the
* tool schema is all a model reads of it and it appears in three places.
*/
export const bookPaintInSchema = paintSchemas(z.strictObject).paint.describe(
'solid (alpha 0..1), linear (angle: 0 towards the top, 90 towards the right) or radial (cx, cy 0..1 of the box, r of half its diagonal) with 2 to 6 stops at 0..1, or a pattern: fg on bg (null shows through), scale 1 is an 8 mm cell.',
);
/**
* The optional field a shape and a page carry. A paint the reader cannot read
* falls back to nothing, which leaves the colour beside it to draw, rather
* than failing the element or the page it sits on.
*/
export const bookPaintFieldSchema = bookPaintSchema.optional().catch(undefined);
+290
View File
@@ -0,0 +1,290 @@
import { normalizeBookDocument } from './book-normalize';
import { applyBookOps } from './book-ops';
import { bookPageOpSchema } from './book-ops.schema';
import {
bookColours,
legacyShapePaint,
paintColours,
paintFallbackHex,
paintWords,
shapePaint,
sortedStops,
spreadPaint,
withShapePaint,
withSpreadPaint,
} from './book-paint';
import { BOOK_PATTERNS, bookPaintInSchema, bookPaintSchema, type BookPaint } from './book-paint.schema';
import { applySavedBookLayout, bookLayoutFromSpread, bookLayoutPreview } from './book-saved-layouts';
import {
bookLayoutSchema,
bookPageSetupSchema,
bookShapeElementSchema,
bookSpreadSchema,
type BookDocument,
type BookShapeElement,
type BookSpread,
} from './book.schema';
import { describeSpread } from './describe-spread';
import { describe, expect, it } from 'vitest';
// BKPAINT-001 to BKPAINT-016
/**
* Paint: the document's gradients, alpha and patterns, how they stay readable
* for a reader from before them, and what the page operations and the page
* description make of them.
*/
const frame = { x: 10, y: 10, w: 60, h: 40 };
const shape = (over: Record<string, unknown> = {}): BookShapeElement =>
bookShapeElementSchema.parse({ id: 's1', kind: 'shape', frame, ...over });
const linear: BookPaint = {
type: 'linear',
angle: 45,
stops: [
{ at: 0, color: '#ff0000' },
{ at: 1, color: '#0000ff' },
],
};
const radial: BookPaint = {
type: 'radial',
cx: 0.3,
cy: 0.4,
r: 0.8,
stops: [
{ at: 0, color: '#ffffff', alpha: 0.5 },
{ at: 1, color: '#000000' },
],
};
const stripes: BookPaint = { type: 'pattern', pattern: 'stripes', fg: '#112233', bg: '#ffeedd', scale: 1, angle: 45 };
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
const doc = (spreads: unknown[]): BookDocument => normalizeBookDocument({ version: 1, title: 'T', page, spreads });
const spread = (over: Record<string, unknown> = {}): BookSpread =>
bookSpreadSchema.parse({ id: 'sp1', role: 'inner', elements: [], ...over });
describe('the paint contract', () => {
it('BKPAINT-001: reads the four kinds and the twelve patterns', () => {
for (const p of [{ type: 'solid', color: '#abcdef', alpha: 0.4 }, linear, radial, stripes]) {
expect(bookPaintSchema.parse(p)).toEqual(p);
}
expect(BOOK_PATTERNS).toHaveLength(12);
for (const pattern of BOOK_PATTERNS) expect(bookPaintSchema.safeParse({ ...stripes, pattern }).success).toBe(true);
});
it('BKPAINT-002: refuses a gradient of one stop or of seven, a colour that is not hex and an alpha past 1', () => {
const stop = { at: 0, color: '#000000' };
expect(bookPaintSchema.safeParse({ ...linear, stops: [stop] }).success).toBe(false);
expect(bookPaintSchema.safeParse({ ...linear, stops: Array(7).fill(stop) }).success).toBe(false);
expect(bookPaintSchema.safeParse({ type: 'solid', color: 'red' }).success).toBe(false);
expect(bookPaintSchema.safeParse({ type: 'solid', color: '#000000', alpha: 1.5 }).success).toBe(false);
expect(bookPaintSchema.safeParse({ ...stripes, scale: 9 }).success).toBe(false);
});
it('BKPAINT-003: an operation paint is strict, a stored one drops what it does not know', () => {
expect(bookPaintInSchema.safeParse({ type: 'solid', color: '#000000', glow: 1 }).success).toBe(false);
expect(bookPaintSchema.parse({ type: 'solid', color: '#000000', glow: 1 })).toEqual({
type: 'solid',
color: '#000000',
});
});
it('BKPAINT-004: a paint the reader cannot read falls back to the fill beside it, and the shape still reads', () => {
const el = bookShapeElementSchema.parse({
id: 's1',
kind: 'shape',
frame,
fill: '#123456',
paint: { type: 'plaid' },
});
expect(el.paint).toBeUndefined();
expect(el.fill).toBe('#123456');
const sp = bookSpreadSchema.parse({
id: 'sp',
background: '#eeeeee',
backgroundPaint: { type: 'linear', stops: [] },
});
expect(sp.backgroundPaint).toBeUndefined();
expect(sp.background).toBe('#eeeeee');
});
});
describe('the colour that stands for a paint', () => {
it('BKPAINT-005: is the colour of a solid, a see-through solid as it looks on the paper', () => {
expect(paintFallbackHex({ type: 'solid', color: '#FF0000' })).toBe('#ff0000');
expect(paintFallbackHex({ type: 'solid', color: '#000000', alpha: 0.5 })).toBe('#808080');
});
it('BKPAINT-006: mixes a gradient by the stretch each stop holds, and takes a pattern by its ground', () => {
expect(paintFallbackHex(linear)).toBe('#800080');
const weighted: BookPaint = {
type: 'linear',
angle: 0,
stops: [
{ at: 0.5, color: '#000000' },
{ at: 0, color: '#ffffff' },
],
};
expect(sortedStops(weighted.type === 'linear' ? weighted.stops : []).map((s) => s.at)).toEqual([0, 0.5]);
// White holds a quarter of the line, black the other three.
expect(paintFallbackHex(weighted)).toBe('#404040');
expect(paintFallbackHex(stripes)).toBe('#ffeedd');
expect(paintFallbackHex({ ...stripes, bg: null } as BookPaint)).toBe('#112233');
});
});
describe('a shape and its paint', () => {
it('BKPAINT-007: reads its old fill and fade as the paint they draw', () => {
expect(legacyShapePaint(shape({ fill: null }))).toBeNull();
expect(shapePaint(shape({ fill: '#ABCDEF' }))).toEqual({ type: 'solid', color: '#abcdef' });
const up = shapePaint(shape({ fill: '#102030', gradient: 'up' }));
expect(up).toMatchObject({ type: 'linear', angle: 0 });
expect(up?.type === 'linear' && up.stops.map((s) => s.alpha)).toEqual([0, 0.55, 1]);
expect(shapePaint(shape({ gradient: 'down' }))).toMatchObject({ angle: 180 });
expect(shapePaint(shape({ paint: stripes }))).toEqual(stripes);
});
it('BKPAINT-008: stores one opaque colour and the cover fade in the old fields alone, anything else beside them', () => {
const plain = withShapePaint(shape({ paint: stripes }), { type: 'solid', color: '#C2410C' });
expect(plain).toMatchObject({ fill: '#c2410c', gradient: 'none' });
expect('paint' in plain).toBe(false);
const fade = withShapePaint(shape(), legacyShapePaint(shape({ fill: '#102030', gradient: 'down' })));
expect(fade).toMatchObject({ fill: '#102030', gradient: 'down' });
expect('paint' in fade).toBe(false);
const painted = withShapePaint(shape({ gradient: 'up' }), linear);
expect(painted).toMatchObject({ paint: linear, fill: '#800080', gradient: 'none' });
expect(withShapePaint(shape({ paint: linear }), null)).toMatchObject({ fill: null, gradient: 'none' });
});
});
describe('a page and its paint', () => {
it('BKPAINT-009: keeps background as the colour that stands for the paint, and the paper as null', () => {
expect(spreadPaint(spread())).toBeNull();
expect(spreadPaint(spread({ background: '#F4EFE6' }))).toEqual({ type: 'solid', color: '#f4efe6' });
const painted = withSpreadPaint(spread(), radial);
expect(painted.backgroundPaint).toEqual(radial);
expect(painted.background).toMatch(/^#[0-9a-f]{6}$/);
expect(spreadPaint(painted)).toEqual(radial);
const flat = withSpreadPaint(painted, { type: 'solid', color: '#202020' });
expect([flat.background, 'backgroundPaint' in flat]).toEqual(['#202020', false]);
expect(withSpreadPaint(painted, null)).toMatchObject({ background: null });
});
it('BKPAINT-010: a saved layout carries the paint of the page it was taken from, and gives it back', () => {
const painted = withSpreadPaint(spread({ elements: [shape()] }), stripes);
const layout = bookLayoutSchema.parse(bookLayoutFromSpread(painted, page, 'Stripes'));
expect(layout.backgroundPaint).toEqual(stripes);
expect(bookLayoutPreview(layout, page).backgroundPaint).toEqual(stripes);
const applied = applySavedBookLayout(spread({ backgroundPaint: linear, background: '#800080' }), layout, page);
expect(applied.backgroundPaint).toEqual(stripes);
const plainLayout = bookLayoutSchema.parse(bookLayoutFromSpread(spread({ background: '#111111' }), page, 'Ink'));
const replaced = applySavedBookLayout(spread({ backgroundPaint: linear }), plainLayout, page);
expect(['backgroundPaint' in replaced, replaced.background]).toEqual([false, '#111111']);
});
});
describe('what a paint is in words', () => {
it('BKPAINT-011: names the kind, the colours and the numbers that place them', () => {
expect(paintWords({ type: 'solid', color: '#ABCDEF' })).toBe('#abcdef');
expect(paintWords({ type: 'solid', color: '#abcdef', alpha: 0.25 })).toBe('#abcdef at 25%');
expect(paintWords(linear)).toBe('linear gradient towards 45 deg: #ff0000 at 0%, #0000ff at 100%');
expect(paintWords(radial)).toBe('radial gradient from 30% 40%, radius 0.8: #ffffff at 50% at 0%, #000000 at 100%');
expect(paintWords(stripes)).toBe('stripes pattern, #112233 on #ffeedd, scale 1, turned 45 deg');
expect(paintWords({ ...stripes, bg: null } as BookPaint)).toContain('on nothing (see-through)');
expect(paintColours(stripes)).toEqual(['#112233', '#ffeedd']);
expect(paintColours(null)).toEqual([]);
});
it('BKPAINT-012: the book palette counts every colour a page is drawn in, most used first', () => {
const book = doc([
{ id: 'a', background: '#ffeedd', elements: [shape({ fill: '#112233' }), shape({ id: 's2', paint: stripes })] },
{ id: 'b', backgroundPaint: linear, background: '#800080', elements: [] },
]);
const colours = bookColours(book);
expect(colours.slice(0, 2)).toEqual(['#112233', '#ffeedd']);
expect(colours).toEqual(expect.arrayContaining(['#ff0000', '#0000ff']));
expect(bookColours(null)).toEqual([]);
expect(bookColours(book, 1)).toHaveLength(1);
});
});
describe('the assistant on the same paint', () => {
const book = () =>
doc([{ id: 'sp1', role: 'inner', elements: [{ id: 's1', kind: 'shape', frame, fill: '#111111' }], parked: [] }]);
const edit = (ops: unknown[]) => {
const result = applyBookOps(book(), { spreadId: 'sp1', ops }, { newId: (p) => `${p}-new` });
if (!result.ok) throw new Error(result.error.message);
return result.doc.spreads[0] as BookSpread;
};
it('BKPAINT-013: the background operation takes a paint or a colour, one of the two', () => {
expect(bookPageOpSchema.safeParse({ op: 'background', color: '#ffffff' }).success).toBe(true);
expect(bookPageOpSchema.safeParse({ op: 'background', paint: linear }).success).toBe(true);
expect(bookPageOpSchema.safeParse({ op: 'background', color: null }).success).toBe(true);
expect(bookPageOpSchema.safeParse({ op: 'background' }).success).toBe(false);
expect(bookPageOpSchema.safeParse({ op: 'background', color: '#ffffff', paint: linear }).success).toBe(false);
expect(edit([{ op: 'background', paint: stripes }])).toMatchObject({
backgroundPaint: stripes,
background: '#ffeedd',
});
const back = edit([
{ op: 'background', paint: stripes },
{ op: 'background', color: '#202020' },
]);
expect([back.background, 'backgroundPaint' in back]).toEqual(['#202020', false]);
expect(edit([{ op: 'background', color: null }]).background).toBeNull();
});
it('BKPAINT-014: style and add set a shape paint, and a plain fill given later replaces it', () => {
const painted = edit([{ op: 'style', elementId: 's1', set: { kind: 'shape', paint: radial } }]);
expect(painted.elements[0]).toMatchObject({ paint: radial, gradient: 'none' });
const flat = edit([
{ op: 'style', elementId: 's1', set: { kind: 'shape', paint: radial } },
{ op: 'style', elementId: 's1', set: { kind: 'shape', fill: '#00ff00' } },
]);
expect(flat.elements[0]).toMatchObject({ fill: '#00ff00' });
expect('paint' in (flat.elements[0] ?? {})).toBe(false);
const added = edit([{ op: 'add', element: { kind: 'shape', shape: 'heart', paint: stripes } }]);
expect(added.elements[1]).toMatchObject({ kind: 'shape', shape: 'heart', paint: stripes, fill: '#ffeedd' });
const none = edit([{ op: 'style', elementId: 's1', set: { kind: 'shape', paint: null } }]);
expect(none.elements[0]).toMatchObject({ fill: null });
});
it('BKPAINT-015: describe_book_page reads the paint of a shape and of the page', () => {
const painted = doc([
{
id: 'sp1',
role: 'inner',
background: '#ffeedd',
backgroundPaint: stripes,
elements: [{ id: 's1', kind: 'shape', frame, fill: '#800080', paint: linear }],
},
]);
const text = describeSpread(painted, 'sp1')?.text ?? '';
expect(text).toContain('Background stripes pattern, #112233 on #ffeedd');
expect(text).toContain('(reads as #ffeedd)');
expect(text).toContain('rect, paint linear gradient towards 45 deg');
const plain = describeSpread(doc([{ id: 'sp1', elements: [{ id: 's1', kind: 'shape', frame }] }]), 'sp1')?.text;
expect(plain).toContain('Background paper (none set)');
expect(plain).toContain('rect, fill #111827');
});
it('BKPAINT-016: a book with paint survives the trip through the document contract whole', () => {
const painted = doc([
{ id: 'sp1', backgroundPaint: radial, background: '#c0c0c0', elements: [shape({ paint: stripes })] },
]);
const again = normalizeBookDocument(JSON.parse(JSON.stringify(painted)));
expect(again.spreads[0]?.backgroundPaint).toEqual(radial);
expect(again.spreads[0]?.elements[0]).toMatchObject({ paint: stripes });
});
});
+210
View File
@@ -0,0 +1,210 @@
import { parseHex, toHex, type Rgb } from './book-colour';
import type { BookPaint, BookPaintStop } from './book-paint.schema';
import type { BookDocument, BookElement, BookShapeElement, BookSpread } from './book.schema';
/**
* Reading and writing paint, the one place the old colour fields and the new
* paint are kept in step.
*
* Every writer goes through `withShapePaint` and `withSpreadPaint`, the
* Studio and the page operations alike, so a shape's `fill` and a page's
* `background` always hold the colour that stands for its paint, and a paint
* that the old fields can say whole (one opaque colour, the cover fade) is
* stored in them alone, as it was before paint existed.
*/
/** The cover fade, as the stops `gradient: 'up' | 'down'` has always drawn. */
const FADE_STOPS = [
{ at: 0, alpha: 0 },
{ at: 0.46, alpha: 0.55 },
{ at: 1, alpha: 1 },
] as const;
const lower = (hex: string) => hex.toLowerCase();
/** A paint stop's alpha, 1 when it says none. */
export const stopAlpha = (stop: { alpha?: number }): number => stop.alpha ?? 1;
/** Stops in order along the gradient, so a renderer and a reader never meet them shuffled. */
export function sortedStops(stops: readonly BookPaintStop[]): BookPaintStop[] {
return [...stops].sort((a, b) => a.at - b.at);
}
/** The paint a shape's old fields describe: its fill, faded towards one edge when `gradient` says so. */
export function legacyShapePaint(el: Pick<BookShapeElement, 'fill' | 'gradient'>): BookPaint | null {
if (el.fill === null) return null;
if (el.gradient === 'none') return { type: 'solid', color: lower(el.fill) };
const color = lower(el.fill);
return {
type: 'linear',
// 'up' is solid at the top, so the line runs towards the top: CSS's 0deg.
angle: el.gradient === 'up' ? 0 : 180,
stops: FADE_STOPS.map((s) => ({ at: s.at, color, alpha: s.alpha })),
};
}
/** What fills a shape: its paint, or what its old fields say. Null is no fill. */
export function shapePaint(el: BookShapeElement): BookPaint | null {
return el.paint ?? legacyShapePaint(el);
}
/** What fills a page: its paint, or its background colour. Null is the paper. */
export function spreadPaint(spread: Pick<BookSpread, 'background' | 'backgroundPaint'>): BookPaint | null {
if (spread.backgroundPaint) return spread.backgroundPaint;
return spread.background ? { type: 'solid', color: lower(spread.background) } : null;
}
/** The paper a see-through paint is read against. */
const PAPER: Rgb = { r: 255, g: 255, b: 255 };
/** A colour at an alpha, as it looks laid on the paper. */
function onPaper(hex: string, alpha: number): Rgb | null {
const rgb = parseHex(hex);
if (!rgb) return null;
const a = Math.max(0, Math.min(1, alpha));
const mix = (c: number, p: number) => p + (c - p) * a;
return { r: mix(rgb.r, PAPER.r), g: mix(rgb.g, PAPER.g), b: mix(rgb.b, PAPER.b) };
}
/**
* The one opaque colour that stands for a paint: what an old reader draws,
* and what a contrast check measures against.
*
* A see-through colour is read as it looks on the paper. A gradient gives the
* mix of its stops, each weighted by the stretch of the line it holds; a
* pattern gives its ground, or its ink where it has none, since the ground is
* what most of the box shows.
*/
export function paintFallbackHex(paint: BookPaint): string {
switch (paint.type) {
case 'solid':
return stopAlpha(paint) >= 1 ? lower(paint.color) : toHex(onPaper(paint.color, stopAlpha(paint)) ?? PAPER);
case 'pattern':
return lower(paint.bg ?? paint.fg);
default:
return mixStops(sortedStops(paint.stops));
}
}
function mixStops(stops: readonly BookPaintStop[]): string {
let sum: Rgb = { r: 0, g: 0, b: 0 };
let total = 0;
stops.forEach((stop, i) => {
const rgb = onPaper(stop.color, stopAlpha(stop));
if (!rgb) return;
const before = i === 0 ? stop.at : (stop.at - (stops[i - 1]?.at ?? 0)) / 2;
const after = i === stops.length - 1 ? 1 - stop.at : ((stops[i + 1]?.at ?? 1) - stop.at) / 2;
// A gradient whose stops all sit on one point still has a colour: theirs.
const weight = before + after || 1;
sum = { r: sum.r + rgb.r * weight, g: sum.g + rgb.g * weight, b: sum.b + rgb.b * weight };
total += weight;
});
return toHex(total > 0 ? { r: sum.r / total, g: sum.g / total, b: sum.b / total } : PAPER);
}
/** Whether a paint is exactly the cover fade the old `gradient` field can say. */
function legacyFade(paint: BookPaint): { color: string; gradient: 'up' | 'down' } | null {
if (paint.type !== 'linear' || (paint.angle !== 0 && paint.angle !== 180)) return null;
const stops = sortedStops(paint.stops);
const color = stops[0]?.color.toLowerCase();
if (!color || stops.length !== FADE_STOPS.length) return null;
const same = stops.every((s, i) => {
const want = FADE_STOPS[i];
return !!want && s.color.toLowerCase() === color && s.at === want.at && stopAlpha(s) === want.alpha;
});
return same ? { color, gradient: paint.angle === 0 ? 'up' : 'down' } : null;
}
const opaqueSolid = (paint: BookPaint): paint is Extract<BookPaint, { type: 'solid' }> =>
paint.type === 'solid' && stopAlpha(paint) >= 1;
/**
* A shape with this paint. Null takes the fill away. One opaque colour and the
* cover fade go into `fill` and `gradient` alone; anything else is kept as
* `paint`, with `fill` set to the colour that stands for it.
*/
export function withShapePaint<T extends BookShapeElement>(el: T, paint: BookPaint | null): T {
const { paint: _old, ...rest } = el;
if (paint === null) return { ...rest, fill: null, gradient: 'none' } as T;
if (opaqueSolid(paint)) return { ...rest, fill: lower(paint.color), gradient: 'none' } as T;
const fade = legacyFade(paint);
if (fade) return { ...rest, fill: fade.color, gradient: fade.gradient } as T;
return { ...rest, paint, fill: paintFallbackHex(paint), gradient: 'none' } as T;
}
/** A page with this paint. Null is the paper; one opaque colour is `background` alone, as before. */
export function withSpreadPaint<T extends BookSpread>(spread: T, paint: BookPaint | null): T {
const { backgroundPaint: _old, ...rest } = spread;
if (paint === null) return { ...rest, background: null } as T;
if (opaqueSolid(paint)) return { ...rest, background: lower(paint.color) } as T;
return { ...rest, backgroundPaint: paint, background: paintFallbackHex(paint) } as T;
}
/** Every colour a paint is made of, in the order it names them. */
export function paintColours(paint: BookPaint | null | undefined): string[] {
if (!paint) return [];
switch (paint.type) {
case 'solid':
return [lower(paint.color)];
case 'pattern':
return paint.bg ? [lower(paint.fg), lower(paint.bg)] : [lower(paint.fg)];
default:
return sortedStops(paint.stops).map((s) => lower(s.color));
}
}
const pct = (v: number) => `${Math.round(v * 100)}%`;
const deg = (v: number) => `${Math.round(v)} deg`;
const colourWord = (color: string, alpha: number | undefined) =>
alpha !== undefined && alpha < 1 ? `${lower(color)} at ${pct(alpha)}` : lower(color);
/** A paint in words, for describe_book_page: the kind, its colours and the numbers that place them. */
export function paintWords(paint: BookPaint): string {
switch (paint.type) {
case 'solid':
return colourWord(paint.color, paint.alpha);
case 'pattern':
return `${paint.pattern} pattern, ${lower(paint.fg)} on ${paint.bg ? lower(paint.bg) : 'nothing (see-through)'}, scale ${paint.scale}, turned ${deg(paint.angle)}`;
default: {
const stops = sortedStops(paint.stops)
.map((s) => `${colourWord(s.color, s.alpha)} at ${pct(s.at)}`)
.join(', ');
return paint.type === 'linear'
? `linear gradient towards ${deg(paint.angle)}: ${stops}`
: `radial gradient from ${pct(paint.cx)} ${pct(paint.cy)}, radius ${paint.r}: ${stops}`;
}
}
}
/** The colours an element is drawn in, as they are stored. */
function elementColours(el: BookElement): string[] {
switch (el.kind) {
case 'photo':
return [];
case 'shape':
return [...paintColours(shapePaint(el)), ...(el.stroke ? [lower(el.stroke)] : [])];
case 'text':
case 'icon':
return [lower(el.color)];
default:
return [lower(el.color), lower(el.accent)];
}
}
/**
* The book's own palette: the colours its pages and elements are drawn in,
* most used first, so a colour chosen once is one click away on every page.
*/
export function bookColours(doc: BookDocument | null | undefined, limit = 8): string[] {
const counts = new Map<string, number>();
const add = (hex: string) => counts.set(hex, (counts.get(hex) ?? 0) + 1);
for (const spread of doc?.spreads ?? []) {
paintColours(spreadPaint(spread)).forEach(add);
for (const el of spread.elements) elementColours(el).forEach(add);
}
return [...counts]
.filter(([hex]) => parseHex(hex))
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, limit)
.map(([hex]) => hex);
}
+5 -1
View File
@@ -33,6 +33,7 @@ export function bookLayoutFromSpread(spread: BookSpread, page: BookPageSetup, na
pageWidth: page.pageWidth,
pageHeight: page.pageHeight,
background: spread.background,
...(spread.backgroundPaint ? { backgroundPaint: spread.backgroundPaint } : {}),
// The design, not the pictures: every frame comes out empty.
elements: spread.elements.map((el) => (el.kind === 'photo' ? { ...el, photoId: null } : el)),
};
@@ -123,7 +124,9 @@ export function applySavedBookLayout(spread: BookSpread, layout: BookLayout, pag
const used = new Set(wordsFor.values());
const parked = [...photos.slice(pi), ...texts.filter((t) => !used.has(t))].slice(0, MAX_SPREAD_ELEMENTS);
return { ...spread, background: layout.background, elements: out, parked };
const { backgroundPaint: _old, ...rest } = spread;
const backgroundPaint = layout.backgroundPaint ? { backgroundPaint: layout.backgroundPaint } : {};
return { ...rest, background: layout.background, ...backgroundPaint, elements: out, parked };
}
/** A saved layout drawn as a spread on this book's page, for its card. Nothing is filled in. */
@@ -134,6 +137,7 @@ export function bookLayoutPreview(layout: BookLayout, page: BookPageSetup): Book
id: layout.id,
role: layout.role === 'inner' ? 'inner' : 'first',
background: layout.background,
...(layout.backgroundPaint ? { backgroundPaint: layout.backgroundPaint } : {}),
elements: layout.elements.map(
(el) =>
({
+3 -4
View File
@@ -20,8 +20,7 @@ import { describe, expect, it } from 'vitest';
*/
const frame = { x: 12, y: 18, w: 60, h: 40 };
/** A revision one past what this build reads. */
/** A revision newer than this build reads. */
const NEWER = BOOK_SCHEMA_REV + 1;
const doc = (over: Record<string, unknown> = {}) => ({
@@ -43,7 +42,6 @@ const doc = (over: Record<string, unknown> = {}) => ({
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', () => {
// Raised for the first time by the wider type: new families and weights an older reader drops.
expect(BOOK_SCHEMA_REV).toBeGreaterThanOrEqual(2);
expect(bookSchemaRev(doc())).toBe(1);
expect(bookSchemaRev(null)).toBe(1);
@@ -60,7 +58,7 @@ describe('the revision a document was written by', () => {
expect(odd.success && odd.data.spreads[0]?.elements).toHaveLength(1);
});
it('BKREV-003: a writer at revision 1 leaves a book as it was, with no revision written into it', () => {
it('BKREV-003: a writer of revision 1 leaves a book as it was, and this build stamps its own revision', () => {
const today = normalizeBookDocument(doc());
expect('schemaRev' in today).toBe(false);
expect(stampBookSchemaRev(today, 1)).toBe(today);
@@ -72,6 +70,7 @@ describe('the revision a document was written by', () => {
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: NEWER }))).toBe(true);
expect(isBookNewerThanReader(doc({ schemaRev: BOOK_SCHEMA_REV }))).toBe(false);
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.
+5
View File
@@ -28,6 +28,11 @@ import type { BookDocument } from './book.schema';
* saves only once that is above 1, so a book saved today stores exactly what
* it stored before this existed.
*/
/**
* The revisions so far:
*
* - 2: paint on shapes and pages (`paint`, `backgroundPaint`, book-paint.schema.ts).
*/
export const BOOK_SCHEMA_REV = 2;
/** The `code` a refused save carries in its body when the stored book is newer than the writer. */
+14 -9
View File
@@ -1,4 +1,4 @@
import { BOOK_FONTS_IDS, BOOK_WEIGHTS, type BookFontFamily } from './book-fonts.data';
import { bookPaintFieldSchema } from './book-paint.schema';
import { z } from 'zod';
@@ -159,15 +159,15 @@ export const BOOK_SHAPES = [
export type BookShapeId = (typeof BOOK_SHAPES)[number];
/**
* The families a book may be set in, and the weights type is set at. The
* catalogue is book-fonts.data.ts: which face each id names, its weights, its
* real italics and what it is for.
* The families a book may be set in.
*
* `sans`, `serif` and `display` are the three the format started with and are
* kept by name, so every document written so far still parses.
* kept by name, so every document written so far still parses — they now point
* at bundled faces rather than at whatever the rendering machine happened to
* have installed. See client/src/components/Studio/bookFonts.ts.
*/
export { BOOK_FONTS_IDS, type BookFontFamily };
const bookWeightSchema = z.literal(BOOK_WEIGHTS);
export const BOOK_FONTS_IDS = ['sans', 'inter', 'serif', 'garamond', 'playfair', 'display', 'bebas'] as const;
export type BookFontFamily = (typeof BOOK_FONTS_IDS)[number];
export const bookFrameSchema = z.object({
/** Millimetres from the left edge of the spread. Negative means bleed. */
@@ -232,7 +232,7 @@ export const bookTextElementSchema = z.object({
font: z.enum(BOOK_FONTS_IDS).default('sans'),
/** Points. */
size: z.number().min(4).max(200).default(11),
weight: bookWeightSchema.default(400),
weight: z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).default(400),
italic: z.boolean().default(false),
align: z.enum(['left', 'center', 'right', 'justify']).default('left'),
/** Multiple of the font size. */
@@ -299,6 +299,8 @@ export const bookShapeElementSchema = z.object({
* the picture keep going and still leaves the words readable.
*/
gradient: z.enum(['none', 'up', 'down']).default('none'),
/** A fill beyond one flat colour (book-paint.schema.ts); `fill` keeps the colour that stands for it. */
paint: bookPaintFieldSchema,
stroke: hex.nullable().default(null),
strokeWidth: mm.default(0),
/**
@@ -364,7 +366,7 @@ const typeset = {
* together, the way `textScale` moves their size. Without it the only way to
* lighten a stats block was to not use one.
*/
weight: bookWeightSchema.default(700),
weight: z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).default(700),
/** Set when the journey has moved on since these values were taken. */
stale: z.boolean().default(false),
};
@@ -827,6 +829,8 @@ export const bookSpreadSchema = z.object({
*/
role: z.enum(['cover', 'first', 'inner', 'last', 'back']).default('inner'),
background: hex.nullable().default(null),
/** A page painted beyond one flat colour; `background` keeps the colour that stands for it. */
backgroundPaint: bookPaintFieldSchema,
/** Back to front. */
elements: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]),
/**
@@ -931,6 +935,7 @@ export const bookLayoutSchema = z.object({
pageWidth: z.number().positive().max(2000),
pageHeight: z.number().positive().max(2000),
background: hex.nullable().default(null),
backgroundPaint: bookPaintFieldSchema,
elements: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]),
});
export type BookLayout = z.infer<typeof bookLayoutSchema>;
+11 -8
View File
@@ -1,6 +1,5 @@
import { photoDpi, spreadFindings } from './book-findings';
import { designFindings, sortBookFindings } from './book-findings-design';
import { bookFontRole, bookWeightWord, nearestBookWeight } from './book-fonts';
import { BOOK_GRIDS } from './book-grids';
import { BOOK_COVER_TEMPLATES, BOOK_TEMPLATES, type BookTemplate } from './book-layouts';
import type { BookPhotoInfo } from './book-ops-core';
@@ -11,6 +10,7 @@ import type {
BookPageDescription,
BookSpreadSummary,
} from './book-ops.schema';
import { paintWords } from './book-paint';
import {
bookPageSetupSchema,
type BookDocument,
@@ -86,9 +86,7 @@ function elementWords(el: BookElement, photos: readonly BookPhotoInfo[] | undefi
photos?.find((p) => p.photoId === el.photoId),
);
case 'text': {
const weight = nearestBookWeight(el.font, el.weight);
const set = `${el.font} (${bookFontRole(el.font)}) ${n(el.size)} pt${weight === 400 ? '' : ` ${bookWeightWord(weight)}`}`;
const style = `${set}${el.italic ? ' italic' : ''}, ${el.align}, ${el.color}`;
const style = `${el.font} ${n(el.size)} pt${el.weight >= 600 ? ' bold' : ''}${el.italic ? ' italic' : ''}, ${el.align}, ${el.color}`;
const bound = el.binding ? `, bound to ${el.binding.source}${el.overridden ? ' (overridden)' : ''}` : '';
return `${style}: "${head(el.text, 80)}"${bound}`;
}
@@ -102,8 +100,8 @@ function elementWords(el: BookElement, photos: readonly BookPhotoInfo[] | undefi
}
function shapeWords(el: Extract<BookElement, { kind: 'shape' }>): string {
const parts = [`${el.shape}, fill ${el.fill ?? 'none'}`];
if (el.gradient !== 'none') parts.push(`fading ${el.gradient}`);
const parts = el.paint ? [`${el.shape}, paint ${paintWords(el.paint)}`] : [`${el.shape}, fill ${el.fill ?? 'none'}`];
if (!el.paint && el.gradient !== 'none') parts.push(`fading ${el.gradient}`);
if (el.stroke) parts.push(`stroke ${el.stroke} ${n(el.strokeWidth)} mm ${el.strokeStyle}`);
if (el.radius > 0) parts.push(`radius ${n(el.radius)}`);
return parts.join(', ');
@@ -113,7 +111,7 @@ type TravelElement = Extract<BookElement, { kind: 'map' | 'stats' | 'countries'
/** What a travel element shows, and how it is set: its face, ink, accent and size. */
function travelWords(el: TravelElement): string {
const set = `${el.font} ${bookWeightWord(nearestBookWeight(el.font, el.weight))}, ink ${el.color}, accent ${el.accent}, type x${n(el.textScale)}`;
const set = `${el.font} ${el.weight >= 600 ? 'bold' : 'regular'}, ink ${el.color}, accent ${el.accent}, type x${n(el.textScale)}`;
const stale = el.stale ? '; its figures are out of date with the journey' : '';
return `${travelWhat(el)}; ${set}${stale}`;
}
@@ -173,7 +171,12 @@ function pageLine(doc: BookDocument, spread: BookSpread, index: number): string
? `${n(p.pageWidth)} x ${n(p.pageHeight)} mm`
: `${n(p.pageWidth * 2)} x ${n(p.pageHeight)} mm (two pages of ${n(p.pageWidth)} x ${n(p.pageHeight)}), fold at x ${n(p.pageWidth)}`;
const entry = spread.entryId != null ? ` From entry ${spread.entryId}.` : '';
return `Page ${index + 1} of ${doc.spreads.length} (${spread.id}): ${ROLE_WORDS[spread.role]}, ${size}; bleed ${n(p.bleed)}, safe ${n(p.safe)}. Background ${spread.background ?? 'paper (none set)'}.${entry}`;
return `Page ${index + 1} of ${doc.spreads.length} (${spread.id}): ${ROLE_WORDS[spread.role]}, ${size}; bleed ${n(p.bleed)}, safe ${n(p.safe)}. Background ${backgroundWords(spread)}.${entry}`;
}
function backgroundWords(spread: BookSpread): string {
if (spread.backgroundPaint) return `${paintWords(spread.backgroundPaint)} (reads as ${spread.background ?? 'paper'})`;
return spread.background ?? 'paper (none set)';
}
function findingLine(f: BookFinding): string {
+1 -4
View File
@@ -1,7 +1,6 @@
import { buildBook, distributeGallery } from '../auto/auto-book';
import type { AutoEntry, AutoInput, AutoPhoto } from '../auto/auto-types';
import { bookLevelFindings, sortBookFindings } from '../book-findings-design';
import { withRealWeight } from '../book-fonts';
import { bookElementId } from '../book-ids';
import type { BookPhotoInfo } from '../book-ops-core';
import type { BookFinding } from '../book-ops.schema';
@@ -253,9 +252,7 @@ export function designBook(
const repairCtx = { page, photos: input.photos, look, newId };
const designed = [...framing, ...inner.spreads, ...closing].map((d): DesignedSpread => {
const fixed = repairSpread(d.spread, repairCtx);
// Every face at a weight its family has: a travel element's bold in a one-weight poster face would be faked.
const spread = { ...fixed.spread, elements: fixed.spread.elements.map(withRealWeight) };
return { ...d, spread, findings: fixed.findings };
return { ...d, spread: fixed.spread, findings: fixed.findings };
});
const merged = mergeDesign(doc, designed, directives.keep);
+3 -6
View File
@@ -1,6 +1,5 @@
import type { AutoEntry, AutoPhoto } from '../auto/auto-types';
import { DESIGN_TEXT_HEADROOM, MIN_PRINT_DPI, textHeightNeeded } from '../book-findings';
import { nearestBookWeight } from '../book-fonts';
import type { BookPhotoInfo } from '../book-ops-core';
import { formatBookDate } from '../book-text';
import { bookBadgeValue, bookTravelBadge, type BookTravelContext } from '../book-travel';
@@ -159,7 +158,6 @@ type TextAt = Pick<BookFrame, 'x' | 'y' | 'w'>;
/** A text in the book's look, its frame as tall as its words need. */
export function textEl(c: ComposeCtx, at: TextAt, words: string, role: TextRole, opts: TextOpts = {}): BookTextElement {
const defaultColour = role === 'caption' || role === 'meta' ? c.look.muted : c.look.ink;
const font = role === 'heading' || role === 'sub' ? c.look.heading : c.look.body;
const el: BookTextElement = {
id: c.newId('t'),
kind: 'text',
@@ -168,10 +166,9 @@ export function textEl(c: ComposeCtx, at: TextAt, words: string, role: TextRole,
opacity: 1,
locked: false,
text: words,
font,
font: role === 'heading' || role === 'sub' ? c.look.heading : c.look.body,
size: opts.size ?? c.look.sizes[role],
// A family that lacks the role's weight is set at its nearest, never at a synthesised one.
weight: nearestBookWeight(font, WEIGHT[role]) as BookTextElement['weight'],
weight: WEIGHT[role],
italic: false,
align: opts.align ?? 'left',
leading: LEADING[role],
@@ -261,7 +258,7 @@ export function titleBlock(
if (value) {
const chip = frameOf(box.x, y, scaled(c, 24), scaled(c, 7));
const badge = bookTravelBadge({ id: c.newId('bd'), frame: chip, variant: 'day', value });
els.push({ ...badge, font: c.look.figures, accent: c.look.accent });
els.push({ ...badge, font: c.look.heading, accent: c.look.accent });
y += chip.h + 4;
}
}
+3 -3
View File
@@ -161,7 +161,7 @@ export function composeSummary(
const textSide = mapSide === 'left' ? 'right' : 'left';
const els: BookElement[] = [];
const map = bookTravelMap(c.travel, stats, { id: c.newId('mp'), frame: liveBox(c, mapSide), scope: 'journey' });
els.push({ ...map, style: c.look.mapStyle, font: c.look.figures, color: c.look.ink, accent: c.look.accent });
els.push({ ...map, style: c.look.mapStyle, font: c.look.heading, color: c.look.ink, accent: c.look.accent });
const head = label(c, words, textSide);
els.push(...head.els);
const metrics = metricsOf(c, stats);
@@ -169,7 +169,7 @@ export function composeSummary(
const box = liveBox(c, textSide);
const frame = frameOf(box.x, head.bottom, box.w, Math.min(box.h * 0.6, box.y + box.h - head.bottom));
const figures = bookTravelStats(stats, { id: c.newId('st'), frame, metrics });
els.push({ ...figures, font: c.look.figures, color: c.look.ink, accent: c.look.accent });
els.push({ ...figures, font: c.look.heading, color: c.look.ink, accent: c.look.accent });
}
return els;
}
@@ -179,7 +179,7 @@ export function composeCountries(c: ComposeCtx, words: string): BookElement[] {
const head = label(c, words, 'left');
const box = liveBox(c, 'right');
const countries = bookTravelCountries(c.travel, { id: c.newId('co'), frame: box });
return [...head.els, { ...countries, font: c.look.figures, color: c.look.ink, accent: c.look.accent }];
return [...head.els, { ...countries, font: c.look.heading, color: c.look.ink, accent: c.look.accent }];
}
/**
+2 -2
View File
@@ -146,7 +146,7 @@ function listUnder(c: ComposeCtx, entry: AutoEntry, foot: Foot): BookElement | n
if (foot.top + h > foot.bottom) return null;
const labels = { pros: c.travel.labels.pros, cons: c.travel.labels.cons };
const list = bookTravelList(labels, { id: c.newId('li'), frame: frameOf(foot.x, foot.top, foot.w, h), items });
return { ...list, font: c.look.figures, color: c.look.ink, accent: c.look.accent };
return { ...list, font: c.look.heading, color: c.look.ink, accent: c.look.accent };
}
/**
@@ -293,7 +293,7 @@ function chapterMap(c: ComposeCtx, entry: AutoEntry, box: BookFrame): BookElemen
const h = box.h * 0.3;
const frame = frameOf(box.x, box.y + box.h - h, box.w, h);
const map = bookTravelMap(c.travel, stats, { id: c.newId('mp'), frame, scope: { tripId } });
const look = { style: c.look.mapStyle, font: c.look.figures, color: c.look.ink, accent: c.look.accent };
const look = { style: c.look.mapStyle, font: c.look.heading, color: c.look.ink, accent: c.look.accent };
return { ...map, ...look, pinStyle: 'dot' };
}
+1 -28
View File
@@ -1,4 +1,3 @@
import { bookFontHasWeight } from '../book-fonts';
import { bookPageSetupSchema, type BookDocument, type BookTextElement } from '../book.schema';
import { designBook, type BookDesignInput } from './design-book';
import { bookDesignDirectivesSchema, type BookDesignDirectivesInput } from './design-directives.schema';
@@ -8,7 +7,7 @@ import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
// BOOKSTY-001 to BOOKSTY-007
// BOOKSTY-001 to BOOKSTY-004
/**
* The type scale of a designed book: `type.scale` reaches a poster's sizes,
@@ -79,29 +78,3 @@ describe('the cover title', () => {
expect((big?.frame.y ?? 0) + (big?.frame.h ?? 0)).toBeLessThanOrEqual(page.pageHeight);
});
});
describe('the typefaces by their roles', () => {
it('BOOKSTY-005: keeps a body face that sets running text, and falls back to the style for one that does not', () => {
expect(lookOf({ style: 'classic', type: { body: 'garamond' } }).body).toBe('garamond');
// A poster face and a script are a title's, never a story's.
expect(lookOf({ style: 'classic', type: { body: 'anton' } }).body).toBe('sans');
expect(lookOf({ style: 'editorial', type: { body: 'caveat' } }).body).toBe('garamond');
});
it('BOOKSTY-006: sets the travel elements in the heading face, unless that is a script or a mono face', () => {
expect(lookOf({ style: 'bold' }).figures).toBe('bebas');
const script = lookOf({ style: 'journal', type: { heading: 'caveat' } });
expect([script.heading, script.figures]).toEqual(['caveat', 'garamond']);
expect(lookOf({ style: 'classic', type: { heading: 'mono', body: 'inter' } }).figures).toBe('inter');
});
it('BOOKSTY-007: sets every text and travel element at a weight its family really has', () => {
const { doc } = designBook(null, rich(), { style: 'bold', type: { heading: 'anton' } });
const typeset = doc.spreads.flatMap((s) => s.elements.filter((e) => 'weight' in e && 'font' in e));
expect(typeset.length).toBeGreaterThan(0);
for (const el of typeset) {
if ('weight' in el && 'font' in el) expect(bookFontHasWeight(el.font, el.weight), el.id).toBe(true);
}
expect(typeset.some((e) => 'font' in e && e.font === 'anton')).toBe(true);
});
});
+3 -13
View File
@@ -1,5 +1,4 @@
import { accentFromPalettes, contrastRatio, parseHex, readPalette, readableInk, toHex } from '../book-colour';
import { bookFontCountsInMix, bookFontSuits } from '../book-fonts';
import type { BookPhotoInfo } from '../book-ops-core';
import type { BookFontFamily, BookPageSetup, BookPhotoElement } from '../book.schema';
import type { BookDesignDirectives, BookDesignStyle } from './design-directives.schema';
@@ -8,12 +7,8 @@ import type { BookDesignDirectives, BookDesignStyle } from './design-directives.
* The look of a designed book: its paper, ink, accent, the two typefaces,
* the type sizes and the page grid, from the style and the directives.
*
* At most two families per book, read by their roles (book-fonts.data.ts):
* the body face has to be one that sets running text, so a poster face or a
* script asked for as the body falls back to the style's own. Badges,
* figures, maps and lists take the heading face, unless that is a script or
* a mono face, which sets a title well and a column of figures badly; then
* they take the body face. Sizes are set for a 210 mm page and scale with the page
* 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 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
@@ -36,8 +31,6 @@ export interface BookDesignLook {
onScrim: string;
heading: BookFontFamily;
body: BookFontFamily;
/** The face of the travel elements: figures, marks, maps and lists. */
figures: BookFontFamily;
/** 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. */
@@ -179,8 +172,6 @@ export function designLook(
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;
const asked = directives.type?.body;
const body = asked && bookFontSuits(asked, 'body') ? asked : preset.body;
return {
style: directives.style,
paper: colours.paper,
@@ -191,8 +182,7 @@ export function designLook(
scrimOpacity: 0.6,
onScrim: '#ffffff',
heading,
body,
figures: bookFontCountsInMix(heading) ? heading : body,
body: directives.type?.body ?? preset.body,
sizes: {
display: size('heading', directives.type?.display ?? 1),
heading: size('heading'),
+2 -2
View File
@@ -58,7 +58,7 @@ import settings from './settings';
import share from './share';
import shared from './shared';
import storage from './storage';
import studioFonts from './studioFonts';
import studioPaint from './studioPaint';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -115,7 +115,7 @@ const locale = {
...todo,
...notif,
...journey,
...studioFonts,
...studioPaint,
...oauth,
...system_notice,
...transport,
+1 -4
View File
@@ -520,12 +520,8 @@ const journey: TranslationStrings = {
'journey.studio.rotation': 'التدوير',
'journey.studio.opacity': 'العتامة',
'journey.studio.fill': 'التعبئة',
'journey.studio.fillOn': 'معبّأ',
'journey.studio.stroke': 'الإطار',
'journey.studio.strokeWidth': 'السماكة',
'journey.studio.gradient': 'التدرّج',
'journey.studio.gradientDown': 'لأسفل',
'journey.studio.gradientUp': 'لأعلى',
'journey.studio.showIcons': 'الأيقونات',
'journey.studio.mapFit': 'الملاءمة',
'journey.studio.mapPadding': 'الهامش',
@@ -611,6 +607,7 @@ const journey: TranslationStrings = {
'journey.studio.tpl.mosaic': 'فسيفساء',
'journey.studio.tpl.bandQuote': 'كلمات في الوسط',
'journey.studio.tpl.staggerFour': 'أربع متدرجة',
'journey.studio.weightMissing': 'هذا الخط لا يوفر هذا الوزن',
'journey.studio.mapSource': 'مصدر الخريطة',
'journey.studio.mapSourceVector': 'المخططات',
'journey.studio.mapSourceRelief': 'تضاريس',
-31
View File
@@ -1,31 +0,0 @@
import type { TranslationStrings } from '../types';
/** The Studio's typeface picker and its weights. */
const studioFonts: TranslationStrings = {
'journey.studio.fonts.change': 'تغيير الخط',
'journey.studio.fonts.search': 'البحث عن خطوط',
'journey.studio.fonts.list': 'الخطوط',
'journey.studio.fonts.used': 'مستخدمة في هذا الكتاب',
'journey.studio.fonts.none': 'لا يوجد خط مطابق',
'journey.studio.fonts.range': 'من {from} إلى {to}',
'journey.studio.fonts.cat.sans': 'بدون تذييل',
'journey.studio.fonts.cat.serif': 'مذيّلة',
'journey.studio.fonts.cat.elegant': 'عناوين أنيقة',
'journey.studio.fonts.cat.condensed': 'ضيقة وملصقات',
'journey.studio.fonts.cat.retro': 'ثقيلة وريترو',
'journey.studio.fonts.cat.script': 'خط يد وزخرفي',
'journey.studio.fonts.cat.mono': 'ثابتة العرض',
'journey.studio.weight.100': 'رفيع',
'journey.studio.weight.200': 'خفيف جدًا',
'journey.studio.weight.300': 'خفيف',
'journey.studio.weight.400': 'عادي',
'journey.studio.weight.500': 'متوسط',
'journey.studio.weight.600': 'شبه عريض',
'journey.studio.weight.700': 'عريض',
'journey.studio.weight.800': 'عريض جدًا',
'journey.studio.weight.900': 'أسود',
'journey.studio.weightOnly': 'وزن واحد فقط',
'journey.studio.italicSlanted': 'مائل بالإمالة: لا يملك هذا الخط مائلًا خاصًا به',
};
export default studioFonts;
+49
View File
@@ -0,0 +1,49 @@
import type { TranslationStrings } from '../types';
/** TREK Studio: paint, gradients, patterns and the page background. */
const studioPaint: TranslationStrings = {
'journey.studio.paint.kind': 'نوع التعبئة',
'journey.studio.paint.none': 'بلا',
'journey.studio.paint.solid': 'لون واحد',
'journey.studio.paint.gradient': 'تدرج',
'journey.studio.paint.pattern': 'نقش',
'journey.studio.paint.linear': 'خطي',
'journey.studio.paint.radial': 'دائري',
'journey.studio.paint.reverse': 'اعكس التدرج',
'journey.studio.paint.addStopHint': 'انقر على الشريط لإضافة نقطة لون',
'journey.studio.paint.stopAt': 'نقطة عند {pos}%',
'journey.studio.paint.removeStop': 'أزل النقطة',
'journey.studio.paint.angle': 'الزاوية',
'journey.studio.paint.centre': 'المركز',
'journey.studio.paint.radius': 'نصف القطر',
'journey.studio.paint.ink': 'لون النقش',
'journey.studio.paint.ground': 'لون الخلفية',
'journey.studio.paint.seeThrough': 'شفاف',
'journey.studio.paint.scale': 'الحجم',
'journey.studio.paint.opacity': 'العتامة',
'journey.studio.paint.bookColours': 'ألوان هذا الكتاب',
'journey.studio.paint.photoColours': 'ألوان من الصور',
'journey.studio.paint.recentColours': 'ألوان حديثة',
'journey.studio.paint.background': 'خلفية الصفحة',
'journey.studio.paint.paper': 'الورق',
'journey.studio.paint.applyAll': 'طبّق على كل الصفحات',
'journey.studio.paint.appliedAll': 'طُبّق على كل الصفحات',
'journey.studio.paint.photoBackground': 'صورة كخلفية',
'journey.studio.paint.photoBackgroundHint':
'تضع الصورة على الصفحة كلها مع هامش القص، مقفلة في الخلف. اخترها مرة أخرى لإزالتها.',
'journey.studio.paint.removePhotoBackground': 'أزل صورة الخلفية',
'journey.studio.paint.noPhotos': 'أضف صورًا إلى الرحلة لتستخدم إحداها خلفية.',
'journey.studio.pattern.checker': 'مربعات',
'journey.studio.pattern.gingham': 'قماش كاروهات',
'journey.studio.pattern.stripes': 'خطوط',
'journey.studio.pattern.pinstripe': 'خطوط رفيعة',
'journey.studio.pattern.dots': 'نقاط',
'journey.studio.pattern.polka': 'دوائر منقطة',
'journey.studio.pattern.zigzag': 'متعرج',
'journey.studio.pattern.waves': 'أمواج',
'journey.studio.pattern.grid': 'شبكة',
'journey.studio.pattern.scales': 'حراشف',
'journey.studio.pattern.sunburst': 'أشعة الشمس',
'journey.studio.pattern.confetti': 'قصاصات ملونة',
};
export default studioPaint;
+2 -2
View File
@@ -58,7 +58,7 @@ import settings from './settings';
import share from './share';
import shared from './shared';
import storage from './storage';
import studioFonts from './studioFonts';
import studioPaint from './studioPaint';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -115,7 +115,7 @@ const locale = {
...todo,
...notif,
...journey,
...studioFonts,
...studioPaint,
...oauth,
...system_notice,
...transport,
+2 -4
View File
@@ -538,12 +538,8 @@ const journey: TranslationStrings = {
'journey.studio.rotation': 'Fırlanma',
'journey.studio.opacity': 'Şəffaflıq',
'journey.studio.fill': 'Dolğu',
'journey.studio.fillOn': 'Doldurulmuş',
'journey.studio.stroke': 'Kontur',
'journey.studio.strokeWidth': 'Qalınlıq',
'journey.studio.gradient': 'Keçid',
'journey.studio.gradientDown': 'Aşağıya',
'journey.studio.gradientUp': 'Yuxarıya',
'journey.studio.showIcons': 'İkonlar',
'journey.studio.mapFit': 'Sığdırma',
@@ -639,6 +635,8 @@ const journey: TranslationStrings = {
'journey.studio.tpl.bandQuote': 'Arada sitat',
'journey.studio.tpl.staggerFour': 'Pilləli dördlük',
'journey.studio.weightMissing': 'Bu şriftdə həmin qalınlıq mövcud deyil',
'journey.studio.mapSource': 'Xəritə mənbəyi',
'journey.studio.mapSourceVector': 'Konturlar',
'journey.studio.mapSourceRelief': 'Relyef',
-31
View File
@@ -1,31 +0,0 @@
import type { TranslationStrings } from '../types';
/** The Studio's typeface picker and its weights. */
const studioFonts: TranslationStrings = {
'journey.studio.fonts.change': 'Şrifti dəyiş',
'journey.studio.fonts.search': 'Şrift axtar',
'journey.studio.fonts.list': 'Şriftlər',
'journey.studio.fonts.used': 'Bu kitabda istifadə olunanlar',
'journey.studio.fonts.none': 'Uyğun şrift yoxdur',
'journey.studio.fonts.range': '{from} ilə {to} arası',
'journey.studio.fonts.cat.sans': 'Dırnaqsız',
'journey.studio.fonts.cat.serif': 'Dırnaqlı',
'journey.studio.fonts.cat.elegant': 'Zərif başlıq',
'journey.studio.fonts.cat.condensed': 'Dar və afişa',
'journey.studio.fonts.cat.retro': 'Qalın və retro',
'journey.studio.fonts.cat.script': 'Yazı və əl yazısı',
'journey.studio.fonts.cat.mono': 'Sabit enli',
'journey.studio.weight.100': 'Nazik',
'journey.studio.weight.200': 'Çox yüngül',
'journey.studio.weight.300': 'Yüngül',
'journey.studio.weight.400': 'Adi',
'journey.studio.weight.500': 'Orta',
'journey.studio.weight.600': 'Yarımqalın',
'journey.studio.weight.700': 'Qalın',
'journey.studio.weight.800': 'Çox qalın',
'journey.studio.weight.900': 'Qara',
'journey.studio.weightOnly': 'Yalnız bir qalınlıq',
'journey.studio.italicSlanted': 'Kursiv, maili: bu şriftin öz kursivi yoxdur',
};
export default studioFonts;
+49
View File
@@ -0,0 +1,49 @@
import type { TranslationStrings } from '../types';
/** TREK Studio: paint, gradients, patterns and the page background. */
const studioPaint: TranslationStrings = {
'journey.studio.paint.kind': 'Doldurma növü',
'journey.studio.paint.none': 'Yoxdur',
'journey.studio.paint.solid': 'Bərk',
'journey.studio.paint.gradient': 'Keçid',
'journey.studio.paint.pattern': 'Naxış',
'journey.studio.paint.linear': 'Xətti',
'journey.studio.paint.radial': 'Radial', // same-as-en
'journey.studio.paint.reverse': 'Keçidi tərsinə çevir',
'journey.studio.paint.addStopHint': 'Rəng nöqtəsi əlavə etmək üçün zolağa klikləyin',
'journey.studio.paint.stopAt': '{pos}% nöqtəsində',
'journey.studio.paint.removeStop': 'Nöqtəni sil',
'journey.studio.paint.angle': 'Bucaq',
'journey.studio.paint.centre': 'Mərkəz',
'journey.studio.paint.radius': 'Radius', // same-as-en
'journey.studio.paint.ink': 'Naxışın rəngi',
'journey.studio.paint.ground': 'Fon rəngi',
'journey.studio.paint.seeThrough': 'Şəffaf',
'journey.studio.paint.scale': 'Ölçü',
'journey.studio.paint.opacity': 'Qeyri-şəffaflıq',
'journey.studio.paint.bookColours': 'Bu kitabdakı rənglər',
'journey.studio.paint.photoColours': 'Fotolardan rənglər',
'journey.studio.paint.recentColours': 'Son rənglər',
'journey.studio.paint.background': 'Səhifə fonu',
'journey.studio.paint.paper': 'Kağız',
'journey.studio.paint.applyAll': 'Bütün səhifələrə tətbiq et',
'journey.studio.paint.appliedAll': 'Bütün səhifələrə tətbiq edildi',
'journey.studio.paint.photoBackground': 'Fon kimi foto',
'journey.studio.paint.photoBackgroundHint':
'Fotonu kəsim payı daxil olmaqla bütün səhifəyə yayır, arxada kilidli saxlayır. Götürmək üçün onu yenidən seçin.',
'journey.studio.paint.removePhotoBackground': 'Fon fotosunu sil',
'journey.studio.paint.noPhotos': 'Birini fon kimi istifadə etmək üçün səyahətə foto əlavə edin.',
'journey.studio.pattern.checker': 'Şahmat',
'journey.studio.pattern.gingham': 'Damalı',
'journey.studio.pattern.stripes': 'Zolaqlar',
'journey.studio.pattern.pinstripe': 'İncə zolaqlar',
'journey.studio.pattern.dots': 'Nöqtələr',
'journey.studio.pattern.polka': 'Xallar',
'journey.studio.pattern.zigzag': 'Ziqzaq',
'journey.studio.pattern.waves': 'Dalğalar',
'journey.studio.pattern.grid': 'Tor',
'journey.studio.pattern.scales': 'Pulcuqlar',
'journey.studio.pattern.sunburst': 'Şüalar',
'journey.studio.pattern.confetti': 'Konfeti',
};
export default studioPaint;

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