mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1be997f7d8 | |||
| 5a8ff16afa | |||
| 4435dc32b8 | |||
| f5a33e0c1e | |||
| a8326b6f5a | |||
| 1e999d968a | |||
| d0f74c9f02 | |||
| f14f1e41b9 | |||
| 9138e722bb | |||
| 9695e269e3 | |||
| 82bdf62c7b | |||
| 8893e3afe0 | |||
| cd1e7d8fa8 | |||
| 059e72df69 | |||
| dc519b3ea7 | |||
| ad4074f107 | |||
| 0534d7b3a7 | |||
| a0554c2346 | |||
| bc5af23896 | |||
| 92be8a7ca5 | |||
| 44ea1ea0ba | |||
| 3bee600096 | |||
| 5b4030f86b | |||
| e2c00ba18b | |||
| 1050029842 | |||
| 64edf899ac | |||
| 0a9075af73 | |||
| c3a5383f5e | |||
| e96813edcf | |||
| 5e36e38b72 |
@@ -1300,7 +1300,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.locator('.st-back'),
|
||||
target: p => p.locator('.st-head-close'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 385,
|
||||
"@typescript-eslint/no-non-null-assertion": 384,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"no-console": 88,
|
||||
"complexity": 222,
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-non-null-assertion": 1931,
|
||||
"@typescript-eslint/no-non-null-assertion": 1929,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -146,6 +146,13 @@ export const DYNAMIC_ALLOWED = [
|
||||
'id is a sticker id of the library in @trek/shared/stickers; stickerLibrary.test.ts resolves the key of every ' +
|
||||
'sticker the library holds against en',
|
||||
},
|
||||
{
|
||||
file: 'components/Studio/shapes.ts',
|
||||
template: 'journey.studio.shapeName.${shape}',
|
||||
because:
|
||||
'shapeName takes a BookShapeId; shared/src/book/book-shape-catalogue.spec.ts holds an en name for every ' +
|
||||
'shape of BOOK_SHAPE_GROUPS',
|
||||
},
|
||||
];
|
||||
|
||||
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2953,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2936,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1157,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||
@@ -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": 2009
|
||||
"src/styles/studio.css": 1936
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-TZHOOK-001 to -007: the world clocks both dashboards share.
|
||||
// FE-COMP-TZHOOK-001 to -009: the world clocks both dashboards share.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -97,4 +97,27 @@ describe('useDashboardTimezones', () => {
|
||||
expect(localStorage.getItem('trek_dashboard_tz')).toBeNull();
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// #2694: Chrome on Windows names no zone for a system zone it cannot map.
|
||||
it('FE-COMP-TZHOOK-008: a browser that names no zone still gets the default clocks', () => {
|
||||
const original = Intl.DateTimeFormat.prototype.resolvedOptions;
|
||||
const spy = vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function (
|
||||
this: Intl.DateTimeFormat
|
||||
) {
|
||||
return { ...original.call(this), timeZone: undefined as unknown as string };
|
||||
});
|
||||
try {
|
||||
const { result } = renderHook(() => useDashboardTimezones('en'));
|
||||
expect(result.current.zones).toEqual(['Europe/London', 'Asia/Tokyo']);
|
||||
expect(() => result.current.zones.map((tz) => result.current.timeIn(tz))).not.toThrow();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-TZHOOK-009: a stored zone this browser does not know is left out instead of crashing the clocks', () => {
|
||||
seedSettings(['Europe/Berlin', 'Mars/Olympus_Mons', '']);
|
||||
const { result } = renderHook(() => useDashboardTimezones('en'));
|
||||
expect(result.current.zones).toEqual(['Europe/Berlin']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { browserTimeZone, isValidTimeZone } from '../../utils/localDate';
|
||||
|
||||
const DEFAULT_ZONES = ['Europe/London', 'Asia/Tokyo'];
|
||||
|
||||
@@ -38,13 +39,15 @@ export function shortZone(tz: string): string {
|
||||
* clocks tick every 30 seconds, `adding` toggles the zone picker.
|
||||
*/
|
||||
export function useDashboardTimezones(locale: string) {
|
||||
const home = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const home = browserTimeZone();
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const isLoaded = useSettingsStore((s) => s.isLoaded);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const stored = useSettingsStore((s) => s.settings.dashboard_timezones);
|
||||
// Unset (never chosen) falls back to home + defaults; an explicit list is honoured.
|
||||
const zones = stored ?? [home, ...DEFAULT_ZONES];
|
||||
// A browser that names no zone (#2694) or a zone this browser does not know
|
||||
// would throw while rendering a clock, so such entries are left out.
|
||||
const zones = (stored ?? [home, ...DEFAULT_ZONES]).filter(isValidTimeZone);
|
||||
const setZones = (next: string[]) => {
|
||||
updateSetting('dashboard_timezones', next).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -397,6 +397,19 @@ describe('downloadTripPDF', () => {
|
||||
expect(iframe!.srcdoc).toContain('€')
|
||||
})
|
||||
|
||||
// #2692: a place's description and notes print as the planner shows them, not as raw markdown.
|
||||
it('FE-COMP-TRIPPDF-016c: renders markdown in a place description, sanitized', async () => {
|
||||
const description = ['**Colosseum** tickets', '', '| Gate | Time |', '| --- | --- |', '| A | 9:00 |', '', '<script>alert(1)</script>'].join('\n')
|
||||
const place = { ...placeWithDetails, description }
|
||||
const md = { ...richArgs, places: [place], assignments: { '10': [{ ...assignmentForDay, place }] } as typeof richArgs.assignments }
|
||||
await downloadTripPDF(md)
|
||||
const html = getIframe()?.srcdoc ?? ''
|
||||
expect(html).toContain('<strong>Colosseum</strong>')
|
||||
expect(html).toContain('<td>9:00</td>')
|
||||
expect(html).not.toContain('**Colosseum**')
|
||||
expect(html).not.toContain('<script>alert(1)</script>')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-016b: formats price chip and totals in the trip currency, not EUR', async () => {
|
||||
const usdArgs = {
|
||||
...richArgs,
|
||||
@@ -796,7 +809,7 @@ describe('downloadTripPDF remaining branches', () => {
|
||||
}]))
|
||||
const html = srcdoc()
|
||||
|
||||
expect(html).toContain('ICE 599 · Gl. 7 · Seat 21A · FRA → BER')
|
||||
expect(html).toContain('ICE 599 · reservations.meta.platform 7 · reservations.meta.seat 21A · FRA → BER')
|
||||
})
|
||||
|
||||
it('FE-W5PDF-006: a multi-leg train renders one line per leg', async () => {
|
||||
@@ -811,7 +824,7 @@ describe('downloadTripPDF remaining branches', () => {
|
||||
}]))
|
||||
const html = srcdoc()
|
||||
|
||||
expect(html).toContain('IC 1 · Gl. 3 · BER → MUC')
|
||||
expect(html).toContain('IC 1 · reservations.meta.platform 3 · BER → MUC')
|
||||
expect(html).toContain('EC 2 · MUC → ZRH')
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { fetchExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/flightLegs'
|
||||
import { isServiceStopType } from '../Roadtrip/roadtripModel'
|
||||
import { onlyMyPlan } from './pdfScope'
|
||||
import { createPdfMarkdown, PDF_MARKDOWN_CSS } from './pdfMarkdown'
|
||||
|
||||
/**
|
||||
* Every day starts a new page by default. On a trip of short days that prints
|
||||
@@ -231,6 +232,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
let hasTransportNotes = false
|
||||
const loc = _locale || undefined
|
||||
const tr = _t || (k => k)
|
||||
const markdown = createPdfMarkdown()
|
||||
// The store read is the fallback, not the source: a caller that forgets the
|
||||
// prop still prints the reader's own format instead of silently reverting.
|
||||
const is12h = (_timeFormat || useSettingsStore.getState().settings.time_format || '24h') === '12h'
|
||||
@@ -439,8 +441,8 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
const source = (usesStationRoute(r.type) ? getTrainLegs(r) : getFlightLegs(r))[l.index]
|
||||
subtitleLines = [[
|
||||
l.airline, l.flight_number, l.train_number,
|
||||
l.platform ? `Gl. ${l.platform}` : '',
|
||||
l.seat ? `Seat ${l.seat}` : '',
|
||||
l.platform ? `${tr('reservations.meta.platform')} ${l.platform}` : '',
|
||||
l.seat ? `${tr('reservations.meta.seat')} ${l.seat}` : '',
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
|
||||
source?.confirmation_number,
|
||||
].filter(Boolean).join(' · ')].filter(Boolean)
|
||||
@@ -471,7 +473,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
// Multi-leg: one line per leg so every train number + segment route shows,
|
||||
// with the segment's own booking code where it has one (#1943).
|
||||
subtitleLines = legs.map(l =>
|
||||
[l.train_number, l.platform ? `Gl. ${l.platform}` : '',
|
||||
[l.train_number, l.platform ? `${tr('reservations.meta.platform')} ${l.platform}` : '',
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
|
||||
l.confirmation_number]
|
||||
.filter(Boolean).join(' · '))
|
||||
@@ -479,7 +481,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
} else {
|
||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
||||
const route = stops.length >= 2 ? stops.join(' → ') : ''
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
subtitle = [meta.train_number, meta.platform ? `${tr('reservations.meta.platform')} ${meta.platform}` : '', meta.seat ? `${tr('reservations.meta.seat')} ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'car') {
|
||||
@@ -587,10 +589,10 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
</div>
|
||||
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
|
||||
${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''}
|
||||
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
|
||||
${place.description ? markdown.row(place.description) : ''}
|
||||
${chips ? `<div class="chips">${chips}</div>` : ''}
|
||||
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
||||
${item.data.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(item.data.notes)}</span></div>` : ''}
|
||||
${place.notes ? markdown.row(place.notes) : ''}
|
||||
${item.data.notes ? markdown.row(item.data.notes) : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
@@ -836,7 +838,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
.info-spacer { width: 13px; flex-shrink: 0; }
|
||||
.info-text { font-size: 9px; color: #64748b; line-height: 1.5; }
|
||||
.info-text.muted { color: #94a3b8; }
|
||||
.info-text.italic { font-style: italic; }
|
||||
${PDF_MARKDOWN_CSS}
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 21px; margin-top: 4px; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 3px; font-size: 8px; font-weight: 600; background: #f1f5f9; color: #374151; border-radius: 99px; padding: 2px 7px; white-space: nowrap; }
|
||||
@@ -930,6 +932,7 @@ ${tripMapHtml}
|
||||
${daysHtml}
|
||||
${pluginSectionsHtml}
|
||||
</body></html>`
|
||||
markdown.dispose()
|
||||
|
||||
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
|
||||
const overlay = document.createElement('div')
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createMarkdownHtml } from '../shared/markdownHtml';
|
||||
|
||||
/**
|
||||
* A place's description and notes are markdown in the app, so the export
|
||||
* renders them the same way instead of printing the raw syntax (#2692): one
|
||||
* info row per text, the HTML from the app's own sanitized pipeline.
|
||||
*/
|
||||
export function createPdfMarkdown(): { row: (text: string) => string; dispose: () => void } {
|
||||
const markdown = createMarkdownHtml();
|
||||
return {
|
||||
row: (text) =>
|
||||
`<div class="info-row"><span class="info-spacer"></span><div class="info-text muted md">${markdown.html(text)}</div></div>`,
|
||||
dispose: markdown.dispose,
|
||||
};
|
||||
}
|
||||
|
||||
/** The markdown's own spacing, lists, tables and links at the export's small size. */
|
||||
export const PDF_MARKDOWN_CSS = `
|
||||
.info-text.md { min-width: 0; overflow-wrap: anywhere; }
|
||||
.md > :first-child { margin-top: 0; }
|
||||
.md > :last-child { margin-bottom: 0; }
|
||||
.md p, .md ul, .md ol, .md table, .md blockquote, .md pre { margin: 0 0 4px; }
|
||||
.md ul, .md ol { padding-inline-start: 14px; }
|
||||
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 { font-size: 1.1em; font-weight: 700; margin: 4px 0 2px; }
|
||||
.md table { border-collapse: collapse; }
|
||||
.md th, .md td { border: 1px solid #e2e8f0; padding: 2px 5px; text-align: start; }
|
||||
.md code { font-family: ui-monospace, monospace; font-size: 8.5px; }
|
||||
.md a { color: inherit; text-decoration: underline; }
|
||||
.md blockquote { border-inline-start: 2px solid #e2e8f0; padding-inline-start: 6px; }
|
||||
`;
|
||||
@@ -43,6 +43,7 @@ import './dayPlanRows.css'
|
||||
|
||||
import { TypeTile } from './bookings/bookingParts'
|
||||
import { typeInfo } from './bookings/bookingsModel'
|
||||
import { transportSubtitle } from './transportDetailModel'
|
||||
import { dayHeadStays } from './dayHeadStays'
|
||||
import { getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
||||
import {
|
||||
@@ -2166,24 +2167,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
let meta: any = {}
|
||||
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
|
||||
|
||||
// Subtitle aus Metadaten zusammensetzen
|
||||
let subtitle = ''
|
||||
if (res.__leg) {
|
||||
// One leg of a multi-leg flight/train — show this segment's own detail.
|
||||
const parts = res.type === 'train'
|
||||
? [res.__leg.train_number, res.__leg.platform ? `${t('reservations.meta.platform')} ${res.__leg.platform}` : '', res.__leg.seat ? `${t('reservations.meta.seat')} ${res.__leg.seat}` : ''].filter(Boolean)
|
||||
: [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
|
||||
if (res.__leg.from || res.__leg.to)
|
||||
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
|
||||
subtitle = parts.join(', ')
|
||||
} else if (res.type === 'flight') {
|
||||
const parts = [meta.airline, meta.flight_number].filter(Boolean)
|
||||
if (meta.departure_airport || meta.arrival_airport)
|
||||
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
||||
subtitle = parts.join(', ')
|
||||
} else if (res.type === 'train') {
|
||||
subtitle = [meta.train_number, meta.platform ? `${t('reservations.meta.platform')} ${meta.platform}` : '', meta.seat ? `${t('reservations.meta.seat')} ${meta.seat}` : ''].filter(Boolean).join(', ')
|
||||
}
|
||||
// A leg of a multi-leg flight or train shows its own detail (#2701).
|
||||
const subtitle = transportSubtitle(res, t) ?? ''
|
||||
|
||||
// A transit journey (#1065) renders its itinerary inline —
|
||||
// line badges in their colors instead of a plain subtitle,
|
||||
|
||||
@@ -23,3 +23,65 @@ export function bookingDayLabel(date: string, locale: string): string {
|
||||
export function segmentCodeLabel(leg: { from?: string | null; to?: string | null }, t: Translate): string {
|
||||
return [leg.from, leg.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode');
|
||||
}
|
||||
|
||||
/** The per-leg detail the day plan's leg rows carry (utils/dayMerge's `__leg`). */
|
||||
export interface TransportLegDetail {
|
||||
index: number;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
airline?: string | null;
|
||||
flight_number?: string | null;
|
||||
train_number?: string | null;
|
||||
platform?: string | null;
|
||||
seat?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subtitle of a transport row in the day plan, desktop and phone alike. A
|
||||
* leg of a multi-leg train or flight names its own train or flight, platform
|
||||
* and seat (#2701); a whole booking reads them off its metadata. Null for the
|
||||
* other transport types, which each view labels its own way.
|
||||
*/
|
||||
export function transportSubtitle(
|
||||
res: { type: string; metadata?: unknown; __leg?: TransportLegDetail | null },
|
||||
t: Translate
|
||||
): string | null {
|
||||
const meta = parseMeta(res.metadata);
|
||||
const leg = res.__leg;
|
||||
const trainParts = (d: { train_number?: unknown; platform?: unknown; seat?: unknown }) => [
|
||||
str(d.train_number),
|
||||
str(d.platform) ? `${t('reservations.meta.platform')} ${str(d.platform)}` : '',
|
||||
str(d.seat) ? `${t('reservations.meta.seat')} ${str(d.seat)}` : '',
|
||||
];
|
||||
if (leg) {
|
||||
const parts = res.type === 'train' ? trainParts(leg) : [str(leg.airline), str(leg.flight_number)];
|
||||
if (leg.from || leg.to) parts.push([leg.from, leg.to].filter(Boolean).join(' → '));
|
||||
return parts.filter(Boolean).join(', ');
|
||||
}
|
||||
if (res.type === 'flight') {
|
||||
const parts = [str(meta.airline), str(meta.flight_number)];
|
||||
if (meta.departure_airport || meta.arrival_airport) {
|
||||
parts.push([str(meta.departure_airport), str(meta.arrival_airport)].filter(Boolean).join(' → '));
|
||||
}
|
||||
return parts.filter(Boolean).join(', ');
|
||||
}
|
||||
if (res.type === 'train') return trainParts(meta).filter(Boolean).join(', ');
|
||||
return null;
|
||||
}
|
||||
|
||||
function str(value: unknown): string {
|
||||
return typeof value === 'string' || typeof value === 'number' ? String(value) : '';
|
||||
}
|
||||
|
||||
function parseMeta(metadata: unknown): Record<string, unknown> {
|
||||
let meta = metadata;
|
||||
// An earlier bug could double-encode the metadata; reading it twice heals that.
|
||||
for (let i = 0; i < 2 && typeof meta === 'string'; i++) {
|
||||
try {
|
||||
meta = JSON.parse(meta || '{}');
|
||||
} catch {
|
||||
meta = {};
|
||||
}
|
||||
}
|
||||
return meta && typeof meta === 'object' ? (meta as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { browserTimeZone } from '../../utils/localDate'
|
||||
|
||||
// The design-token contract handed to plugins (#4 richer context): non-secret CSS
|
||||
// values, resolved for the CURRENT theme, so a plugin can match TREK exactly (and
|
||||
@@ -379,7 +380,7 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
|
||||
timeFormat: settings.time_format,
|
||||
distanceUnit: settings.distance_unit,
|
||||
temperatureUnit: settings.temperature_unit,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
timezone: browserTimeZone() ?? 'UTC',
|
||||
blurBookingCodes: Boolean(settings.blur_booking_codes),
|
||||
},
|
||||
tokens: readThemeTokens(),
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
Ungroup,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-panels.css';
|
||||
@@ -118,7 +117,8 @@ export function StudioArrangeSection({
|
||||
const copyStyle = useStudioStore((s) => s.copyStyle);
|
||||
const pasteStyle = useStudioStore((s) => s.pasteStyle);
|
||||
const keys = useStudioShortcut();
|
||||
const [chosenTo, setChosenTo] = useState<BookAlignTo | null>(null);
|
||||
const chosenTo = useStudioStore((s) => s.alignTo);
|
||||
const setChosenTo = useStudioStore((s) => s.setAlignTo);
|
||||
|
||||
if (!spread || !selection.length) return null;
|
||||
const sel = spread.elements.filter((e) => selection.includes(e.id));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useId, useState } from 'react'
|
||||
import { Frame, Minus, Quote, Shapes, Spline, Sparkles, Square, Sticker, Type, type LucideIcon } from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS } from './shapes'
|
||||
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS, shapeName } from './shapes'
|
||||
import {
|
||||
BOOK_GRIDS as GRIDS, MAX_SPREAD_ELEMENTS, bookElementId, bookShapeSize, bookGridElements as gridElements,
|
||||
defaultBookGridBox as defaultGridBox,
|
||||
@@ -195,7 +195,11 @@ function useElementAdders(page: BookPageSetup) {
|
||||
const els = gridElements(grid, defaultGridBox(page, single))
|
||||
// The page's own cap, as a duplicate keeps it: frames that would make the book unsaveable are not added.
|
||||
const on = useStudioStore.getState().doc?.spreads[active]
|
||||
if (!on || on.elements.length + els.length > MAX_SPREAD_ELEMENTS) return
|
||||
if (!on) return
|
||||
if (on.elements.length + els.length > MAX_SPREAD_ELEMENTS) {
|
||||
useStudioStore.getState().refuseFull()
|
||||
return
|
||||
}
|
||||
commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })),
|
||||
@@ -276,7 +280,8 @@ function ShapesTab({ addShape, t }: { addShape: Adders['addShape']; t: (k: strin
|
||||
key={shape}
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => addShape(shape)}
|
||||
aria-label={shape}
|
||||
title={shapeName(shape, t)}
|
||||
aria-label={shapeName(shape, t)}
|
||||
>
|
||||
<ShapeGlyph shape={shape} />
|
||||
</button>
|
||||
@@ -315,7 +320,8 @@ function FramesTab({ adders, t }: { adders: Adders; t: (k: string) => string })
|
||||
key={shape}
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => adders.addFrame(shape === 'rect' ? null : shape)}
|
||||
aria-label={shape}
|
||||
title={shapeName(shape, t)}
|
||||
aria-label={shapeName(shape, t)}
|
||||
>
|
||||
<ShapeGlyph shape={shape} photo />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { StudioHeader } from './StudioHeader';
|
||||
import type { Studio } from './StudioShell';
|
||||
import type { StudioAssistant } from './useStudioAssistant';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-HEAD-001 to FE-STUDIO-HEAD-007: the Studio's head band, with the
|
||||
* hook's answer stubbed to what the band reads.
|
||||
*/
|
||||
|
||||
function studio(over: Partial<Studio> = {}): Studio {
|
||||
return {
|
||||
t: (key: string) => key,
|
||||
journey: { title: 'Japan 2026' },
|
||||
coverUrl: '/uploads/cover.jpg',
|
||||
saveState: { status: 'idle' },
|
||||
peers: [],
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
canEdit: true,
|
||||
activeSpread: 0,
|
||||
doc: { spreads: [] },
|
||||
preset: 'square-210',
|
||||
page: { pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
|
||||
canRelayoutSpread: true,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
close: vi.fn(),
|
||||
setPreset: vi.fn(),
|
||||
setPageSize: vi.fn(),
|
||||
setPageEdge: vi.fn(),
|
||||
relayoutCurrentSpread: vi.fn(),
|
||||
relayoutBook: vi.fn(),
|
||||
loadDoc: vi.fn(),
|
||||
acceptTheirs: vi.fn(),
|
||||
keepMine: vi.fn(),
|
||||
saveNow: vi.fn(),
|
||||
...over,
|
||||
} as unknown as Studio;
|
||||
}
|
||||
|
||||
const noAssistant = { available: false } as unknown as StudioAssistant;
|
||||
|
||||
function head(s = studio(), onExport = vi.fn()) {
|
||||
render(<StudioHeader s={s} onExport={onExport} assistant={noAssistant} />);
|
||||
return { s, onExport };
|
||||
}
|
||||
|
||||
describe('StudioHeader', () => {
|
||||
it("FE-STUDIO-HEAD-001: names the journey under the Studio's mark, with its cover as the tile", () => {
|
||||
head();
|
||||
expect(screen.getByRole('heading', { name: 'Japan 2026' })).toBeInTheDocument();
|
||||
expect(screen.getByText('journey.studio.beta')).toBeInTheDocument();
|
||||
expect(document.querySelector('.st-head-tile img')?.getAttribute('src')).toBe('/uploads/cover.jpg');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-002: the round button closes the Studio, export hands over', () => {
|
||||
const { s, onExport } = head();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.backToJourney' }));
|
||||
expect(s.close).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.studio.export/ }));
|
||||
expect(onExport).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-003: undo runs, redo waits for something to redo, paste for something copied', () => {
|
||||
const { s } = head();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.undo' }));
|
||||
expect(s.undo).toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.redo' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.paste' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-004: the format chip names the format and opens the list, a pick sets it', () => {
|
||||
const { s } = head();
|
||||
const chip = screen.getByRole('button', { name: 'journey.studio.format' });
|
||||
expect(chip).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(chip);
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options.length).toBeGreaterThan(1);
|
||||
fireEvent.click(options[options.length - 1]);
|
||||
expect(s.setPreset).toHaveBeenCalled();
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-005: a custom format reads its size, and Escape or a press outside closes the list', () => {
|
||||
head(
|
||||
studio({ preset: 'custom', page: { pageWidth: 180.4, pageHeight: 240, bleed: 3, safe: 5 } } as Partial<Studio>)
|
||||
);
|
||||
const chip = screen.getByRole('button', { name: 'journey.studio.format' });
|
||||
expect(chip).toHaveTextContent('180 × 240 mm');
|
||||
fireEvent.click(chip);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
fireEvent.click(chip);
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-006: auto layout offers this spread and the whole book', () => {
|
||||
const { s } = head();
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.studio.autoLayout/ }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^journey\.studio\.relayoutSpread/ }));
|
||||
expect(s.relayoutCurrentSpread).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.studio.autoLayout/ }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^journey\.studio\.relayoutBook/ }));
|
||||
expect(s.relayoutBook).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-007: with something copied, paste puts it on the page on screen', () => {
|
||||
const paste = vi.fn();
|
||||
useStudioStore.setState({ clipboard: { elements: [] } as never, paste } as never);
|
||||
head(studio({ activeSpread: 2 } as Partial<Studio>));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.paste' }));
|
||||
expect(paste).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import { Check, ChevronDown, ClipboardPaste, Download, Redo2, Sparkles, Undo2, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-head.css';
|
||||
import HelpButton from '../Help/HelpButton';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets';
|
||||
import { PeerBadges } from './PeerBadges';
|
||||
import { SaveIndicator } from './SaveIndicator';
|
||||
import { StudioAssistantToggle } from './StudioAssistantPanel';
|
||||
import type { Studio } from './StudioShell';
|
||||
import { StudioWordmark } from './StudioWordmark';
|
||||
import { TrimField } from './TrimField';
|
||||
import type { StudioAssistant } from './useStudioAssistant';
|
||||
|
||||
/**
|
||||
* The Studio's head band, in the shape every TREK dialog has: a raised tile on
|
||||
* the left, a small line above the title, the title, a line under it, and the
|
||||
* round close button at the far end.
|
||||
*
|
||||
* The tile is the journey's cover and the title its name, so it is always clear
|
||||
* whose book this is. What the book is printed as and whether it is saved sit
|
||||
* under the title, because they describe it. The tools on the right are grouped
|
||||
* by what they do: who else is here, editing (undo, redo, paste), making
|
||||
* (auto layout, the assistant), and getting the book out (help, export). How
|
||||
* the page is looked at, the book view, lives with the zoom under the page.
|
||||
*/
|
||||
export function StudioHeader({
|
||||
s,
|
||||
onExport,
|
||||
assistant,
|
||||
}: {
|
||||
s: Studio;
|
||||
onExport: () => void;
|
||||
assistant: StudioAssistant;
|
||||
}) {
|
||||
return (
|
||||
<header className="st-bar st-head">
|
||||
<span className="st-head-tile">{s.coverUrl ? <img src={s.coverUrl} alt="" /> : null}</span>
|
||||
|
||||
<div className="st-head-text">
|
||||
<div className="st-head-eyebrow">
|
||||
<StudioWordmark style={{ height: 17 }} />
|
||||
<span className="st-beta">{s.t('journey.studio.beta')}</span>
|
||||
</div>
|
||||
<h1 className="st-head-title">{s.journey?.title || s.t('journey.title')}</h1>
|
||||
<div className="st-head-sub">
|
||||
<FormatPicker s={s} />
|
||||
<SaveIndicator
|
||||
state={s.saveState}
|
||||
t={s.t}
|
||||
onAcceptTheirs={(current) => s.loadDoc(s.acceptTheirs(current))}
|
||||
onKeepMine={s.keepMine}
|
||||
onRetry={() => {
|
||||
void s.saveNow();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="st-head-tools">
|
||||
<PeerBadges peers={s.peers} t={s.t} />
|
||||
|
||||
<div className="st-head-group">
|
||||
<IconTool label={s.t('journey.studio.undo')} disabled={!s.canUndo} onClick={s.undo}>
|
||||
<Undo2 size={15} />
|
||||
</IconTool>
|
||||
<IconTool label={s.t('journey.studio.redo')} disabled={!s.canRedo} onClick={s.redo}>
|
||||
<Redo2 size={15} />
|
||||
</IconTool>
|
||||
<PasteButton s={s} />
|
||||
</div>
|
||||
|
||||
<div className="st-head-group">
|
||||
<AutoLayoutButton s={s} />
|
||||
{/* The book's assistant; only where it can run for this user. */}
|
||||
<StudioAssistantToggle assistant={assistant} />
|
||||
</div>
|
||||
|
||||
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
|
||||
<HelpButton className="st-tool is-icon" />
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool is-primary"
|
||||
onClick={onExport}
|
||||
disabled={!s.doc}
|
||||
title={s.t('journey.studio.export')}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
|
||||
</button>
|
||||
|
||||
<Tooltip label={s.t('journey.studio.backToJourney')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={s.close}
|
||||
className="st-head-close"
|
||||
aria-label={s.t('journey.studio.backToJourney')}
|
||||
>
|
||||
<X size={16} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTool({
|
||||
label,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button type="button" className="st-tool is-icon" disabled={disabled} onClick={onClick} aria-label={label}>
|
||||
{children}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a popover on a pointer press outside it and on Escape. Escape is
|
||||
* swallowed, or the shell's own handler would close Studio itself.
|
||||
*/
|
||||
function useDismiss(box: RefObject<HTMLElement | null>, open: boolean, setOpen: (open: boolean) => void) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', onDown);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
};
|
||||
}, [box, open, setOpen]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste what was copied onto the page on screen (#2316). Beside undo and redo
|
||||
* because it is the same kind of thing, and there so the keyboard shortcut is
|
||||
* not the only way to find it.
|
||||
*/
|
||||
function PasteButton({ s }: { s: Studio }) {
|
||||
const clipboard = useStudioStore((st) => st.clipboard);
|
||||
const paste = useStudioStore((st) => st.paste);
|
||||
const label = s.t('journey.studio.paste');
|
||||
return (
|
||||
<Tooltip label={clipboard ? label : s.t('journey.studio.pasteEmpty')}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!clipboard || !s.canEdit}
|
||||
onClick={() => paste(s.activeSpread)}
|
||||
aria-label={label}
|
||||
>
|
||||
<ClipboardPaste size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out again: this spread, or the whole book.
|
||||
*
|
||||
* A split button rather than two: they are the same action at two scopes, and
|
||||
* the spread is the one you want nine times out of ten. Rebuilding the whole
|
||||
* book is behind the chevron, where it cannot be hit by accident, and both are
|
||||
* ordinary undo steps.
|
||||
*/
|
||||
function AutoLayoutButton({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
useDismiss(box, open, setOpen);
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={s.t('journey.studio.autoLayout')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
|
||||
<ChevronDown size={13} style={{ opacity: 0.5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
disabled={!s.canRelayoutSpread}
|
||||
onClick={() => {
|
||||
s.relayoutCurrentSpread();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
|
||||
<span className="st-menu-dim">
|
||||
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
s.relayoutBook();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
|
||||
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The format, as a chip under the book's name: it says what the book is
|
||||
* printed as, and opening it changes that.
|
||||
*
|
||||
* A native <select> was the first draft and it was wrong: the browser draws the
|
||||
* list in its own chrome, so it ignores the theme entirely and drops a list that
|
||||
* runs off the bottom of the screen. This is a small popover instead, themed,
|
||||
* anchored under the chip, and it shows each format's proportions rather than
|
||||
* only naming them.
|
||||
*/
|
||||
function FormatPicker({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
useDismiss(box, open, setOpen);
|
||||
|
||||
const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210'];
|
||||
const width = s.preset === 'custom' ? s.page.pageWidth : active.pageWidthMm;
|
||||
const height = s.preset === 'custom' ? s.page.pageHeight : active.pageHeightMm;
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-head-chip"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={s.t('journey.studio.format')}
|
||||
>
|
||||
<span
|
||||
className="st-head-chip-shape"
|
||||
style={
|
||||
width >= height
|
||||
? { width: 12, height: Math.max(6, Math.round((12 * height) / width)) }
|
||||
: { height: 12, width: Math.max(6, Math.round((12 * width) / height)) }
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{s.preset === 'custom'
|
||||
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
|
||||
: s.t(active.labelKey)}
|
||||
</span>
|
||||
<ChevronDown size={12} style={{ opacity: 0.55 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu st-head-menu" role="listbox">
|
||||
{PAGE_PRESET_ORDER.map((id) => {
|
||||
const p = PAGE_PRESETS[id];
|
||||
const on = id === s.preset;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
className={`st-menu-item ${on ? 'is-active' : ''}`}
|
||||
onClick={() => {
|
||||
s.setPreset(id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="st-menu-tile">
|
||||
<span
|
||||
className="st-menu-shape"
|
||||
style={
|
||||
p.pageWidthMm >= p.pageHeightMm
|
||||
? { width: 24, height: Math.round((24 * p.pageHeightMm) / p.pageWidthMm) }
|
||||
: { height: 24, width: Math.round((24 * p.pageWidthMm) / p.pageHeightMm) }
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t(p.labelKey)}</span>
|
||||
<span className="st-menu-dim">
|
||||
{p.pageWidthMm} × {p.pageHeightMm} mm
|
||||
</span>
|
||||
</span>
|
||||
{on && <Check size={14} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/*
|
||||
The two fields live under the list rather than behind a "custom"
|
||||
entry that opens a second popover: picking the custom row and then
|
||||
typing into the fields it reveals is two steps for one decision, and
|
||||
typing into them *is* picking custom; the hook switches the preset
|
||||
for you.
|
||||
*/}
|
||||
<div className="st-menu-sep" />
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.width')}
|
||||
value={s.page.pageWidth}
|
||||
onCommit={(v) => s.setPageSize('w', v)}
|
||||
/>
|
||||
<span className="st-menu-times">×</span>
|
||||
<TrimField
|
||||
label={s.t('journey.studio.height')}
|
||||
value={s.page.pageHeight}
|
||||
onCommit={(v) => s.setPageSize('h', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
What the press takes off, and what it must not print into. Under the
|
||||
trim size because they answer the same question, how big is this book
|
||||
really, and because a vendor states all three in one breath.
|
||||
*/}
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.bleed')}
|
||||
value={s.page.bleed}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.5}
|
||||
onCommit={(v) => s.setPageEdge('bleed', v)}
|
||||
/>
|
||||
<span className="st-menu-times" />
|
||||
<TrimField
|
||||
label={s.t('journey.studio.safeArea')}
|
||||
value={s.page.safe}
|
||||
min={0}
|
||||
max={40}
|
||||
step={0.5}
|
||||
onCommit={(v) => s.setPageEdge('safe', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
/**
|
||||
* What the Studio says about a click it could not carry out. Today one thing:
|
||||
* the page already holds as many elements as a page can, so a sticker, a frame,
|
||||
* a paste or a duplicate was not added. Every path that refuses counts it in the
|
||||
* store, and this says it once per count rather than each path in its own words.
|
||||
*/
|
||||
export function StudioNotices() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const refused = useStudioStore((s) => s.refusedFull);
|
||||
const seen = useRef(refused);
|
||||
useEffect(() => {
|
||||
if (refused === seen.current) return;
|
||||
seen.current = refused;
|
||||
toast.warning(t('journey.studio.pageFull'));
|
||||
}, [refused, toast, t]);
|
||||
return null;
|
||||
}
|
||||
@@ -64,12 +64,19 @@ export function StudioPaintPicker({
|
||||
channel,
|
||||
t,
|
||||
noneLabel,
|
||||
kinds = KINDS,
|
||||
}: {
|
||||
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;
|
||||
/**
|
||||
* The kinds offered as tabs; all four by default. With one, the row of tabs
|
||||
* is left out: the letters of a text pick gradient or pattern beside their
|
||||
* other fills and only need that kind's controls here.
|
||||
*/
|
||||
kinds?: readonly PaintKind[];
|
||||
}) {
|
||||
const kind = kindOf(value);
|
||||
// The last paint of each kind shown here, so a kind visited again comes back as it was left.
|
||||
@@ -96,28 +103,30 @@ export function StudioPaintPicker({
|
||||
|
||||
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
|
||||
{kinds.length > 1 && (
|
||||
<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)}
|
||||
>
|
||||
{k === 'none' && <Ban size={11} strokeWidth={2.2} />}
|
||||
</span>
|
||||
<span className="st-paint-tab-label">{k === 'none' ? noneLabel : kindLabel(k, t)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate } from 'react-router'
|
||||
import {
|
||||
ArrowLeft, BookOpen, Check, ChevronDown, ClipboardPaste, Download, Maximize2, Minus, Plus,
|
||||
Redo2, Sparkles, Undo2,
|
||||
} from 'lucide-react'
|
||||
import { BookOpen, Download, Maximize2, Minus, Plus } from 'lucide-react'
|
||||
import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
|
||||
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { foliosOf } from './bookSheets'
|
||||
import { StudioSidebar } from './StudioSidebar'
|
||||
import type { RailSection } from './sidebar/railSections'
|
||||
@@ -15,21 +12,16 @@ 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'
|
||||
import { StudioExport } from './StudioExport'
|
||||
import { PeerBadges } from './PeerBadges'
|
||||
import { TrimField } from './TrimField'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { StudioAssistantColumn, StudioAssistantToggle, StudioPageStage } from './StudioAssistantPanel'
|
||||
import { useStudioAssistant, type StudioAssistant } from './useStudioAssistant'
|
||||
import { StudioAssistantColumn, StudioPageStage } from './StudioAssistantPanel'
|
||||
import { useStudioAssistant } from './useStudioAssistant'
|
||||
import { StudioHeader } from './StudioHeader'
|
||||
import { StudioNotices } from './StudioNotices'
|
||||
import '../../styles/dashboard.css'
|
||||
import '../../styles/studio.css'
|
||||
import './bookFontFaces'
|
||||
import { useWarmBookFaces } from './bookFontWarm'
|
||||
import HelpButton from '../Help/HelpButton'
|
||||
|
||||
/**
|
||||
* The Studio shell: top bar, page rail, workbench, inspector.
|
||||
@@ -93,13 +85,8 @@ export default function StudioShell() {
|
||||
aria-modal="true"
|
||||
aria-label={s.t('journey.studio.title')}
|
||||
>
|
||||
<StudioBar
|
||||
s={s}
|
||||
bookView={bookView}
|
||||
setBookView={setBookView}
|
||||
onExport={() => setExporting(true)}
|
||||
assistant={assistant}
|
||||
/>
|
||||
<StudioHeader s={s} onExport={() => setExporting(true)} assistant={assistant} />
|
||||
<StudioNotices />
|
||||
{/* The journey gallery's picker, inside Studio's own layer (#2271). */}
|
||||
{s.providerPick && s.journey && (
|
||||
<ProviderPicker
|
||||
@@ -132,7 +119,7 @@ export default function StudioShell() {
|
||||
section={railSection}
|
||||
onSection={setRailSection}
|
||||
/>
|
||||
<Workbench s={s} bookView={bookView} />
|
||||
<Workbench s={s} bookView={bookView} setBookView={setBookView} />
|
||||
<StudioPaletteScope spreadIndex={s.activeSpread} t={s.t}>
|
||||
<StudioInspector
|
||||
spreadIndex={s.activeSpread}
|
||||
@@ -164,339 +151,10 @@ export default function StudioShell() {
|
||||
)
|
||||
}
|
||||
|
||||
type Studio = ReturnType<typeof useJourneyStudio>
|
||||
export type Studio = ReturnType<typeof useJourneyStudio>
|
||||
|
||||
function StudioBar({
|
||||
s, bookView, setBookView, onExport, assistant,
|
||||
}: {
|
||||
s: Studio; bookView: boolean; setBookView: (v: boolean) => void; onExport: () => void
|
||||
assistant: StudioAssistant
|
||||
}) {
|
||||
return (
|
||||
<div className="st-bar">
|
||||
<button type="button" onClick={s.close} className="st-back" title={s.t('journey.studio.backToJourney')}>
|
||||
<ArrowLeft size={16} />
|
||||
{s.coverUrl
|
||||
? <img src={s.coverUrl} alt="" className="st-cover" />
|
||||
: <span className="st-cover" />}
|
||||
<span className="st-back-label">{s.journey?.title || s.t('journey.title')}</span>
|
||||
</button>
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
{/*
|
||||
The lockup, not a name field. The book is the journey's, and it was
|
||||
already titled after it — a second place to type the same name is a
|
||||
field whose only job is to be left alone. What the bar is short of is
|
||||
somewhere to say what this thing *is*.
|
||||
*/}
|
||||
<div className="st-brand">
|
||||
<StudioWordmark style={{ height: 26 }} />
|
||||
<span className="st-beta">{s.t('journey.studio.beta')}</span>
|
||||
</div>
|
||||
|
||||
<PeerBadges peers={s.peers} t={s.t} />
|
||||
|
||||
<SaveIndicator
|
||||
state={s.saveState}
|
||||
t={s.t}
|
||||
onAcceptTheirs={current => s.loadDoc(s.acceptTheirs(current))}
|
||||
onKeepMine={s.keepMine}
|
||||
onRetry={() => { void s.saveNow() }}
|
||||
/>
|
||||
|
||||
<div className="st-bar-group">
|
||||
<button type="button"
|
||||
className={`st-tool ${bookView ? 'is-on' : ''}`}
|
||||
onClick={() => setBookView(!bookView)}
|
||||
title={s.t('journey.studio.bookView')}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.bookView')}</span>
|
||||
</button>
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<FormatPicker s={s} />
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<button type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!s.canUndo}
|
||||
onClick={s.undo}
|
||||
title={s.t('journey.studio.undo')}
|
||||
aria-label={s.t('journey.studio.undo')}
|
||||
>
|
||||
<Undo2 size={15} />
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!s.canRedo}
|
||||
onClick={s.redo}
|
||||
title={s.t('journey.studio.redo')}
|
||||
aria-label={s.t('journey.studio.redo')}
|
||||
>
|
||||
<Redo2 size={15} />
|
||||
</button>
|
||||
<PasteButton s={s} />
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<AutoLayoutButton s={s} />
|
||||
{/* The book's assistant, as a column beside the page; only where it can run for this user. */}
|
||||
<StudioAssistantToggle assistant={assistant} />
|
||||
<button type="button"
|
||||
className="st-tool is-primary"
|
||||
onClick={onExport}
|
||||
disabled={!s.doc}
|
||||
title={s.t('journey.studio.export')}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
|
||||
</button>
|
||||
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
|
||||
<HelpButton className="st-tool is-icon" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out again — this spread, or the whole book.
|
||||
*
|
||||
* A split button rather than two: they are the same action at two scopes, and
|
||||
* the spread is the one you want nine times out of ten. Rebuilding the whole
|
||||
* book is behind the chevron, where it cannot be hit by accident, and both are
|
||||
* ordinary undo steps.
|
||||
*/
|
||||
/**
|
||||
* Paste what was copied onto the page on screen (#2316). Beside undo and redo
|
||||
* because it is the same kind of thing, and there so the keyboard shortcut is
|
||||
* not the only way to find it.
|
||||
*/
|
||||
function PasteButton({ s }: { s: Studio }) {
|
||||
const clipboard = useStudioStore(st => st.clipboard)
|
||||
const paste = useStudioStore(st => st.paste)
|
||||
const label = s.t('journey.studio.paste')
|
||||
return (
|
||||
<Tooltip label={clipboard ? label : s.t('journey.studio.pasteEmpty')}>
|
||||
<button type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!clipboard || !s.canEdit}
|
||||
onClick={() => paste(s.activeSpread)}
|
||||
aria-label={label}
|
||||
>
|
||||
<ClipboardPaste size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function AutoLayoutButton({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={s.t('journey.studio.autoLayout')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
|
||||
<ChevronDown size={13} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="menu">
|
||||
<button type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
disabled={!s.canRelayoutSpread}
|
||||
onClick={() => { s.relayoutCurrentSpread(); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
|
||||
<span className="st-menu-dim">
|
||||
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => { s.relayoutBook(); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
|
||||
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The format picker.
|
||||
*
|
||||
* A native <select> was the first draft and it was wrong: the browser draws the
|
||||
* list in its own chrome, so it ignores the theme entirely and drops a list that
|
||||
* runs off the bottom of the screen. This is a small popover instead — themed,
|
||||
* anchored under the bar, and it shows each format's proportions rather than
|
||||
* only naming them.
|
||||
*/
|
||||
function FormatPicker({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Swallow it, or the shell's Escape handler would close Studio itself.
|
||||
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210']
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={s.t('journey.studio.format')}
|
||||
>
|
||||
<span className="st-tool-label">
|
||||
{s.preset === 'custom'
|
||||
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
|
||||
: s.t(active.labelKey)}
|
||||
</span>
|
||||
<ChevronDown size={13} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="listbox">
|
||||
{PAGE_PRESET_ORDER.map(id => {
|
||||
const p = PAGE_PRESETS[id]
|
||||
const on = id === s.preset
|
||||
return (
|
||||
<button type="button"
|
||||
key={id}
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
className={`st-menu-item ${on ? 'is-active' : ''}`}
|
||||
onClick={() => { s.setPreset(id); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-tile">
|
||||
<span
|
||||
className="st-menu-shape"
|
||||
style={
|
||||
p.pageWidthMm >= p.pageHeightMm
|
||||
? { width: 24, height: Math.round(24 * p.pageHeightMm / p.pageWidthMm) }
|
||||
: { height: 24, width: Math.round(24 * p.pageWidthMm / p.pageHeightMm) }
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t(p.labelKey)}</span>
|
||||
<span className="st-menu-dim">{p.pageWidthMm} × {p.pageHeightMm} mm</span>
|
||||
</span>
|
||||
{on && <Check size={14} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/*
|
||||
The two fields live under the list rather than behind a "custom"
|
||||
entry that opens a second popover: picking the custom row and then
|
||||
typing into the fields it reveals is two steps for one decision, and
|
||||
typing into them *is* picking custom — the hook switches the preset
|
||||
for you.
|
||||
*/}
|
||||
<div className="st-menu-sep" />
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.width')}
|
||||
value={s.page.pageWidth}
|
||||
onCommit={v => s.setPageSize('w', v)}
|
||||
/>
|
||||
<span className="st-menu-times">×</span>
|
||||
<TrimField
|
||||
label={s.t('journey.studio.height')}
|
||||
value={s.page.pageHeight}
|
||||
onCommit={v => s.setPageSize('h', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
What the press takes off, and what it must not print into.
|
||||
|
||||
Under the trim size because they answer the same question — how big
|
||||
is this book, really — and because a vendor states all three in one
|
||||
breath. They were fixed at 3 and 5, which is what most photo-book
|
||||
printers ask for and is useless to the one that asks for 5 and 10.
|
||||
*/}
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.bleed')}
|
||||
value={s.page.bleed}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.5}
|
||||
onCommit={v => s.setPageEdge('bleed', v)}
|
||||
/>
|
||||
<span className="st-menu-times" />
|
||||
<TrimField
|
||||
label={s.t('journey.studio.safeArea')}
|
||||
value={s.page.safe}
|
||||
min={0}
|
||||
max={40}
|
||||
step={0.5}
|
||||
onCommit={v => s.setPageEdge('safe', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
|
||||
function Workbench({ s, bookView, setBookView }: { s: Studio; bookView: boolean; setBookView: (on: boolean) => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="st-work-wrap">
|
||||
<div className="st-work" ref={s.workRef}>
|
||||
@@ -538,6 +196,16 @@ function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
|
||||
<button type="button" onClick={s.zoomToFit} aria-label={s.t('journey.studio.zoomFit')} title={s.t('journey.studio.zoomFit')}>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
{/* How the page is looked at, so with the zoom rather than in the head band. */}
|
||||
<button type="button"
|
||||
onClick={() => setBookView(!bookView)}
|
||||
aria-pressed={bookView}
|
||||
aria-label={t('journey.studio.bookView')}
|
||||
title={t('journey.studio.bookView')}
|
||||
className={bookView ? 'is-on' : undefined}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
</button>
|
||||
{/*
|
||||
The page, as a file.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { StudioTabPanel, StudioTabs } from './StudioTabs';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-TABS-001 to FE-STUDIO-TABS-004: the tab rows inside Studio's
|
||||
* FE-STUDIO-TABS-001 to FE-STUDIO-TABS-005: the tab rows inside Studio's
|
||||
* panels, as a screen reader and a keyboard meet them, and as a long word
|
||||
* meets them.
|
||||
*/
|
||||
@@ -69,6 +69,20 @@ describe('StudioTabs', () => {
|
||||
expect(three?.tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-TABS-005: in a grid of two lines, up and down move to the tab above or below', () => {
|
||||
render(<Row />);
|
||||
const [one, two, three] = screen.getAllByRole('tab');
|
||||
one?.focus();
|
||||
fireEvent.keyDown(one as HTMLElement, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(three);
|
||||
fireEvent.keyDown(three as HTMLElement, { key: 'ArrowUp' });
|
||||
expect(document.activeElement).toBe(one);
|
||||
two?.focus();
|
||||
// Nothing below the second tab: the focus stays.
|
||||
fireEvent.keyDown(two as HTMLElement, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(two);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-TABS-003: keeps a long word in a cell that breaks it, and the count as plain figures', () => {
|
||||
const { container } = render(<Row />);
|
||||
const label = screen.getByText('Klistermärken');
|
||||
|
||||
@@ -15,7 +15,8 @@ import { rovingTarget, runsRtl } from './studioRoving';
|
||||
* label's own colour, quieter than the word, never a pill inside the tab.
|
||||
*
|
||||
* The keyboard is the rail's (studioRoving.ts): the arrows move along the row,
|
||||
* Enter or Space opens the tab.
|
||||
* Enter or Space opens the tab. In a grid of more than one line, up and down
|
||||
* move to the tab above or below.
|
||||
*/
|
||||
|
||||
interface StudioTab<T extends string> {
|
||||
@@ -45,8 +46,15 @@ export function StudioTabs<T extends string>({
|
||||
columns?: number;
|
||||
}) {
|
||||
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const across = Math.max(1, Math.min(columns, tabs.length));
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
const next = rovingTarget(e.key, index, tabs.length, { vertical: false, rtl: runsRtl(e.currentTarget) });
|
||||
const below = e.key === 'ArrowDown' ? index + across : e.key === 'ArrowUp' ? index - across : null;
|
||||
const next =
|
||||
below !== null && across < tabs.length
|
||||
? below >= 0 && below < tabs.length
|
||||
? below
|
||||
: null
|
||||
: rovingTarget(e.key, index, tabs.length, { vertical: false, rtl: runsRtl(e.currentTarget) });
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
buttons.current[next]?.focus();
|
||||
@@ -56,7 +64,7 @@ export function StudioTabs<T extends string>({
|
||||
className={`st-subtabs ${tabs.some((tab) => tab.icon) ? 'has-icons' : ''}`}
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
style={{ gridTemplateColumns: `repeat(${Math.max(1, Math.min(columns, tabs.length))}, minmax(0, 1fr))` }}
|
||||
style={{ gridTemplateColumns: `repeat(${across}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{tabs.map((tab, i) => {
|
||||
const on = tab.id === value;
|
||||
|
||||
@@ -189,6 +189,32 @@ describe('the fill of the letters', () => {
|
||||
expect(screen.getByText('journey.studio.fx.focalHint')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('turns the letters into a gradient or a pattern from their own colour, edited as a shape is', () => {
|
||||
const plain = mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' }, color: '#0f766e' }));
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.paint.gradient' }));
|
||||
const gradient = plain.set.mock.lastCall?.[0]?.fillPaint;
|
||||
expect(gradient?.type).toBe('linear');
|
||||
expect(gradient?.stops[0]?.color).toBe('#0f766e');
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.paint.pattern' }));
|
||||
expect(plain.set.mock.lastCall?.[0]?.fillPaint?.type).toBe('pattern');
|
||||
plain.unmount();
|
||||
|
||||
const patterned = {
|
||||
type: 'pattern' as const,
|
||||
pattern: 'dots' as const,
|
||||
fg: '#d62828',
|
||||
bg: '#ffffff',
|
||||
scale: 1,
|
||||
angle: 0,
|
||||
};
|
||||
mount(text({ fillPaint: patterned }));
|
||||
expect(screen.getByRole('radio', { name: 'journey.studio.paint.pattern' }).getAttribute('aria-checked')).toBe(
|
||||
'true'
|
||||
);
|
||||
// Only the pattern's own controls: the kind is picked in the row above, so the paint's tabs stay away.
|
||||
expect(screen.queryByRole('tablist')).toBeNull();
|
||||
});
|
||||
|
||||
it('says so, and offers no photo fill, when the journey has no photos', () => {
|
||||
mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' } }), []);
|
||||
expect(screen.getByText('journey.studio.fx.noPhotos')).toBeTruthy();
|
||||
|
||||
@@ -8,13 +8,16 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { BookElement, BookSpread, BookTextElement } from '@trek/shared';
|
||||
import { bookTextIsDrawn } from '@trek/shared';
|
||||
import { bookTextIsDrawn, bookTextPaintFill } from '@trek/shared';
|
||||
|
||||
import '../../styles/studio-text.css';
|
||||
import { photoSrc } from './bookRender';
|
||||
import { FocalPad } from './inspector/FocalPad';
|
||||
import { textEffectsInUse, textPlacementInUse } from './inspector/inspectorModel';
|
||||
import { paintAs } from './paint/paintEdit';
|
||||
import type { PaintChannel } from './paint/usePaintChannel';
|
||||
import { Choice, Line, NumField, Section, Switch } from './StudioControls';
|
||||
import { StudioPaintPicker } from './StudioPaintPicker';
|
||||
import { Swatches } from './StudioSwatches';
|
||||
import { defaultBand, defaultOutline, defaultShadow, firstFillPhoto, isHollow } from './textEffects';
|
||||
|
||||
@@ -275,10 +278,13 @@ function CurveControls({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
|
||||
);
|
||||
}
|
||||
|
||||
type FillKind = 'colour' | 'hollow' | 'photo';
|
||||
type FillKind = 'colour' | 'hollow' | 'photo' | 'gradient' | 'pattern';
|
||||
|
||||
function fillKindOf(el: BookTextElement): FillKind {
|
||||
if (el.fillPaint?.type === 'photo') return 'photo';
|
||||
const type = el.fillPaint?.type;
|
||||
if (type === 'photo') return 'photo';
|
||||
if (type === 'linear' || type === 'radial') return 'gradient';
|
||||
if (type === 'pattern') return 'pattern';
|
||||
return isHollow(el) ? 'hollow' : 'colour';
|
||||
}
|
||||
|
||||
@@ -294,9 +300,18 @@ function FillControls({ el, set, live, photos, spread, t }: Props) {
|
||||
outline: el.outline ?? { ...defaultOutline(el), color: el.color },
|
||||
} as Patch);
|
||||
}
|
||||
if (next === 'gradient' || next === 'pattern') {
|
||||
// From the text's own colour, as a shape's paint turns from its solid fill.
|
||||
return set({ fillPaint: paintAs(next, { type: 'solid', color: el.color }) } as Patch);
|
||||
}
|
||||
const photoId = firstFillPhoto(spread, photos);
|
||||
if (photoId != null) set({ fillPaint: { type: 'photo', photoId, focalX: 0.5, focalY: 0.5, zoom: 1 } } as Patch);
|
||||
};
|
||||
const letterPaint = bookTextPaintFill(el);
|
||||
const channel: PaintChannel = {
|
||||
live: (paint) => live({ fillPaint: paint } as Patch),
|
||||
commit: (paint) => set({ fillPaint: paint } as Patch),
|
||||
};
|
||||
return (
|
||||
<div className="st-fx-group">
|
||||
<Line label={t('journey.studio.fx.letters')}>
|
||||
@@ -307,11 +322,22 @@ function FillControls({ el, set, live, photos, spread, t }: Props) {
|
||||
{ value: 'colour' as const, label: t('journey.studio.colour') },
|
||||
{ value: 'hollow' as const, label: t('journey.studio.fx.hollow') },
|
||||
{ value: 'photo' as const, label: t('journey.studio.kind.photo'), disabled: !photos.length },
|
||||
{ value: 'gradient' as const, label: t('journey.studio.paint.gradient') },
|
||||
{ value: 'pattern' as const, label: t('journey.studio.paint.pattern') },
|
||||
]}
|
||||
onPick={pick}
|
||||
/>
|
||||
</Line>
|
||||
{!photos.length && <p className="st-hint st-fx-hint">{t('journey.studio.fx.noPhotos')}</p>}
|
||||
{letterPaint && (kind === 'gradient' || kind === 'pattern') && (
|
||||
<StudioPaintPicker
|
||||
value={letterPaint}
|
||||
channel={channel}
|
||||
t={t}
|
||||
noneLabel={t('journey.studio.colour')}
|
||||
kinds={[kind]}
|
||||
/>
|
||||
)}
|
||||
{photo && (
|
||||
<div className="st-fx-body">
|
||||
<div className="st-fx-photos" role="radiogroup" aria-label={t('journey.studio.fx.pickPhoto')}>
|
||||
|
||||
@@ -99,7 +99,11 @@ export function StudioTextStyles({
|
||||
const place = (lockup: BookLockup) => {
|
||||
const els = placeLockup(lockup, { page, spread, words, gallery: source.photos, newId: bookElementId });
|
||||
// The page's own cap, as a duplicate keeps it: a set that would make the book unsaveable is not placed.
|
||||
if (!els.length || !spread || spread.elements.length + els.length > MAX_SPREAD_ELEMENTS) return;
|
||||
if (!els.length || !spread) return;
|
||||
if (spread.elements.length + els.length > MAX_SPREAD_ELEMENTS) {
|
||||
useStudioStore.getState().refuseFull();
|
||||
return;
|
||||
}
|
||||
commit((d) => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i === active ? addBookLockup(sp, els, bookElementId('g')) : sp)),
|
||||
|
||||
@@ -108,6 +108,40 @@ describe('a text drawn as SVG', () => {
|
||||
expect(Array.from(clip?.querySelectorAll('tspan') ?? []).map((t) => t.textContent)).toEqual(['LIS', 'BON']);
|
||||
});
|
||||
|
||||
it('fills the letters with a gradient through a gradient of their own frame', () => {
|
||||
const el = text({
|
||||
fillPaint: {
|
||||
type: 'linear',
|
||||
angle: 90,
|
||||
stops: [
|
||||
{ at: 0, color: '#ff6600' },
|
||||
{ at: 1, color: '#ffcc00' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
|
||||
const gradient = container.querySelector('linearGradient');
|
||||
expect(gradient?.getAttribute('gradientUnits')).toBe('userSpaceOnUse');
|
||||
expect(Array.from(gradient?.querySelectorAll('stop') ?? []).map((s) => s.getAttribute('stop-color'))).toEqual([
|
||||
'#ff6600',
|
||||
'#ffcc00',
|
||||
]);
|
||||
const letters = container.querySelector('svg > text');
|
||||
expect(letters?.getAttribute('fill')).toBe(`url(#${gradient?.getAttribute('id')})`);
|
||||
});
|
||||
|
||||
it('fills the letters with a pattern, its ground and its marks clipped to them', () => {
|
||||
const el = text({
|
||||
fillPaint: { type: 'pattern', pattern: 'stripes', fg: '#d62828', bg: '#ffffff', scale: 1, angle: 0 },
|
||||
});
|
||||
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
|
||||
const clip = container.getElementsByTagName('clipPath')[0];
|
||||
const group = container.querySelector('g[clip-path]');
|
||||
expect(group?.getAttribute('clip-path')).toBe(`url(#${clip?.getAttribute('id')})`);
|
||||
expect(group?.querySelector('rect')?.getAttribute('fill')).toBe('#ffffff');
|
||||
expect(group?.querySelectorAll('path[fill="#d62828"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('gives every copy of a page its own ids, so a page drawn twice never clips to the other copy', () => {
|
||||
const el = text({ fillPaint: { type: 'photo', photoId: 12 }, curve: { type: 'circle' } });
|
||||
const { container } = render(
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useId } from 'react';
|
||||
|
||||
import type { BookTextElement } from '@trek/shared';
|
||||
import { bookDrawnLines, bookTextCurvePath, bookTextIsDrawn } from '@trek/shared';
|
||||
import { bookDrawnLines, bookTextCurvePath, bookTextIsDrawn, bookTextPaintFill } from '@trek/shared';
|
||||
|
||||
import { fontStack } from './bookFonts';
|
||||
import { photoSrc } from './bookRender';
|
||||
import { paintParts, type PaintParts } from './paint/paintParts';
|
||||
import {
|
||||
caseStyle,
|
||||
curveAnchor,
|
||||
@@ -145,15 +146,46 @@ interface GlyphPaint {
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
/** A gradient or pattern in the letters, laid out over the frame as a shape's paint is over its box. */
|
||||
function lettersPaint(el: BookTextElement, id: string): PaintParts | null {
|
||||
const paint = bookTextPaintFill(el);
|
||||
return paint ? paintParts(paint, { x: 0, y: 0, w: el.frame.w, h: el.frame.h }, id) : null;
|
||||
}
|
||||
|
||||
/** What the letters are filled with: the paint's fill, else their own colour. */
|
||||
function lettersFill(el: BookTextElement, paint: PaintParts | null): string {
|
||||
if (paint) return paint.fill;
|
||||
return letterFill(el) ?? el.color;
|
||||
}
|
||||
|
||||
/** A pattern's ground and its marks, clipped to the letters. */
|
||||
function PatternLetters({ parts, w, h, clipId }: { parts: PaintParts; w: number; h: number; clipId: string }) {
|
||||
const pattern = parts.pattern;
|
||||
if (!pattern) return null;
|
||||
return (
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
{parts.fill !== 'none' && <rect width={w} height={h} fill={parts.fill} />}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: CSSProperties; big: boolean }) {
|
||||
const reactId = useId();
|
||||
const pathId = svgId('tp-', reactId);
|
||||
const clipId = svgId('tc-', reactId);
|
||||
const paintId = svgId('tf-', reactId);
|
||||
const { w, h } = el.frame;
|
||||
const fontMm = ptToMm(el.size);
|
||||
const lines = bookDrawnLines(el);
|
||||
const curve = el.curve ? bookTextCurvePath(el.curve, w, h, fontMm) : null;
|
||||
const photo = el.fillPaint?.type === 'photo' ? el.fillPaint : null;
|
||||
const letterPaint = lettersPaint(el, paintId);
|
||||
const pattern = letterPaint ? letterPaint.pattern : null;
|
||||
const hollow = isHollow(el);
|
||||
const { outline, shadow } = el;
|
||||
|
||||
@@ -211,8 +243,10 @@ function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: C
|
||||
if (photo) {
|
||||
const cover = photoCover(w, h, photo);
|
||||
layers.push(<image key="photo" href={photoSrc(photo.photoId, big)} {...cover} clipPath={`url(#${clipId})`} />);
|
||||
} else if (pattern && letterPaint) {
|
||||
layers.push(<PatternLetters key="pattern" parts={letterPaint} w={w} h={h} clipId={clipId} />);
|
||||
} else {
|
||||
layers.push(glyphs({ fill: letterFill(el) ?? el.color }, 'fill'));
|
||||
layers.push(glyphs({ fill: lettersFill(el, letterPaint) }, 'fill'));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -227,7 +261,8 @@ function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: C
|
||||
>
|
||||
<defs>
|
||||
{curve && <path id={pathId} d={curve.d} fill="none" />}
|
||||
{photo && (
|
||||
{letterPaint?.defs}
|
||||
{(photo || pattern) && (
|
||||
<clipPath id={clipId} clipPathUnits="userSpaceOnUse">
|
||||
{glyphs(null, 'clip')}
|
||||
</clipPath>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ReactNode } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../../store/studioStore';
|
||||
import { PhotoCropExtras, PhotoLookSections } from '../photo/StudioPhotoLook';
|
||||
import { FRAME_SHAPES } from '../shapes';
|
||||
import { FRAME_SHAPES, shapeName } from '../shapes';
|
||||
import { Choice, Line, NumField, Section } from '../StudioControls';
|
||||
import { EdgeSection } from '../StudioEdgeControls';
|
||||
import { ShapeGlyph } from '../StudioElementsPanel';
|
||||
@@ -178,7 +178,8 @@ export function StudioPhotoInspector({
|
||||
key={sh}
|
||||
className={`st-mini-shape ${el.mask === sh ? 'is-on' : ''}`}
|
||||
onClick={() => set({ mask: sh })}
|
||||
aria-label={sh}
|
||||
title={shapeName(sh, t)}
|
||||
aria-label={shapeName(sh, t)}
|
||||
aria-pressed={el.mask === sh}
|
||||
>
|
||||
<ShapeGlyph shape={sh} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BookElement, BookShapeElement, BookShapeId, BookTheme } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { SHAPE_GROUPS } from '../shapes';
|
||||
import { SHAPE_GROUPS, shapeName } from '../shapes';
|
||||
import { NumField, Section } from '../StudioControls';
|
||||
import { EdgeSection } from '../StudioEdgeControls';
|
||||
import { ShapeGlyph } from '../StudioElementsPanel';
|
||||
@@ -41,7 +41,8 @@ export function StudioShapeInspector({
|
||||
key={sh}
|
||||
className={`st-mini-shape ${el.shape === sh ? 'is-on' : ''}`}
|
||||
onClick={() => set({ shape: sh })}
|
||||
aria-label={sh}
|
||||
title={shapeName(sh, t)}
|
||||
aria-label={shapeName(sh, t)}
|
||||
aria-pressed={el.shape === sh}
|
||||
>
|
||||
<ShapeGlyph shape={sh} />
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { sortedStops, stopAlpha, type BookPaint, type BookPaintStop } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { linearLine, r2 } from './paintGeometry';
|
||||
import { patternGeometry, type PatternBox } from './patterns';
|
||||
|
||||
/**
|
||||
* A paint as SVG parts over a box (paintRender.tsx explains why the ids come
|
||||
* from useId): kept apart from the components that draw them, so a shape and
|
||||
* the letters of a text share it without the component file exporting a helper.
|
||||
*/
|
||||
|
||||
/** A gradient's stops as SVG elements, in order. */
|
||||
function stopElements(stops: readonly BookPaintStop[]): ReactNode {
|
||||
// Two stops may share an offset (a hard edge), so the index keys them.
|
||||
return sortedStops(stops).map((s, i) => (
|
||||
<stop 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. */
|
||||
export 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* What drawing a paint over a box takes: the gradient definition and the fill
|
||||
* to point at it, or a pattern's geometry to draw clipped to the outline over
|
||||
* its ground. A shape's outline and the letters of a text both draw from it.
|
||||
*/
|
||||
export 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}>
|
||||
{stopElements(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)}
|
||||
>
|
||||
{stopElements(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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
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 type { BookPaint } from '@trek/shared';
|
||||
import { useMemo, type SVGProps } from 'react';
|
||||
import { r2 } from './paintGeometry';
|
||||
import { paintParts } from './paintParts';
|
||||
import type { PatternBox } from './patterns';
|
||||
import { usePaintId } from './usePaintId';
|
||||
|
||||
/**
|
||||
@@ -25,79 +26,6 @@ import { usePaintId } from './usePaintId';
|
||||
* 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'>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,6 +46,11 @@ interface PatternGeometry {
|
||||
const r2 = (v: number) => Math.round(v * 100) / 100 || 0;
|
||||
const f = (v: number) => String(r2(v));
|
||||
|
||||
/** Threads per cell each way in linen: fine enough to read as cloth, few enough for a whole page under the cap. */
|
||||
const LINEN_THREADS = 6;
|
||||
/** Specks per cell in grain. */
|
||||
const GRAIN_SPECKS = 3;
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -66,6 +71,8 @@ const DENSITY: Record<BookPattern, { area: number; lin: number }> = {
|
||||
scales: { area: 2, lin: 0 },
|
||||
sunburst: { area: 0, lin: 0 },
|
||||
confetti: { area: 1.2, lin: 0 },
|
||||
linen: { area: 0, lin: 2 * LINEN_THREADS },
|
||||
grain: { area: GRAIN_SPECKS, lin: 0 },
|
||||
};
|
||||
|
||||
/** A primitive count estimate for a repeat over a region at a cell size. */
|
||||
@@ -269,6 +276,24 @@ const DRAW: Record<Exclude<BookPattern, 'sunburst'>, Draw> = {
|
||||
]);
|
||||
}
|
||||
},
|
||||
// Threads of uneven weight both ways, on two half-clear layers: where they cross the cloth reads denser.
|
||||
linen: (pen, g, s, alt) => {
|
||||
const rand = mulberry32(Math.round(s * 1000) + 31);
|
||||
const pitch = s / LINEN_THREADS;
|
||||
for (const y of steps(g.y0, g.y1, pitch)) pen.rect(g.x0, y, g.x1 - g.x0, pitch * (0.2 + rand() * 0.35));
|
||||
for (const x of steps(g.x0, g.x1, pitch)) alt.rect(x, g.y0, pitch * (0.2 + rand() * 0.35), g.y1 - g.y0);
|
||||
},
|
||||
// Specks of a few sizes, the larger ones fainter, scattered the same way on every render.
|
||||
grain: (pen, g, s, alt) => {
|
||||
const rand = mulberry32(Math.round(s * 1000) + 43);
|
||||
const n = Math.round((((g.x1 - g.x0) * (g.y1 - g.y0)) / (s * s)) * GRAIN_SPECKS);
|
||||
for (let i = 0; i < n && !pen.full && !alt.full; i++) {
|
||||
const x = g.x0 + rand() * (g.x1 - g.x0);
|
||||
const y = g.y0 + rand() * (g.y1 - g.y0);
|
||||
if (rand() < 0.6) pen.circle(x, y, s * (0.012 + rand() * 0.012));
|
||||
else alt.circle(x, y, s * (0.025 + rand() * 0.02));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** A seeded generator, so a confetti page draws the same scatter on every render and in print. */
|
||||
@@ -307,6 +332,13 @@ function sunburst(pen: Pen, box: PatternBox, cell: number, angle: number) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The repeats drawn on two layers, and how clear each layer is: textures stay quiet over their ground. */
|
||||
const LAYER_OPACITY: Partial<Record<BookPattern, readonly [number, number]>> = {
|
||||
gingham: [0.5, 0.5],
|
||||
linen: [0.32, 0.32],
|
||||
grain: [0.5, 0.28],
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -338,17 +370,18 @@ export function patternGeometry(
|
||||
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.
|
||||
// The second layer holds gingham's and linen's cross bands and grain's larger specks;
|
||||
// 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 }];
|
||||
const two = LAYER_OPACITY[paint.pattern];
|
||||
const layers = two
|
||||
? [
|
||||
{ d: pen.d, opacity: two[0] },
|
||||
{ d: alt.d, opacity: two[1] },
|
||||
]
|
||||
: [{ d: pen.d, opacity: 1 }];
|
||||
return {
|
||||
layers: layers.filter((l) => l.d),
|
||||
count,
|
||||
|
||||
@@ -330,3 +330,8 @@ export const HOLED_SHAPES = new Set<BookShapeId>(['gear', 'postmark', 'label-tab
|
||||
*/
|
||||
export const SHAPE_GROUPS = BOOK_SHAPE_GROUPS
|
||||
export const FRAME_SHAPES = BOOK_FRAME_SHAPES
|
||||
|
||||
/** A shape's name in the reader's language, for its tooltip and a screen reader. */
|
||||
export function shapeName(shape: BookShapeId, t: (k: string) => string): string {
|
||||
return t(`journey.studio.shapeName.${shape}`)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function withAlpha(hex: string, alpha = 1): string {
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${Math.max(0, alpha)})`;
|
||||
}
|
||||
|
||||
/** The colour the letters are filled with, or null when a photograph fills them. */
|
||||
/** The colour the letters are filled with, or null when a photograph, a gradient or a pattern fills them. */
|
||||
export function letterFill(el: BookTextElement): string | null {
|
||||
const fill = el.fillPaint;
|
||||
if (!fill) return el.color;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createMarkdownHtml } from './markdownHtml';
|
||||
import { SafeMarkdown } from './SafeMarkdown';
|
||||
|
||||
describe('SafeMarkdown (#2692)', () => {
|
||||
it('FE-COMP-SAFEMD-001: renders GitHub markdown with kept line breaks and safe links', () => {
|
||||
render(<SafeMarkdown>{'# Title\nfirst\nsecond\n\n[site](https://example.com)'}</SafeMarkdown>);
|
||||
expect(screen.getByRole('heading', { name: 'Title' })).toBeInTheDocument();
|
||||
expect(document.querySelector('br')).not.toBeNull();
|
||||
const link = screen.getByRole('link', { name: 'site' });
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
|
||||
});
|
||||
|
||||
it('FE-COMP-SAFEMD-002: shows raw HTML as text and never as an element', () => {
|
||||
render(<SafeMarkdown>{'Room <TBD> <img src=x onerror=alert(1)>'}</SafeMarkdown>);
|
||||
expect(document.querySelector('img')).toBeNull();
|
||||
expect(screen.getByText(/Room <TBD>/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMarkdownHtml (#2692)', () => {
|
||||
it('FE-COMP-SAFEMD-003: returns the sanitized HTML, the same for the same text', () => {
|
||||
const markdown = createMarkdownHtml();
|
||||
try {
|
||||
const html = markdown.html('**bold** and <script>alert(1)</script>');
|
||||
expect(html).toContain('<strong>bold</strong>');
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(markdown.html('**bold** and <script>alert(1)</script>')).toBe(html);
|
||||
expect(markdown.html('- a\n- b')).toContain('<li>a</li>');
|
||||
} finally {
|
||||
markdown.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from './markdownSanitize';
|
||||
|
||||
/**
|
||||
* Text a user wrote in markdown (a place's description, its notes), rendered
|
||||
* the way the planner shows it: GitHub flavour, line breaks kept, raw HTML
|
||||
* shown as text, the rest sanitized, links in a new tab without an opener.
|
||||
* Safe for pages strangers see, like a shared trip (#2692).
|
||||
*/
|
||||
export function SafeMarkdown({ children }: { children: string }) {
|
||||
return (
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
rehypePlugins={sanitizedMarkdownPlugins}
|
||||
components={sanitizedMarkdownComponents}
|
||||
>
|
||||
{children}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { flushSync } from 'react-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { SafeMarkdown } from './SafeMarkdown';
|
||||
|
||||
/**
|
||||
* Turns markdown into the HTML {@link SafeMarkdown} renders, for output built as
|
||||
* a string such as the PDF export (#2692). It renders through the same
|
||||
* pipeline into a detached node, so the sanitizer is the one the app uses and
|
||||
* no second markdown library is bundled. Call it outside a render and dispose
|
||||
* of it when done; the same text is rendered once.
|
||||
*/
|
||||
export function createMarkdownHtml(): { html: (text: string) => string; dispose: () => void } {
|
||||
const host = document.createElement('div');
|
||||
const root = createRoot(host);
|
||||
const done = new Map<string, string>();
|
||||
return {
|
||||
html: (text) => {
|
||||
const known = done.get(text);
|
||||
if (known !== undefined) return known;
|
||||
flushSync(() => root.render(<SafeMarkdown>{text}</SafeMarkdown>));
|
||||
done.set(text, host.innerHTML);
|
||||
return host.innerHTML;
|
||||
},
|
||||
dispose: () => root.unmount(),
|
||||
};
|
||||
}
|
||||
@@ -224,7 +224,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
drag={dragFor(row)}
|
||||
onOpen={() => {
|
||||
if (editing) tl.editTransport(row.res)
|
||||
else shell.openSheet('transport', { reservationId: row.res.id })
|
||||
else shell.openSheet('transport', { reservationId: row.res.id, legIndex: row.res.__leg?.index })
|
||||
}}
|
||||
/>
|
||||
{dayScheduleFor('reservation', row.res.id)}
|
||||
|
||||
@@ -265,7 +265,7 @@ export function TransportRow({ res, dayId, chrome, reorder, drag, onOpen }: {
|
||||
const time = start
|
||||
? `${fmtTime(start, chrome)}${phase === 'single' && end ? ` – ${fmtTime(end, chrome)}` : ''}`
|
||||
: ''
|
||||
const sub = transportSubtitle(res)
|
||||
const sub = transportSubtitle(res, chrome.t)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder'
|
||||
import type { MergedItem } from '../../../../utils/dayMerge'
|
||||
import type { TransitLegDisplay } from '../../../../components/Planner/transitDisplay'
|
||||
import { transportSubtitle as dayPlanTransportSubtitle, type TransportLegDetail } from '../../../../components/Planner/transportDetailModel'
|
||||
import { projectDayItinerary } from '../../../../components/Map/dayTourProjection'
|
||||
import type { Accommodation, Assignment, Day, DayNote, Place, Reservation, RouteSegment, TranslationFn } from '../../../../types'
|
||||
|
||||
@@ -16,7 +17,7 @@ import type { Accommodation, Assignment, Day, DayNote, Place, Reservation, Route
|
||||
|
||||
/** A day-timeline transport row can be a synthetic per-leg expansion of a multi-leg booking. */
|
||||
export type TransportEntry = Reservation & {
|
||||
__leg?: { index: number; total: number; from: string | null; to: string | null }
|
||||
__leg?: TransportLegDetail & { total: number }
|
||||
}
|
||||
|
||||
export interface TransitMeta {
|
||||
@@ -48,26 +49,8 @@ export function getTransitMeta(res: Reservation): TransitMeta | null {
|
||||
}
|
||||
|
||||
/** Same subtitle language as the desktop day plan: airline/number, train/platform/seat, leg detail. */
|
||||
export function transportSubtitle(res: TransportEntry): string {
|
||||
const meta = parseReservationMeta(res) as Record<string, string | undefined>
|
||||
if (res.__leg) {
|
||||
const parts = [res.__leg.from, res.__leg.to].filter(Boolean).length
|
||||
? [[res.__leg.from, res.__leg.to].filter(Boolean).join(' → ')]
|
||||
: []
|
||||
return parts.join(' · ')
|
||||
}
|
||||
if (res.type === 'flight') {
|
||||
const parts = [meta.airline, meta.flight_number].filter(Boolean) as string[]
|
||||
if (meta.departure_airport || meta.arrival_airport) {
|
||||
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
if (res.type === 'train') {
|
||||
return [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : '']
|
||||
.filter(Boolean).join(' · ')
|
||||
}
|
||||
return res.location || ''
|
||||
export function transportSubtitle(res: TransportEntry, t: TranslationFn): string {
|
||||
return dayPlanTransportSubtitle(res, t) ?? (res.location || '')
|
||||
}
|
||||
|
||||
const sameCoord = (a: [number, number], b: [number, number]): boolean =>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from '../../../../i18n'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constants'
|
||||
import { splitReservationDateTime } from '../../../../utils/formatters'
|
||||
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../../../utils/flightLegs'
|
||||
import { getFlightLegs, getTrainLegs, usesStationRoute, type FlightLeg, type TrainLeg } from '../../../../utils/flightLegs'
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import { runsOnDay } from '../../../../utils/reservationRoutes'
|
||||
import { filesFor } from '../../../../utils/reservationFiles'
|
||||
@@ -19,6 +19,8 @@ import { Eyebrow, INNER_CLS, StatBox, TileHeader, displayTime } from './MTripShe
|
||||
|
||||
interface TransportSheetPayload {
|
||||
reservationId?: number
|
||||
/** The leg of a multi-leg train or flight the day plan row stood for (#2701). */
|
||||
legIndex?: number
|
||||
}
|
||||
|
||||
interface TransitLeg {
|
||||
@@ -118,24 +120,38 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const to = (res.endpoints || []).find(e => e.role === 'to')
|
||||
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
const depTime = from?.local_time || startTime
|
||||
const arrTime = to?.local_time || endTime
|
||||
|
||||
const subParts: string[] = []
|
||||
if (meta.airline) subParts.push(meta.airline)
|
||||
if (meta.flight_number) subParts.push(meta.flight_number)
|
||||
if (meta.train_number) subParts.push(meta.train_number)
|
||||
if (from?.name && to?.name) subParts.push(`${from.name} → ${to.name}`)
|
||||
else if (date) subParts.push(bookingDayLabel(date, locale))
|
||||
|
||||
const seat = meta.seat
|
||||
const platform = meta.platform
|
||||
const transitLegs: TransitLeg[] = Array.isArray(meta.transit?.legs) ? meta.transit.legs : []
|
||||
const bookingDepTime = from?.local_time || startTime
|
||||
const bookingArrTime = to?.local_time || endTime
|
||||
|
||||
// Per-segment booking codes (#1943), only on a real stopover booking: the
|
||||
// single-leg fallback would just echo the booking's own code shown below.
|
||||
const routeLegs = res.type === 'flight' ? getFlightLegs(res) : usesStationRoute(res.type) ? getTrainLegs(res) : []
|
||||
const isFlight = res.type === 'flight'
|
||||
const routeLegs: (FlightLeg | TrainLeg)[] = isFlight ? getFlightLegs(res) : usesStationRoute(res.type) ? getTrainLegs(res) : []
|
||||
const legCodes = routeLegs.length > 1 ? routeLegs.filter(l => l.confirmation_number) : []
|
||||
// Opened from one leg's row, the sheet shows that leg's own number, seat,
|
||||
// platform and times rather than the first leg's (#2701).
|
||||
const leg = routeLegs.length > 1 && payload.legIndex != null ? routeLegs[payload.legIndex] ?? null : null
|
||||
const legDetail = (leg ?? {}) as Partial<FlightLeg & TrainLeg>
|
||||
const depTime = leg ? leg.dep_time || '' : bookingDepTime
|
||||
const arrTime = leg ? leg.arr_time || '' : bookingArrTime
|
||||
const depLabel = (leg ? leg.from : from?.name) || t('reservations.time')
|
||||
const arrLabel = (leg ? leg.to : to?.name) || t('reservations.time')
|
||||
|
||||
const subParts: string[] = []
|
||||
const airline = leg ? legDetail.airline : meta.airline
|
||||
const flightNumber = leg ? legDetail.flight_number : meta.flight_number
|
||||
const trainNumber = leg ? legDetail.train_number : meta.train_number
|
||||
if (airline) subParts.push(airline)
|
||||
if (flightNumber) subParts.push(flightNumber)
|
||||
if (trainNumber) subParts.push(trainNumber)
|
||||
if (leg && (leg.from || leg.to)) subParts.push([leg.from, leg.to].filter(Boolean).join(' → '))
|
||||
else if (from?.name && to?.name) subParts.push(`${from.name} → ${to.name}`)
|
||||
else if (date) subParts.push(bookingDayLabel(date, locale))
|
||||
|
||||
// A flight's legs carry no seat or platform of their own, so those stay the booking's.
|
||||
const seat = leg && !isFlight ? legDetail.seat : meta.seat
|
||||
const platform = leg && !isFlight ? legDetail.platform : meta.platform
|
||||
const transitLegs: TransitLeg[] = Array.isArray(meta.transit?.legs) ? meta.transit.legs : []
|
||||
|
||||
const resFiles = filesFor(res, planner.files || [])
|
||||
|
||||
@@ -186,10 +202,10 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
{(depTime || arrTime || seat || platform) && (
|
||||
<div className="mt-[14px] flex gap-[6px]">
|
||||
{depTime && (
|
||||
<StatBox value={displayTime(depTime, locale, timeFormat)} label={from?.name || t('reservations.time')} />
|
||||
<StatBox value={displayTime(depTime, locale, timeFormat)} label={depLabel} />
|
||||
)}
|
||||
{arrTime && (
|
||||
<StatBox value={displayTime(arrTime, locale, timeFormat)} label={to?.name || t('reservations.time')} />
|
||||
<StatBox value={displayTime(arrTime, locale, timeFormat)} label={arrLabel} />
|
||||
)}
|
||||
{platform && <StatBox value={platform} label={t('reservations.meta.platform')} />}
|
||||
{seat && <StatBox value={seat} label={t('reservations.meta.seat')} />}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { Journey } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
import { useJourney } from './journey/useJourney'
|
||||
import { journeyYearLabel } from './journey/journeyModel'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../components/shared/DialogShell'
|
||||
import { EditorField, INPUT } from '../components/shared/dialogParts'
|
||||
@@ -438,7 +439,7 @@ function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?:
|
||||
<span className="absolute top-3.5 start-3.5 z-[2] inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-white text-[11px] font-semibold"
|
||||
style={{ background: 'rgba(255,255,255,0.18)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid rgba(255,255,255,0.26)' }}>
|
||||
<Calendar size={12} strokeWidth={2.2} />
|
||||
{new Date(j.created_at).getFullYear()}
|
||||
{journeyYearLabel(j)}
|
||||
</span>
|
||||
{lifecycle !== 'live' && (
|
||||
<span className="absolute top-3.5 end-3.5 z-[2] px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide text-white"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { journeyYearLabel } from './journeyModel';
|
||||
|
||||
describe('journeyYearLabel (#2685)', () => {
|
||||
it('FE-PAGE-JOURNEY-YEAR-001: names the year of the trips, not the year the journey was written', () => {
|
||||
expect(
|
||||
journeyYearLabel({ trip_date_min: '2024-09-12', trip_date_max: '2024-09-20', created_at: '2026-03-01 10:00:00' })
|
||||
).toBe('2024');
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-YEAR-002: a trip over New Year names both years', () => {
|
||||
expect(
|
||||
journeyYearLabel({ trip_date_min: '2024-12-28', trip_date_max: '2025-01-04', created_at: '2026-03-01 10:00:00' })
|
||||
).toBe('2024/2025');
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-YEAR-003: one known date is enough, and without any it falls back to when it was written', () => {
|
||||
expect(
|
||||
journeyYearLabel({ trip_date_min: null, trip_date_max: '2023-05-02', created_at: '2026-03-01 10:00:00' })
|
||||
).toBe('2023');
|
||||
expect(journeyYearLabel({ trip_date_min: null, trip_date_max: null, created_at: '2026-03-01 10:00:00' })).toBe(
|
||||
'2026'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/** The year a journey card names: the years its trips cover, not the year it was written (#2685). */
|
||||
export function journeyYearLabel(journey: {
|
||||
trip_date_min?: string | null;
|
||||
trip_date_max?: string | null;
|
||||
created_at?: string | number | null;
|
||||
}): string {
|
||||
// The dates are calendar dates, so the year is read from the text, not through a Date in some zone.
|
||||
const from = journey.trip_date_min?.slice(0, 4);
|
||||
const to = journey.trip_date_max?.slice(0, 4);
|
||||
if (from && to && to !== from) return `${from}/${to}`;
|
||||
if (from || to) return (from || to) as string;
|
||||
return journey.created_at ? String(new Date(journey.created_at).getFullYear()) : '';
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { SharedPlaceDetails } from './SharedPlaceDetails';
|
||||
|
||||
/** FE-PAGE-SHARED-PLACE-001: a stop's description and notes on a shared trip, read as the planner shows them (#2692). */
|
||||
|
||||
describe('SharedPlaceDetails', () => {
|
||||
it('FE-PAGE-SHARED-PLACE-001: renders markdown in the description and notes, and keeps raw HTML as text', () => {
|
||||
render(
|
||||
<SharedPlaceDetails
|
||||
place={{
|
||||
name: 'Market',
|
||||
address: 'Market Square 1',
|
||||
description: [
|
||||
'## Opening',
|
||||
'',
|
||||
'| Day | Hours |',
|
||||
'| --- | --- |',
|
||||
'| Sat | 8 to 14 |',
|
||||
'',
|
||||
'<img src=x onerror=alert(1)>',
|
||||
].join('\n'),
|
||||
notes: ['- fruit', '- bread'].join('\n'),
|
||||
}}
|
||||
assignmentNotes="**Bring** cash"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: 'Opening' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: '8 to 14' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Bring').tagName).toBe('STRONG');
|
||||
expect(screen.getByText('fruit').tagName).toBe('LI');
|
||||
expect(document.querySelector('img[src="x"]')).toBeNull();
|
||||
expect(screen.queryByText('## Opening')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { fs } from '../../components/shared/DialogShell'
|
||||
import { Tooltip } from '../../components/shared/Tooltip'
|
||||
import { WhiteBadge } from '../../components/Planner/planParts'
|
||||
import { SafeMarkdown } from '../../components/shared/SafeMarkdown'
|
||||
import { getGoogleMapsUrlForPlace } from '../../components/Planner/placeGoogleMaps'
|
||||
import { formatDurationMinutes, isHttpUrl, plannedStayMinutes } from './sharedTripModel'
|
||||
|
||||
@@ -61,7 +62,7 @@ export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPl
|
||||
</div>
|
||||
)}
|
||||
{place.description && (
|
||||
<div className="whitespace-pre-wrap text-content-secondary [overflow-wrap:anywhere]" style={fs(12, 'body')}>{place.description}</div>
|
||||
<div className="collab-note-md-full text-content-secondary [overflow-wrap:anywhere]" style={fs(12, 'body')}><SafeMarkdown>{place.description}</SafeMarkdown></div>
|
||||
)}
|
||||
{dayNote && <NoteLine label={t('places.assignmentNotes')} text={dayNote} strong />}
|
||||
{placeNote && <NoteLine label={t('places.formNotes')} text={placeNote} />}
|
||||
@@ -98,7 +99,7 @@ function NoteLine({ label, text, strong = false }: { label: string; text: string
|
||||
<Tooltip label={label} placement="top">
|
||||
<div className={`flex items-start gap-1.5 rounded-lg bg-surface-secondary px-2 py-1.5 ${strong ? 'text-content-secondary' : 'text-content-muted'}`} style={fs(11.5, 'body')}>
|
||||
<FileText size={11} strokeWidth={2} className="mt-0.5 flex-none text-content-faint" aria-label={label} />
|
||||
<span className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]">{text}</span>
|
||||
<div className="collab-note-md-full min-w-0 [overflow-wrap:anywhere]"><SafeMarkdown>{text}</SafeMarkdown></div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ import { aiConnectionsRepo } from '../repo/aiConnectionsRepo';
|
||||
import { assistantRepo } from '../repo/assistantRepo';
|
||||
import { OnlineOnlyError } from '../repo/onlineOnly';
|
||||
import type { Assignment } from '../types';
|
||||
import { browserTimeZone } from '../utils/localDate';
|
||||
import { randomId } from '../utils/randomId';
|
||||
import type { RouteProfileFocus } from '../utils/routeGeometry';
|
||||
import { assistantScopeOf, assistantTargetOf, type AssistantChatKey } from './assistantChatKey';
|
||||
@@ -331,7 +332,7 @@ function errorCodeOf(err: unknown): AssistantErrorCode {
|
||||
function clientContext() {
|
||||
return {
|
||||
locale: useSettingsStore.getState().settings.language || 'en',
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
timeZone: browserTimeZone() ?? 'UTC',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { forgetServerLanguage } from './settingsStore'
|
||||
import { forgetResumeRoute } from '../utils/resumeRoute'
|
||||
import { markSignedOut, clearSignedOut } from '../utils/signedOut'
|
||||
import { forgetPushDeviceOnLogout, resyncPushSubscription } from '../push/webPush'
|
||||
import { browserTimeZone } from '../utils/localDate'
|
||||
|
||||
/** How long a logout waits for the queued changes to go out. */
|
||||
const LOGOUT_FLUSH_MS = 5000
|
||||
@@ -154,7 +155,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
hasMapsKey: false,
|
||||
hasAmapKey: false,
|
||||
placesProvider: 'auto',
|
||||
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
serverTimezone: browserTimeZone() ?? '',
|
||||
appRequireMfa: false,
|
||||
tripRemindersEnabled: false,
|
||||
placesPhotosEnabled: true,
|
||||
|
||||
@@ -54,6 +54,7 @@ interface Host {
|
||||
styleClip: BookCopiedStyle | null;
|
||||
commit: (fn: (doc: BookDocument) => BookDocument) => void;
|
||||
apply: (fn: (doc: BookDocument) => BookDocument) => void;
|
||||
refuseFull: () => void;
|
||||
}
|
||||
|
||||
type Set = (partial: Partial<Host> | ((s: Host) => Partial<Host>)) => void;
|
||||
@@ -370,7 +371,11 @@ function panelActions(
|
||||
if (!doc) return;
|
||||
const chosen = spread.elements.filter((e) => ids.includes(e.id));
|
||||
// The page's own cap: copies that would make the book unsaveable are not made.
|
||||
if (!chosen.length || spread.elements.length + chosen.length > MAX_SPREAD_ELEMENTS) return;
|
||||
if (!chosen.length) return;
|
||||
if (spread.elements.length + chosen.length > MAX_SPREAD_ELEMENTS) {
|
||||
get().refuseFull();
|
||||
return;
|
||||
}
|
||||
const copies = chosen.map(
|
||||
(e) =>
|
||||
({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import type { BookDocument, BookElement, BookFrame, BookLayout, BookSpread } from '@trek/shared'
|
||||
import type { BookAlignTo, BookDocument, BookElement, BookFrame, BookLayout, BookSpread } from '@trek/shared'
|
||||
import {
|
||||
MAX_BOOK_LAYOUTS, MAX_SPREAD_ELEMENTS, applySavedBookLayout as applySavedLayout,
|
||||
bookLayoutFromSpread as layoutFromSpread, canEditSpread as isMovableSpread, fitBookTexts, innerSpreadBounds as innerBounds,
|
||||
@@ -35,6 +35,21 @@ interface StudioState extends StudioLayerActions {
|
||||
gestureBase: BookDocument | null
|
||||
/** Elements copied for pasting onto any page of the book (#2316). Kept across page switches. */
|
||||
clipboard: StudioClipboard | null
|
||||
/**
|
||||
* Counts the times something was not added because the page already holds
|
||||
* MAX_SPREAD_ELEMENTS. The Studio says so once per count (StudioNotices), so
|
||||
* a full page no longer swallows a click without a word.
|
||||
*/
|
||||
refusedFull: number
|
||||
/** Notes that an addition was refused because the page is full. */
|
||||
refuseFull: () => void
|
||||
/**
|
||||
* What the arrange section lines things up with, as last picked: kept for the
|
||||
* session, so a new selection or another kind of element does not reset it.
|
||||
* Null until somebody picks; the section then chooses by how much is selected.
|
||||
*/
|
||||
alignTo: BookAlignTo | null
|
||||
setAlignTo: (to: BookAlignTo) => void
|
||||
/**
|
||||
* The page a chat card points at, outlined in the page rail (journey-studio.md
|
||||
* 4.2). Not part of the book or its history; opening a book clears it.
|
||||
@@ -147,6 +162,10 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
future: [],
|
||||
gestureBase: null,
|
||||
clipboard: null,
|
||||
refusedFull: 0,
|
||||
refuseFull: () => set(st => ({ refusedFull: st.refusedFull + 1 })),
|
||||
alignTo: null,
|
||||
setAlignTo: to => set({ alignTo: to }),
|
||||
hintSpread: null,
|
||||
readOnly: false,
|
||||
|
||||
@@ -228,7 +247,11 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
addElement: (spreadIndex, el) => {
|
||||
// The page's own cap, as paste and duplicate keep it: what would make the book unsaveable is not added.
|
||||
const target = get().doc?.spreads[spreadIndex]
|
||||
if (!target || target.elements.length >= MAX_SPREAD_ELEMENTS) return false
|
||||
if (!target) return false
|
||||
if (target.elements.length >= MAX_SPREAD_ELEMENTS) {
|
||||
get().refuseFull()
|
||||
return false
|
||||
}
|
||||
get().commit(doc =>
|
||||
replaceSpread(doc, spreadIndex, sp => ({ ...sp, elements: [...sp.elements, el] })))
|
||||
/*
|
||||
@@ -268,6 +291,10 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
const room = Math.max(0, MAX_SPREAD_ELEMENTS - target.elements.length)
|
||||
const source = doc.spreads.find(sp => sp.id === clipboard.fromSpreadId)
|
||||
// A copied group arrives as a group of its own, never as part of one already on the page.
|
||||
if (room === 0) {
|
||||
get().refuseFull()
|
||||
return
|
||||
}
|
||||
const copies = regroupCopies(pastedElements(clipboard, target, doc.page).slice(0, room), source, bookElementId)
|
||||
if (!copies.elements.length) return
|
||||
get().commit(d => replaceSpread(d, spreadIndex, sp => withCopies(sp, copies)))
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
inspector keep all their room. */
|
||||
|
||||
/* The toggle is the bar's newest tool, so it gives up its label first: from 1360
|
||||
to 1519 it is an icon while the others keep theirs, and the bar fits where it
|
||||
did before. The journey's name gives way too rather than push the bar past
|
||||
the window. */
|
||||
to 1519 it is an icon while the others keep theirs, and the head band fits
|
||||
where it did before. */
|
||||
@media (max-width: 1519px) {
|
||||
.trek-dash .st-assistant-toggle .st-tool-label {
|
||||
display: none;
|
||||
@@ -15,14 +14,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.trek-dash .st-bar .st-back {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trek-dash .st-bar .st-back > svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.trek-dash .st-assistant-notice {
|
||||
margin: 8px 12px 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/* ----------------- head band (StudioHeader.tsx) ----------------- *
|
||||
* The shape every TREK dialog head has: a tinted band, a raised tile, a small
|
||||
* line above the title and a round close button at the end. The band keeps the
|
||||
* glass of the old bar under its tint, so the journey still shows through.
|
||||
*/
|
||||
.trek-dash .st-head {
|
||||
/* Its own stacking context, above the rails: the panels below carry z-index 1,
|
||||
and without this the format menu would open behind them. */
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding-inline: 14px 16px;
|
||||
background:
|
||||
linear-gradient(
|
||||
color-mix(in srgb, var(--accent) 7%, transparent),
|
||||
color-mix(in srgb, var(--accent) 7%, transparent)
|
||||
),
|
||||
var(--glass-bg);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
|
||||
/* The journey's cover, so it is always clear whose book this is. */
|
||||
.trek-dash .st-head-tile {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
border-radius: 13px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-head-tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* The wordmark and the beta mark: what this screen is, quietly, above its name. */
|
||||
.trek-dash .st-head-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 17px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.trek-dash .st-head-eyebrow svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-title {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The format, as a chip that opens the format menu. */
|
||||
.trek-dash .st-head-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 22px;
|
||||
padding-inline: 7px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
color: var(--ink-2);
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color 0.16s,
|
||||
box-shadow 0.16s;
|
||||
}
|
||||
.trek-dash .st-head-chip:hover {
|
||||
color: var(--ink);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-head-chip:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-head-chip:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--accent-soft),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
.trek-dash .st-head-chip-shape {
|
||||
flex: none;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 2px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* The chip sits on the left, so its menu opens towards the page, not off the window. */
|
||||
.trek-dash .st-head-menu {
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: auto;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* Tools that belong together share one raised group, like a segmented control. */
|
||||
.trek-dash .st-head-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
|
||||
.trek-dash .st-head-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: none;
|
||||
margin-inline-start: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-sm);
|
||||
color: var(--ink-3);
|
||||
transition: color 0.16s;
|
||||
}
|
||||
.trek-dash .st-head-close:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-head-close:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-head-close:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--accent-soft),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
|
||||
/* The book view moved to the zoom, where the page's view lives; on reads as pressed. */
|
||||
.trek-dash .st-zoom button.is-on {
|
||||
background: color-mix(in oklch, var(--accent) 14%, transparent);
|
||||
color: var(--ink);
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
z-index: 9999;
|
||||
inset: 16px;
|
||||
display: grid;
|
||||
grid-template-rows: 56px minmax(0, 1fr);
|
||||
grid-template-rows: 72px minmax(0, 1fr);
|
||||
border-radius: var(--r-xl);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--glass-border);
|
||||
@@ -88,66 +88,7 @@
|
||||
.st-exit { animation-name: st-fade-out; }
|
||||
}
|
||||
|
||||
/* ----------------- top bar ----------------- */
|
||||
.trek-dash .st-bar {
|
||||
/* Its own stacking context, above the rails: the panels below carry z-index 1,
|
||||
and without this the format menu would open behind them. */
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding-block: 0;
|
||||
padding-inline: 10px 18px;
|
||||
min-width: 0;
|
||||
background: var(--glass-bg);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
|
||||
.trek-dash .st-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 12px 0 8px;
|
||||
border-radius: 999px;
|
||||
color: var(--ink-2);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
max-width: 240px;
|
||||
transition: background .18s, color .18s;
|
||||
}
|
||||
.trek-dash .st-back:hover { background: var(--accent-soft); color: var(--ink); }
|
||||
.trek-dash .st-back .st-back-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The journey's own cover, so you never lose track of whose book this is. */
|
||||
.trek-dash .st-cover {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 7px;
|
||||
object-fit: cover;
|
||||
flex: none;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.trek-dash .st-bar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex: none;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
/* The undo/redo pair belongs together; everything else in the group breathes. */
|
||||
.trek-dash .st-bar-group .st-tool.is-icon + .st-tool.is-icon { margin-inline-start: -5px; }
|
||||
|
||||
/* ----------------- tools (head band and elsewhere) ----------------- */
|
||||
.trek-dash .st-tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1506,20 +1447,6 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ----------------- brand ----------------- *
|
||||
* The lockup takes the bar's ink through `currentColor`, so it is dark on a
|
||||
* light theme and light on a dark one from one copy of the artwork.
|
||||
*/
|
||||
.trek-dash .st-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--ink);
|
||||
min-width: 0;
|
||||
}
|
||||
.trek-dash .st-brand svg { flex: none; }
|
||||
|
||||
/* Small, quiet and set apart — a beta mark states a fact about the feature; it
|
||||
is not a call to action and should not read as one. */
|
||||
.trek-dash .st-beta {
|
||||
|
||||
@@ -44,10 +44,26 @@ const VECTOR_CACHE = 'gl-map-offline'
|
||||
*/
|
||||
const GLYPH_RANGES = ['0-255', '256-511', '512-767', '768-1023', '1024-1279', '1280-1535', '1536-1791', '8192-8447', '8448-8703']
|
||||
|
||||
interface StyleSource {
|
||||
type?: string
|
||||
url?: string
|
||||
tiles?: string[]
|
||||
minzoom?: number
|
||||
maxzoom?: number
|
||||
}
|
||||
|
||||
/** One tiled source of the style, with the zooms it actually serves. */
|
||||
interface TileSource {
|
||||
template: string
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
vector: boolean
|
||||
}
|
||||
|
||||
interface StyleDoc {
|
||||
sprite?: string
|
||||
glyphs?: string
|
||||
sources?: Record<string, { url?: string; tiles?: string[] }>
|
||||
sources?: Record<string, StyleSource>
|
||||
layers?: { layout?: { 'text-font'?: string[] } }[]
|
||||
}
|
||||
|
||||
@@ -100,28 +116,73 @@ async function openVectorCache(): Promise<Cache | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Tile coordinates covering the box, low zoom first so a truncated run is still useful. */
|
||||
function enumerateVectorTiles(bbox: TileBbox, minZoom: number, maxZoom: number): [number, number, number][] {
|
||||
const out: [number, number, number][] = []
|
||||
for (let z = minZoom; z <= maxZoom; z++) {
|
||||
// Same screen-widened rectangle as the raster side: the opening fitBounds
|
||||
// view shows more than the places extent on the non-limiting axis (#2180).
|
||||
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
out.push([z, x, y])
|
||||
if (out.length >= MAX_VECTOR_TILES) return out
|
||||
}
|
||||
/**
|
||||
* Tile URLs covering the box, vector sources first and low zoom first within
|
||||
* each, so a truncated run is still useful. A source is only asked for the
|
||||
* zooms it serves: OpenFreeMap's shaded relief stops at z6 and answers 404
|
||||
* above it.
|
||||
*/
|
||||
function enumerateTileUrls(bbox: TileBbox, sources: TileSource[]): string[] {
|
||||
const out: string[] = []
|
||||
for (const source of sources) {
|
||||
for (let z = Math.max(source.minZoom, VECTOR_MIN_ZOOM); z <= Math.min(source.maxZoom, VECTOR_MAX_ZOOM); z++) {
|
||||
if (!pushZoomTiles(out, source.template, bbox, z)) return out
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** One zoom's tiles of a source onto `out`; false once the run is full. */
|
||||
function pushZoomTiles(out: string[], template: string, bbox: TileBbox, z: number): boolean {
|
||||
// Same screen-widened rectangle as the raster side: the opening fitBounds
|
||||
// view shows more than the places extent on the non-limiting axis (#2180).
|
||||
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
out.push(template.replace('{z}', String(z)).replace('{x}', String(x)).replace('{y}', String(y)))
|
||||
if (out.length >= MAX_VECTOR_TILES) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The style's tiled sources, vector first. A source given as a TileJSON
|
||||
* reference gets its template and zooms from there: the tile path carries a
|
||||
* planet version, and asking without that segment answers 200 with an empty
|
||||
* body, so it cannot be guessed. A source without tiles (GeoJSON) is skipped.
|
||||
*/
|
||||
async function readTileSources(style: StyleDoc, cache: Cache | null): Promise<TileSource[]> {
|
||||
const out: TileSource[] = []
|
||||
for (const source of Object.values(style.sources ?? {})) {
|
||||
let { tiles, minzoom, maxzoom } = source
|
||||
if (!tiles?.[0] && source.url) {
|
||||
await warm(cache, source.url)
|
||||
const tileJson = await fetchJson<{ tiles?: string[]; minzoom?: number; maxzoom?: number }>(source.url)
|
||||
tiles = tileJson?.tiles
|
||||
minzoom ??= tileJson?.minzoom
|
||||
maxzoom ??= tileJson?.maxzoom
|
||||
}
|
||||
if (!tiles?.[0]) continue
|
||||
out.push({
|
||||
template: tiles[0],
|
||||
minZoom: minzoom ?? VECTOR_MIN_ZOOM,
|
||||
maxZoom: maxzoom ?? VECTOR_MAX_ZOOM,
|
||||
vector: source.type !== 'raster' && source.type !== 'raster-dem',
|
||||
})
|
||||
}
|
||||
return out.sort((a, b) => Number(b.vector) - Number(a.vector))
|
||||
}
|
||||
|
||||
/**
|
||||
* The device-wide half: the style, its TileJSON, the sprite and the glyphs.
|
||||
* Returns the tile URL template, or null when the style could not be read.
|
||||
* Returns the vector tile URL template, or null when the style could not be read.
|
||||
*/
|
||||
export async function prefetchStyleAssets(styleUrl: string): Promise<string | null> {
|
||||
return (await prefetchStyle(styleUrl))?.[0]?.template ?? null
|
||||
}
|
||||
|
||||
async function prefetchStyle(styleUrl: string): Promise<TileSource[] | null> {
|
||||
const cache = await openVectorCache()
|
||||
const style = await fetchJson<StyleDoc>(styleUrl)
|
||||
if (!style) return null
|
||||
@@ -148,19 +209,7 @@ export async function prefetchStyleAssets(styleUrl: string): Promise<string | nu
|
||||
)
|
||||
}
|
||||
|
||||
// The vector source is a TileJSON reference, and the tile path it hands back
|
||||
// carries a planet version. Asking without that segment answers 200 with an
|
||||
// empty body, so the template has to come from the TileJSON rather than be
|
||||
// guessed.
|
||||
for (const source of Object.values(style.sources ?? {})) {
|
||||
if (source.tiles?.[0]) return source.tiles[0]
|
||||
if (source.url) {
|
||||
await warm(cache, source.url)
|
||||
const tileJson = await fetchJson<{ tiles?: string[] }>(source.url)
|
||||
if (tileJson?.tiles?.[0]) return tileJson.tiles[0]
|
||||
}
|
||||
}
|
||||
return null
|
||||
return readTileSources(style, cache)
|
||||
}
|
||||
|
||||
export interface VectorPrefetchResult {
|
||||
@@ -184,25 +233,21 @@ export async function prefetchVectorForPlaces(
|
||||
const bbox = computeBbox(places)
|
||||
if (!bbox) return { tiles: 0, template: null }
|
||||
|
||||
const template = await prefetchStyleAssets(styleUrl)
|
||||
if (!template || isCancelled()) return { tiles: 0, template }
|
||||
const sources = await prefetchStyle(styleUrl)
|
||||
const template = sources?.[0]?.template ?? null
|
||||
if (!sources?.length || isCancelled()) return { tiles: 0, template }
|
||||
|
||||
const cache = await openVectorCache()
|
||||
const coords = enumerateVectorTiles(bbox, VECTOR_MIN_ZOOM, VECTOR_MAX_ZOOM)
|
||||
const urls = enumerateTileUrls(bbox, sources)
|
||||
|
||||
let cursor = 0
|
||||
let fetched = 0
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
while (cursor < coords.length) {
|
||||
while (cursor < urls.length) {
|
||||
if (isCancelled() || !navigator.onLine) return
|
||||
const next = coords[cursor++]
|
||||
if (!next) return
|
||||
const [z, x, y] = next
|
||||
const url = template!
|
||||
.replace('{z}', String(z))
|
||||
.replace('{x}', String(x))
|
||||
.replace('{y}', String(y))
|
||||
const url = urls[cursor++]
|
||||
if (!url) return
|
||||
if (await warm(cache, url)) fetched++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,11 @@ async function syncTrip(tripId: number): Promise<void> {
|
||||
// first sync is in. Ahead of the writes and rethrowing anything but a 404, it
|
||||
// threw the whole downloaded bundle away for that trip while the run still
|
||||
// reported it stored, so Settings said "N trips ready" over an empty database.
|
||||
await roadtripPreferencesRepo.read(tripId).catch(() => { /* optional addon, optional cache */ })
|
||||
// Asked only while the addon is on, like the tours below: with it off, every
|
||||
// trip of a dashboard load spent a request on a 404.
|
||||
if (useAddonStore.getState().isEnabled('roadtrip')) {
|
||||
await roadtripPreferencesRepo.read(tripId).catch(() => { /* optional cache */ })
|
||||
}
|
||||
|
||||
// Tours, the same way: optional, and never fatal. Without them every Tour
|
||||
// reads as a plain place offline. Asked only while the addon is on, so a
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { isLocalToday, localIsoDate, localMinutes } from './localDate'
|
||||
import { browserTimeZone, isLocalToday, isValidTimeZone, localIsoDate, localMinutes } from './localDate'
|
||||
|
||||
/**
|
||||
* "Today" must be the user's local calendar date. `toISOString()` is the UTC
|
||||
@@ -66,3 +66,26 @@ describe('isLocalToday', () => {
|
||||
expect(isLocalToday(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('browserTimeZone and isValidTimeZone (#2694)', () => {
|
||||
it('names the browser zone, and nothing when the browser names none', () => {
|
||||
expect(browserTimeZone()).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone)
|
||||
const original = Intl.DateTimeFormat.prototype.resolvedOptions
|
||||
const spy = vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function (this: Intl.DateTimeFormat) {
|
||||
return { ...original.call(this), timeZone: undefined as unknown as string }
|
||||
})
|
||||
try {
|
||||
expect(browserTimeZone()).toBeUndefined()
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts the zones Intl knows and refuses the rest', () => {
|
||||
expect(isValidTimeZone('Europe/Berlin')).toBe(true)
|
||||
expect(isValidTimeZone('UTC')).toBe(true)
|
||||
expect(isValidTimeZone('Mars/Olympus_Mons')).toBe(false)
|
||||
expect(isValidTimeZone('')).toBe(false)
|
||||
expect(isValidTimeZone(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,3 +27,24 @@ export function localMinutes(d: Date = new Date()): number {
|
||||
export function isLocalToday(date: string | null | undefined): boolean {
|
||||
return !!date && date.slice(0, 10) === localIsoDate()
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser's IANA time zone, or undefined when it cannot name one. Chrome
|
||||
* on Windows answers undefined for a system zone it cannot map, such as
|
||||
* Alberta's permanent UTC-6 (#2694), so every reader picks its own fallback.
|
||||
*/
|
||||
export function browserTimeZone(): string | undefined {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone as string | undefined
|
||||
return typeof tz === 'string' && tz ? tz : undefined
|
||||
}
|
||||
|
||||
/** Whether `Intl` accepts the zone; formatting with one it rejects throws a RangeError. */
|
||||
export function isValidTimeZone(tz: unknown): tz is string {
|
||||
if (typeof tz !== 'string' || !tz) return false
|
||||
try {
|
||||
new Intl.DateTimeFormat(undefined, { timeZone: tz })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('TransportRow', () => {
|
||||
|
||||
expect(screen.getByText('LH 714 to Tokyo')).toBeInTheDocument()
|
||||
expect(container.textContent).toContain('08:15 – 18:40')
|
||||
expect(screen.getByText('Lufthansa · LH714 · MUC → HND')).toBeInTheDocument()
|
||||
expect(screen.getByText('Lufthansa, LH714, MUC → HND')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-014: a multi-day span shows only the day-relevant time', () => {
|
||||
@@ -234,7 +234,7 @@ describe('TransportRow', () => {
|
||||
} as unknown as TransportEntry
|
||||
render(<TransportRow {...base} res={train} />)
|
||||
|
||||
expect(screen.getByText('ICE 599 · Gl. 7 · Sitz 21A')).toBeInTheDocument()
|
||||
expect(screen.getByText('ICE 599, reservations.meta.platform 7, reservations.meta.seat 21A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-018: an untimed booking of an unknown type still renders its title', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-TRSH-001 to FE-MOB-TRSH-033
|
||||
// FE-MOB-TRSH-001 to FE-MOB-TRSH-035
|
||||
|
||||
vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn() }))
|
||||
|
||||
@@ -442,4 +442,34 @@ describe('MTransportSheet', () => {
|
||||
expect(planner.handleSelectDay).toHaveBeenCalledWith(3, false)
|
||||
})
|
||||
})
|
||||
|
||||
// #2701: a leg row opens the sheet on that leg, not on the booking's first one.
|
||||
describe('opened from one leg of a multi-leg train', () => {
|
||||
const TRAIN = {
|
||||
...FLIGHT, id: 8, type: 'train', title: 'Takayama to Kyoto', confirmation_number: null,
|
||||
metadata: JSON.stringify({
|
||||
train_number: 'Hida 6', seat: '10-4C',
|
||||
legs: [
|
||||
{ from: 'Takayama', to: 'Nagoya', train_number: 'Hida 6', seat: '10-4C', platform: '1', dep_time: '09:40', arr_time: '12:10' },
|
||||
{ from: 'Nagoya', to: 'Kyoto', train_number: 'Nozomi 67', seat: '4-5A', platform: '14', dep_time: '12:35', arr_time: '13:10' },
|
||||
],
|
||||
}),
|
||||
} as unknown as Reservation
|
||||
|
||||
it("FE-MOB-TRSH-034: shows the second leg's own train, seat, platform and times", () => {
|
||||
render(<MTransportSheet planner={makePlanner({ reservations: [TRAIN] })} shell={makeShell({ sheet: { id: 'transport', payload: { reservationId: 8, legIndex: 1 } } })} />)
|
||||
expect(screen.getByText(/Nozomi 67/)).toBeInTheDocument()
|
||||
expect(screen.getByText('4-5A')).toBeInTheDocument()
|
||||
expect(screen.getByText('14')).toBeInTheDocument()
|
||||
expect(screen.getByText('12:35')).toBeInTheDocument()
|
||||
expect(screen.getByText('13:10')).toBeInTheDocument()
|
||||
expect(screen.queryByText('10-4C')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-035: without a leg the sheet still shows the booking as a whole', () => {
|
||||
render(<MTransportSheet planner={makePlanner({ reservations: [TRAIN] })} shell={makeShell({ sheet: { id: 'transport', payload: { reservationId: 8 } } })} />)
|
||||
expect(screen.getByText('10-4C')).toBeInTheDocument()
|
||||
expect(screen.queryByText('4-5A')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -128,14 +128,17 @@ describe('planTimelineModel — metadata parsing', () => {
|
||||
})
|
||||
|
||||
describe('planTimelineModel — transportSubtitle', () => {
|
||||
// The labels stay keys, so the assertions read what the subtitle puts where.
|
||||
const tr: TranslationFn = (key) => key
|
||||
|
||||
it('FE-MOB-PTLM-006: renders a synthetic leg as from → to', () => {
|
||||
const res = { ...buildReservation({ type: 'flight' }), __leg: { index: 0, total: 2, from: 'FRA', to: 'IST' } } as TransportEntry
|
||||
expect(transportSubtitle(res)).toBe('FRA → IST')
|
||||
expect(transportSubtitle(res, tr)).toBe('FRA → IST')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-007: renders a leg without endpoints as an empty subtitle', () => {
|
||||
const res = { ...buildReservation({ type: 'flight' }), __leg: { index: 1, total: 2, from: null, to: null } } as TransportEntry
|
||||
expect(transportSubtitle(res)).toBe('')
|
||||
expect(transportSubtitle(res, tr)).toBe('')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-008: joins airline, flight number and airports for a flight', () => {
|
||||
@@ -143,25 +146,42 @@ describe('planTimelineModel — transportSubtitle', () => {
|
||||
type: 'flight',
|
||||
metadata: JSON.stringify({ airline: 'LH', flight_number: 'LH123', departure_airport: 'FRA', arrival_airport: 'HND' }),
|
||||
})
|
||||
expect(transportSubtitle(res)).toBe('LH · LH123 · FRA → HND')
|
||||
expect(transportSubtitle(res, tr)).toBe('LH, LH123, FRA → HND')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-009: omits the airport pair when neither airport is known', () => {
|
||||
const res = buildReservation({ type: 'flight', metadata: JSON.stringify({ airline: 'LH', flight_number: 'LH123' }) })
|
||||
expect(transportSubtitle(res)).toBe('LH · LH123')
|
||||
expect(transportSubtitle(res, tr)).toBe('LH, LH123')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-010: labels platform and seat for a train', () => {
|
||||
const full = buildReservation({
|
||||
type: 'train', metadata: JSON.stringify({ train_number: 'ICE 599', platform: '7', seat: '32A' }),
|
||||
})
|
||||
expect(transportSubtitle(full)).toBe('ICE 599 · Gl. 7 · Sitz 32A')
|
||||
expect(transportSubtitle(buildReservation({ type: 'train', metadata: '{"train_number":"ICE 599"}' }))).toBe('ICE 599')
|
||||
expect(transportSubtitle(full, tr)).toBe('ICE 599, reservations.meta.platform 7, reservations.meta.seat 32A')
|
||||
expect(transportSubtitle(buildReservation({ type: 'train', metadata: '{"train_number":"ICE 599"}' }), tr)).toBe('ICE 599')
|
||||
})
|
||||
|
||||
// #2701: a leg names its own train, platform and seat, not the first leg's.
|
||||
it('FE-MOB-PTLM-011b: a train leg carries its own number, platform and seat', () => {
|
||||
const res = {
|
||||
...buildReservation({ type: 'train', metadata: JSON.stringify({ train_number: 'Hida 6', seat: '10-4C' }) }),
|
||||
__leg: { index: 1, total: 2, from: 'Nagoya', to: 'Kyoto', train_number: 'Nozomi 67', platform: '14', seat: '4-5A' },
|
||||
} as TransportEntry
|
||||
expect(transportSubtitle(res, tr)).toBe('Nozomi 67, reservations.meta.platform 14, reservations.meta.seat 4-5A, Nagoya → Kyoto')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-011c: a flight leg carries its own airline and flight number', () => {
|
||||
const res = {
|
||||
...buildReservation({ type: 'flight' }),
|
||||
__leg: { index: 1, total: 2, from: 'IST', to: 'HND', airline: 'TK', flight_number: 'TK52' },
|
||||
} as TransportEntry
|
||||
expect(transportSubtitle(res, tr)).toBe('TK, TK52, IST → HND')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-011: falls back to the location for any other type', () => {
|
||||
expect(transportSubtitle(buildReservation({ type: 'bus', location: 'Central Station' }))).toBe('Central Station')
|
||||
expect(transportSubtitle(buildReservation({ type: 'bus', location: null }))).toBe('')
|
||||
expect(transportSubtitle(buildReservation({ type: 'bus', location: 'Central Station' }), tr)).toBe('Central Station')
|
||||
expect(transportSubtitle(buildReservation({ type: 'bus', location: null }), tr)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -487,7 +487,7 @@ describe('a shape', () => {
|
||||
load([shape({ shape: 'rect', fill: '#c2410c' })])
|
||||
const { container } = draw()
|
||||
|
||||
fireEvent.click(container.querySelector('.st-mini-shapes [aria-label="ellipse"]')!)
|
||||
fireEvent.click(container.querySelector('.st-mini-shapes [aria-label="journey.studio.shapeName.ellipse"]')!)
|
||||
|
||||
expect(el()).toMatchObject({ shape: 'ellipse', fill: '#c2410c', frame: FRAME })
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BookPaint } from '@trek/shared';
|
||||
import { BOOK_PATTERNS, type BookPaint } from '@trek/shared';
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { StudioPaintPicker } from '../../../src/components/Studio/StudioPaintPicker';
|
||||
@@ -172,7 +172,7 @@ describe('a pattern', () => {
|
||||
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(container.querySelectorAll('.st-pattern-tile')).toHaveLength(BOOK_PATTERNS.length);
|
||||
expect(need(container.querySelector('.st-pattern-tile[aria-checked="true"]')).getAttribute('aria-label')).toBe(
|
||||
'journey.studio.pattern.dots'
|
||||
);
|
||||
@@ -182,7 +182,7 @@ describe('a pattern', () => {
|
||||
|
||||
fireEvent.click(need(container.querySelector('[role="switch"]')));
|
||||
expect(last(channel.commit)).toMatchObject({ bg: null });
|
||||
expect(container.querySelectorAll('.st-pattern-tile.is-clear')).toHaveLength(12);
|
||||
expect(container.querySelectorAll('.st-pattern-tile.is-clear')).toHaveLength(BOOK_PATTERNS.length);
|
||||
fireEvent.click(need(container.querySelector('[role="switch"]')));
|
||||
expect(last(channel.commit)).toMatchObject({ bg: '#ffffff' });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { useStudioStore } from '../../../src/store/studioStore';
|
||||
|
||||
/** FE-STUDIO-ALIGNTO-001: what the arrange section lines things up with stays as picked across selections. */
|
||||
describe('the align target', () => {
|
||||
it('FE-STUDIO-ALIGNTO-001: is kept when the selection changes', () => {
|
||||
useStudioStore.setState({ alignTo: null, selection: [] });
|
||||
useStudioStore.getState().setAlignTo('safe');
|
||||
useStudioStore.getState().select(['a']);
|
||||
useStudioStore.getState().select(['b', 'c']);
|
||||
expect(useStudioStore.getState().alignTo).toBe('safe');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { MAX_SPREAD_ELEMENTS, type BookDocument, type BookElement, type BookSpread } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const toast = vi.hoisted(() => ({ warning: vi.fn() }));
|
||||
vi.mock('../../../src/components/shared/Toast', () => ({ useToast: () => toast }));
|
||||
vi.mock('../../../src/i18n', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
import { StudioNotices } from '../../../src/components/Studio/StudioNotices';
|
||||
import { useStudioStore } from '../../../src/store/studioStore';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-FULL-001 to FE-STUDIO-FULL-004: a page that already holds
|
||||
* MAX_SPREAD_ELEMENTS takes nothing more, and the Studio says so instead of
|
||||
* swallowing the click.
|
||||
*/
|
||||
|
||||
const el = (id: string): BookElement =>
|
||||
({
|
||||
id,
|
||||
kind: 'shape',
|
||||
frame: { x: 0, y: 0, w: 10, h: 10 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
shape: 'rect',
|
||||
fill: '#000000',
|
||||
gradient: 'none',
|
||||
stroke: null,
|
||||
strokeWidth: 0,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
}) as BookElement;
|
||||
|
||||
const spread = (id: string, elements: BookElement[]): BookSpread => ({
|
||||
id,
|
||||
role: 'inner',
|
||||
background: null,
|
||||
elements,
|
||||
parked: [],
|
||||
entryId: null,
|
||||
});
|
||||
|
||||
const book = (...spreads: BookSpread[]): BookDocument =>
|
||||
({
|
||||
version: 1,
|
||||
title: 'T',
|
||||
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
|
||||
spreads,
|
||||
}) as BookDocument;
|
||||
|
||||
const full = () => Array.from({ length: MAX_SPREAD_ELEMENTS }, (_, i) => el(`f${i}`));
|
||||
const store = () => useStudioStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
store().load(book(spread('full', full()), spread('room', [el('a')])));
|
||||
useStudioStore.setState({ refusedFull: 0, clipboard: null });
|
||||
});
|
||||
|
||||
describe('a full page', () => {
|
||||
it('FE-STUDIO-FULL-001: refuses one more element and counts the refusal', () => {
|
||||
expect(store().addElement(0, el('extra'))).toBe(false);
|
||||
expect(store().doc?.spreads[0]?.elements).toHaveLength(MAX_SPREAD_ELEMENTS);
|
||||
expect(store().refusedFull).toBe(1);
|
||||
expect(store().addElement(1, el('fits'))).toBe(true);
|
||||
expect(store().refusedFull).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FULL-002: a paste onto it is refused and counted', () => {
|
||||
useStudioStore.setState({ clipboard: { fromSpreadId: 'room', elements: [el('a')] } });
|
||||
store().paste(0);
|
||||
expect(store().doc?.spreads[0]?.elements).toHaveLength(MAX_SPREAD_ELEMENTS);
|
||||
expect(store().refusedFull).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FULL-003: a duplicate on it is refused and counted', () => {
|
||||
store().duplicate(0, ['f0']);
|
||||
expect(store().doc?.spreads[0]?.elements).toHaveLength(MAX_SPREAD_ELEMENTS);
|
||||
expect(store().refusedFull).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FULL-004: the Studio says it once per refusal', () => {
|
||||
toast.warning.mockClear();
|
||||
render(<StudioNotices />);
|
||||
expect(toast.warning).not.toHaveBeenCalled();
|
||||
act(() => store().refuseFull());
|
||||
expect(toast.warning).toHaveBeenCalledTimes(1);
|
||||
expect(toast.warning).toHaveBeenLastCalledWith('journey.studio.pageFull');
|
||||
act(() => store().refuseFull());
|
||||
expect(toast.warning).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -147,7 +147,7 @@ describe('prefetchVectorForPlaces', () => {
|
||||
const zooms = new Set(
|
||||
[...cache.store.keys()]
|
||||
.filter(u => u.endsWith('.pbf') && u.includes('/planet/'))
|
||||
.map(u => Number(u.split('/planet/')[1]!.split('/')[1])),
|
||||
.map(u => Number((u.split('/planet/')[1] ?? '').split('/')[1])),
|
||||
);
|
||||
// z14 is the last one OpenFreeMap serves; MapLibre scales it beyond that
|
||||
// rather than asking for another, which is why this stops where raster could not.
|
||||
@@ -166,7 +166,7 @@ describe('prefetchVectorForPlaces', () => {
|
||||
|
||||
const xs = [...cache.store.keys()]
|
||||
.filter(u => u.endsWith('.pbf') && u.includes('/planet/'))
|
||||
.map(u => u.split('/planet/')[1]!.split('/'))
|
||||
.map(u => (u.split('/planet/')[1] ?? '').split('/'))
|
||||
.filter(parts => Number(parts[1]) === 7)
|
||||
.map(parts => Number(parts[2]));
|
||||
expect((Math.max(...xs) - Math.min(...xs) + 1) * 256).toBeGreaterThanOrEqual(window.innerWidth);
|
||||
@@ -201,6 +201,46 @@ describe('prefetchVectorForPlaces', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// OpenFreeMap's liberty style lists its shaded relief (raster, up to z6) before the
|
||||
// vector planet. Taking the first source used to prefetch that relief at z0 to z14,
|
||||
// hundreds of 404s, and never the vector tiles the map needs offline.
|
||||
describe('a style with a raster source ahead of the vector one', () => {
|
||||
const places = [buildPlace({ lat: 52.52, lng: 13.405 })];
|
||||
const RELIEF = 'https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png';
|
||||
const LIBERTY = {
|
||||
sources: {
|
||||
ne2_shaded: { type: 'raster', tiles: [RELIEF], maxzoom: 6 },
|
||||
openmaptiles: { type: 'vector', url: 'https://tiles.openfreemap.org/planet' },
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((url: string, init?: { mode?: string }) => {
|
||||
requested.push({ url, mode: init?.mode });
|
||||
if (url === STYLE_URL) return Promise.resolve({ ok: true, json: () => Promise.resolve(LIBERTY), clone: () => ({}) });
|
||||
return Promise.resolve(respond(url));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-SYNC-GLPF-015: the template is the vector source, not the first one listed', async () => {
|
||||
await expect(prefetchStyleAssets(STYLE_URL)).resolves.toBe(TILE_TEMPLATE);
|
||||
});
|
||||
|
||||
it('FE-SYNC-GLPF-016: downloads the vector tiles and the relief only at the zooms it serves', async () => {
|
||||
const result = await prefetchVectorForPlaces(places, STYLE_URL);
|
||||
expect(result.template).toBe(TILE_TEMPLATE);
|
||||
const urls = requested.map(r => r.url);
|
||||
const vectorZooms = new Set(urls.filter(u => u.includes('/planet/')).map(u => Number((u.split('/planet/')[1] ?? '').split('/')[1])));
|
||||
expect(Math.max(...vectorZooms)).toBe(14);
|
||||
const reliefZooms = urls.filter(u => u.includes('/ne2sr/')).map(u => Number((u.split('/ne2sr/')[1] ?? '').split('/')[0]));
|
||||
expect(reliefZooms.length).toBeGreaterThan(0);
|
||||
expect(Math.max(...reliefZooms)).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearVectorCache', () => {
|
||||
it('FE-SYNC-GLPF-012: drops the offline vector basemap', async () => {
|
||||
await clearVectorCache();
|
||||
|
||||
@@ -476,7 +476,9 @@ async function carryOn(
|
||||
const { text, calls, images } = answer;
|
||||
turn.history.push({ role: 'assistant', text, toolCalls: calls, providerState: step.providerState });
|
||||
for (const part of step.parts) {
|
||||
if (part.type !== 'tool-call') continue;
|
||||
// A web search is the provider's own record, kept in the provider state; an answer to it would
|
||||
// name a call the provider never made, and it refuses the next step over that.
|
||||
if (part.type !== 'tool-call' || part.toolName === ASSISTANT_WEB_SEARCH_STEP) continue;
|
||||
const seen = images.get(part.toolCallId);
|
||||
turn.history.push({
|
||||
role: 'tool',
|
||||
|
||||
@@ -160,7 +160,7 @@ export class JourneyBookMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'add_book_pages',
|
||||
description: `Add 1 to ${BOOK_OP_LIMITS.pagesPerCall} empty double spreads after afterSpreadId, always kept between the first and the last page, laid out on template when one is named and tied to the journey entry entryId when given. ${WRITE_ANSWER}`,
|
||||
description: `Add 1 to ${BOOK_OP_LIMITS.pagesPerCall} empty double spreads after afterSpreadId, always kept between the first and the last page, laid out on template when one is named (a built-in or decorated double-spread layout from list_book_layouts) and tied to the journey entry entryId when given. ${WRITE_ANSWER}`,
|
||||
inputSchema: { journeyId: idSchema, ...addBookPagesArgs },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: journeyAddonOn,
|
||||
@@ -208,7 +208,7 @@ export class JourneyBookMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'set_book_cover',
|
||||
description: `The cover of a journey's book: its layout (a single page template), its photo (from the journey's gallery, into a free frame, else a full bleed one behind everything), its title and its subtitle. A title or subtitle bound to the journey's own stays bound unless other words are given. With returnPrevious the answer also holds the cover as it was, under previous_spreads, and a digest of the cover as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
|
||||
description: `The cover of a journey's book: its layout (a built-in or decorated single-page template from list_book_layouts), its photo (from the journey's gallery, into a free frame, else a full bleed one behind everything), its title and its subtitle. A title or subtitle bound to the journey's own stays bound unless other words are given. With returnPrevious the answer also holds the cover as it was, under previous_spreads, and a digest of the cover as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
|
||||
inputSchema: { journeyId: idSchema, ...setBookCoverArgs },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: journeyAddonOn,
|
||||
|
||||
@@ -347,8 +347,8 @@ export class PlacesMcp {
|
||||
name: 'search_place',
|
||||
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent. Searches the TREK index and OpenStreetMap, or Google Places alone with provider "google" and whenever the admin switch "Search with Google only" is on: if the instance has a plugin providing its own search index, call search_places_via_plugins as well, and note that ratings can only come from there.',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).max(500).describe('Place name or address to search for'),
|
||||
locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Only the Google provider honours it; the OpenStreetMap fallback ignores it'),
|
||||
query: z.string().min(1).max(500).describe('One place name or address. Several places in one query match none of them well; search each on its own, the calls can run side by side'),
|
||||
locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Use where the place should be, such as a stop already planned in that neighbourhood, not the middle of the city: the TREK index ranks by distance from it, OpenStreetMap orders its results by it and Google searches a circle around it'),
|
||||
lang: z.string().max(35).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja". Defaults to English'),
|
||||
provider: mapsSearchRequestSchema.shape.provider.describe('"google" sends this one search to Google Places alone instead of the TREK index and OpenStreetMap, the same as the "search Google instead" link under the results in the app. Ignored unless Google holds the keyed slot: an instance without a Google key, or one whose admin picked Amap or OpenStreetMap as the places provider, answers from the TREK index and OpenStreetMap as usual'),
|
||||
},
|
||||
|
||||
@@ -858,4 +858,22 @@ describe('runAgentLoop', () => {
|
||||
await runAgentLoop(unable.deps, unable.input, unable.writer, new AbortController().signal);
|
||||
expect(unable.requests[0]?.webSearch).toBe(false);
|
||||
});
|
||||
|
||||
it('ASTAGT-041: a step that searched the web and called a read answers only the read, never the search', async () => {
|
||||
const t = setup(
|
||||
[
|
||||
[
|
||||
{ type: 'web-search', query: 'Mount Takao trails' },
|
||||
{ type: 'tool-call', call: { id: 'call_1', name: 'list_places', args: { tripId: 1 } } },
|
||||
{ type: 'finish', reason: 'tool-calls' },
|
||||
],
|
||||
[{ type: 'text-delta', delta: 'Trail 1 up, trail 6 down.' }],
|
||||
],
|
||||
{ webSearch: true },
|
||||
true,
|
||||
);
|
||||
await runAgentLoop(t.deps, t.input, t.writer, new AbortController().signal);
|
||||
const answers = t.requests[1].messages.filter((m) => m.role === 'tool');
|
||||
expect(answers.map((m) => (m as { toolCallId: string }).toolCallId)).toEqual(['call_1']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
BOOK_FINDING_CODES,
|
||||
BOOK_OP_LIMITS,
|
||||
bookCoverTemplateIdSchema,
|
||||
bookAnyTemplateIdSchema,
|
||||
bookPageOpSchema,
|
||||
bookSpreadIdSchema,
|
||||
} from '../../book/book-ops.schema';
|
||||
@@ -63,7 +63,7 @@ export type BookCardLayout = z.infer<typeof bookCardLayoutSchema>;
|
||||
|
||||
/** `set_book_cover`'s arguments, null where the call leaves a part of the cover as it is. */
|
||||
export const bookCardCoverSchema = z.object({
|
||||
template: bookCoverTemplateIdSchema.nullable(),
|
||||
template: bookAnyTemplateIdSchema.nullable(),
|
||||
photoId: idSchema.nullable(),
|
||||
title: z.string().max(MAX_BOOK_TITLE).nullable(),
|
||||
subtitle: z.string().max(MAX_BOOK_TITLE).nullable(),
|
||||
|
||||
@@ -76,7 +76,12 @@ export function photoDpi(el: BookPhotoElement, info: BookPhotoInfo | undefined):
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** A family's average letter width per em at a weight: its measured bold from 600 on, else a guess a little wider. */
|
||||
function letterEm(font: string, weight: number): number {
|
||||
const face = bookFontFace(font);
|
||||
const regular = face?.letterEm ?? 0.5;
|
||||
return weight >= 600 ? (face?.boldEm ?? regular * 1.06) : regular;
|
||||
}
|
||||
|
||||
/**
|
||||
* The headroom on letter width the design engine sizes a text frame with. A
|
||||
@@ -124,7 +129,7 @@ export function bookLetterMm(el: BookTextElement, headroom = 1): number {
|
||||
const sizeMm = (orDefault(el.size, 11) * 25.4) / 72;
|
||||
const tracking = orDefault(el.tracking, 0);
|
||||
const caps = bookFontFace(el.font)?.caps ? 1 : (CASE_WIDTH[el.transform ?? 'none'] ?? 1);
|
||||
return sizeMm * (letterEm(el.font) * (el.weight >= 600 ? 1.06 : 1) * caps * headroom + tracking);
|
||||
return sizeMm * (letterEm(el.font, el.weight) * caps * headroom + tracking);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,6 +58,11 @@ export interface BookFontFace {
|
||||
* the face's own advance widths, for the text measure in book-findings.ts.
|
||||
*/
|
||||
letterEm: number;
|
||||
/**
|
||||
* The same at weight 700, measured the same way, for a family that ships a
|
||||
* real bold: a condensed bold sets up to a sixth wider than its regular.
|
||||
*/
|
||||
boldEm?: number;
|
||||
/** Set in capitals whatever is typed. */
|
||||
caps?: true;
|
||||
}
|
||||
@@ -81,6 +86,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: ALL,
|
||||
italics: [],
|
||||
letterEm: 0.507,
|
||||
boldEm: 0.519,
|
||||
},
|
||||
inter: {
|
||||
name: 'Inter',
|
||||
@@ -91,6 +97,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: ALL,
|
||||
italics: [],
|
||||
letterEm: 0.478,
|
||||
boldEm: 0.491,
|
||||
},
|
||||
montserrat: {
|
||||
name: 'Montserrat',
|
||||
@@ -101,6 +108,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: ALL,
|
||||
italics: [],
|
||||
letterEm: 0.507,
|
||||
boldEm: 0.536,
|
||||
},
|
||||
// ── serif ──
|
||||
serif: {
|
||||
@@ -112,6 +120,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: MID,
|
||||
italics: MID,
|
||||
letterEm: 0.469,
|
||||
boldEm: 0.481,
|
||||
},
|
||||
garamond: {
|
||||
name: 'EB Garamond',
|
||||
@@ -122,6 +131,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: [400, 500, 600, 700, 800],
|
||||
italics: [400, 500, 600, 700, 800],
|
||||
letterEm: 0.383,
|
||||
boldEm: 0.424,
|
||||
},
|
||||
// ── elegant display serifs ──
|
||||
playfair: {
|
||||
@@ -133,6 +143,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: [400, 500, 600, 700, 800, 900],
|
||||
italics: [400, 500, 600, 700, 800, 900],
|
||||
letterEm: 0.454,
|
||||
boldEm: 0.462,
|
||||
},
|
||||
fraunces: {
|
||||
name: 'Fraunces',
|
||||
@@ -143,6 +154,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: ALL,
|
||||
italics: ALL,
|
||||
letterEm: 0.467,
|
||||
boldEm: 0.497,
|
||||
},
|
||||
cormorant: {
|
||||
name: 'Cormorant Garamond',
|
||||
@@ -153,6 +165,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: [300, 400, 500, 600, 700],
|
||||
italics: [300, 400, 500, 600, 700],
|
||||
letterEm: 0.393,
|
||||
boldEm: 0.398,
|
||||
},
|
||||
dmserif: {
|
||||
name: 'DM Serif Display',
|
||||
@@ -173,6 +186,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: [400, 500, 600, 700, 800, 900],
|
||||
italics: [400, 500, 600, 700, 800, 900],
|
||||
letterEm: 0.463,
|
||||
boldEm: 0.488,
|
||||
},
|
||||
// ── condensed and poster ──
|
||||
bebas: {
|
||||
@@ -205,6 +219,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: [200, 300, 400, 500, 600, 700],
|
||||
italics: [],
|
||||
letterEm: 0.365,
|
||||
boldEm: 0.412,
|
||||
},
|
||||
league: {
|
||||
name: 'League Gothic',
|
||||
@@ -225,6 +240,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: ALL,
|
||||
italics: [],
|
||||
letterEm: 0.304,
|
||||
boldEm: 0.355,
|
||||
},
|
||||
archivo: {
|
||||
name: 'Archivo Black',
|
||||
@@ -246,6 +262,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: ALL,
|
||||
italics: [],
|
||||
letterEm: 0.492,
|
||||
boldEm: 0.5,
|
||||
},
|
||||
shrikhand: {
|
||||
name: 'Shrikhand',
|
||||
@@ -276,6 +293,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: [200, 300, 400, 500, 600, 700, 800],
|
||||
italics: [],
|
||||
letterEm: 0.471,
|
||||
boldEm: 0.49,
|
||||
},
|
||||
abril: {
|
||||
name: 'Abril Fatface',
|
||||
@@ -297,6 +315,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: MID,
|
||||
italics: [],
|
||||
letterEm: 0.344,
|
||||
boldEm: 0.349,
|
||||
},
|
||||
dancing: {
|
||||
name: 'Dancing Script',
|
||||
@@ -307,6 +326,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: MID,
|
||||
italics: [],
|
||||
letterEm: 0.363,
|
||||
boldEm: 0.372,
|
||||
},
|
||||
pacifico: {
|
||||
name: 'Pacifico',
|
||||
|
||||
@@ -71,6 +71,12 @@ describe('the catalogue', () => {
|
||||
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);
|
||||
// A measured bold only where the family ships one, and never narrower than its regular.
|
||||
const bold = (face as { boldEm?: number }).boldEm;
|
||||
if (bold !== undefined) {
|
||||
expect(face.weights as readonly number[], id).toContain(700);
|
||||
expect(bold, id).toBeGreaterThanOrEqual(face.letterEm);
|
||||
}
|
||||
expect(face.roles.length, id).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -264,6 +264,23 @@ describe('a mark set as text on a page laid out again', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the page background a saved layout brings', () => {
|
||||
it("BOOKLAY-030: replaces the page's colour and its link to a theme colour, so a theme cannot paint over it", () => {
|
||||
const from = bookSpreadSchema.parse({ id: 'a', role: 'inner', background: '#123456', elements: [] });
|
||||
const layout = bookLayoutFromSpread(from, page, 'Navy');
|
||||
const linked = bookSpreadSchema.parse({
|
||||
id: 'b',
|
||||
role: 'inner',
|
||||
background: '#ffffff',
|
||||
backgroundToken: 'paper',
|
||||
elements: [],
|
||||
});
|
||||
const next = applySavedBookLayout(linked, layout, page);
|
||||
expect(next.background).toBe('#123456');
|
||||
expect(next).not.toHaveProperty('backgroundToken');
|
||||
});
|
||||
});
|
||||
|
||||
describe('letters filled with a photo in a saved layout', () => {
|
||||
const frame = { x: 10, y: 10, w: 80, h: 40 };
|
||||
const filledWith = (photoId: number) => ({ type: 'photo' as const, photoId, focalX: 0.5, focalY: 0.5, zoom: 1 });
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
bookAddOpInSchema,
|
||||
} from './book-element-ops.schema';
|
||||
import { BOOK_GROUP_OP_SCHEMAS } from './book-group-ops.schema';
|
||||
import { BOOK_COVER_TEMPLATES, BOOK_TEMPLATES } from './book-layouts';
|
||||
import {
|
||||
BOOK_BINDING_HINT,
|
||||
bookFontIdSchema,
|
||||
@@ -76,16 +75,6 @@ export const BOOK_OP_LIMITS = {
|
||||
findings: 40,
|
||||
} as const;
|
||||
|
||||
const idsOf = (list: readonly { id: string }[]) => list.map((t) => t.id) as [string, ...string[]];
|
||||
|
||||
/** The spread layouts, by id. */
|
||||
export const bookTemplateIdSchema = z
|
||||
.enum(idsOf(BOOK_TEMPLATES))
|
||||
.describe('A built-in double page layout by id; list_book_layouts says what each one holds.');
|
||||
/** The single-page (cover and back) layouts, by id. */
|
||||
export const bookCoverTemplateIdSchema = z
|
||||
.enum(idsOf(BOOK_COVER_TEMPLATES))
|
||||
.describe('A built-in cover or back layout by id; list_book_layouts says what each one holds.');
|
||||
/**
|
||||
* A layout of either set, built-in or decorated: which one fits is the page's
|
||||
* question, answered when the operation runs. A string checked against the
|
||||
@@ -278,7 +267,8 @@ export const addBookPagesArgs = {
|
||||
/** The new pages go after this one, kept inside the covers. */
|
||||
afterSpreadId: bookSpreadIdSchema,
|
||||
count: z.number().int().min(1).max(BOOK_OP_LIMITS.pagesPerCall).optional(),
|
||||
template: bookTemplateIdSchema.optional(),
|
||||
/** A double-spread layout, built-in or decorated; a single-page one is refused when the page is laid. */
|
||||
template: bookAnyTemplateIdSchema.optional(),
|
||||
entryId: idSchema.optional(),
|
||||
};
|
||||
export const moveBookPageArgs = {
|
||||
@@ -296,7 +286,8 @@ export const deleteBookPagesArgs = {
|
||||
expectedVersion,
|
||||
};
|
||||
export const setBookCoverArgs = {
|
||||
template: bookCoverTemplateIdSchema.optional(),
|
||||
/** A single-page layout, built-in or decorated; a double-spread one is refused when the cover is laid. */
|
||||
template: bookAnyTemplateIdSchema.optional(),
|
||||
photoId: idSchema.optional(),
|
||||
title: z.string().max(MAX_BOOK_TITLE).optional(),
|
||||
subtitle: z.string().max(MAX_BOOK_TITLE).optional(),
|
||||
|
||||
@@ -108,6 +108,19 @@ describe('add, move and delete', () => {
|
||||
expect(doc.spreads[3]?.elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('BOOKPG-029: lays added pages out on a decorated template, and refuses a cover template for a double spread', () => {
|
||||
const doc = ok(
|
||||
addBookPages(book(1), { afterSpreadId: 'sp-in1', count: 2, template: 'hero-script-word' }, { newId }),
|
||||
);
|
||||
expect(doc.spreads[3]?.elements.length).toBeGreaterThan(0);
|
||||
expect(doc.spreads[4]?.elements.length).toBe(doc.spreads[3]?.elements.length);
|
||||
const wrong = addBookPages(book(1), { afterSpreadId: 'sp-in1', template: 'poster-pop-sunburst' }, { newId });
|
||||
expect(refused(wrong)).toBe('layout_role');
|
||||
expect(refused(addBookPages(book(1), { afterSpreadId: 'sp-in1', template: 'no-such' }, { newId }))).toBe(
|
||||
'invalid_op',
|
||||
);
|
||||
});
|
||||
|
||||
it('BOOKPG-020: ties an added page only to an entry the journey has, when the journey is known', () => {
|
||||
const journey = { title: 'Iceland', entries: [{ id: 7 }] };
|
||||
const added = ok(addBookPages(book(1), { afterSpreadId: 'sp-in1', entryId: 7 }, { newId, journey }));
|
||||
@@ -336,6 +349,13 @@ describe('the cover', () => {
|
||||
expect(title).toBeDefined();
|
||||
});
|
||||
|
||||
it('BOOKPG-030: lays the cover out on a decorated cover template, and refuses a double-spread one', () => {
|
||||
const doc = ok(setBookCover(book(1), { template: 'poster-pop-sunburst', title: 'Iceland 2026' }, { newId }));
|
||||
expect(coverOf(doc).elements.length).toBeGreaterThan(1);
|
||||
expect(textsOf(doc).some((t) => t.text === 'Iceland 2026')).toBe(true);
|
||||
expect(refused(setBookCover(book(1), { template: 'hero-script-word' }, { newId }))).toBe('layout_role');
|
||||
});
|
||||
|
||||
it('BOOKPG-016: keeps a bound title bound when the words are what the journey says, and the subtitle apart', () => {
|
||||
const base = book(1);
|
||||
const bound = bookTextElementSchema.parse({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { bookElementId } from './book-ids';
|
||||
import { applyBookTemplate, classifyBookTexts, findBookTemplate } from './book-layouts';
|
||||
import { classifyBookTexts } from './book-layouts';
|
||||
import { applyBookOps } from './book-ops';
|
||||
import {
|
||||
caught,
|
||||
@@ -62,17 +62,23 @@ export function addBookPages(doc: BookDocument, args: unknown, ctx: BookOpsConte
|
||||
}
|
||||
const { first, limit } = innerSpreadBounds(doc.spreads);
|
||||
const at = Math.min(Math.max(after + 1, first), limit);
|
||||
const tpl = template === undefined ? undefined : findBookTemplate(template);
|
||||
const added = Array.from({ length: count }, () => {
|
||||
const empty = { ...blankSpread('inner', ctx.newId ?? bookElementId), entryId: entryId ?? null };
|
||||
return checkedSpread(tpl ? applyBookTemplate(empty, tpl, doc.page) : empty, null);
|
||||
});
|
||||
const spreads = [...doc.spreads.slice(0, at), ...added, ...doc.spreads.slice(at)];
|
||||
const added = Array.from({ length: count }, () =>
|
||||
checkedSpread({ ...blankSpread('inner', ctx.newId ?? bookElementId), entryId: entryId ?? null }, null),
|
||||
);
|
||||
let next: BookDocument = { ...doc, spreads: [...doc.spreads.slice(0, at), ...added, ...doc.spreads.slice(at)] };
|
||||
// Laid out as edit_book_page lays a page, so a decorated template takes the book's theme and words too.
|
||||
if (template !== undefined) {
|
||||
for (const spread of added) {
|
||||
const r = applyBookOps(next, { spreadId: spread.id, ops: [{ op: 'layout', template }] }, ctx);
|
||||
if (r.ok === false) throw failOp(r.error.code, r.error.message, null, r.error.elementId);
|
||||
next = r.doc;
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
doc: { ...doc, spreads },
|
||||
doc: next,
|
||||
spreadIds: added.map((s) => s.id),
|
||||
created: createdIds(doc.spreads, spreads),
|
||||
created: createdIds(doc.spreads, next.spreads),
|
||||
dropped: [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -42,6 +42,8 @@ export const BOOK_PATTERNS = [
|
||||
'scales',
|
||||
'sunburst',
|
||||
'confetti',
|
||||
'linen',
|
||||
'grain',
|
||||
] as const;
|
||||
export type BookPattern = (typeof BOOK_PATTERNS)[number];
|
||||
|
||||
@@ -68,32 +70,39 @@ type ObjectFactory = typeof z.object | typeof z.strictObject;
|
||||
function paintSchemas(obj: ObjectFactory, pattern: z.ZodType<BookPattern, string>) {
|
||||
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 linear = obj({ type: z.literal('linear'), angle, stops });
|
||||
const radial = 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,
|
||||
});
|
||||
const patterned = obj({
|
||||
type: z.literal('pattern'),
|
||||
pattern,
|
||||
fg: hex,
|
||||
bg: hex.nullable(),
|
||||
scale: z.number().min(PATTERN_SCALE.min).max(PATTERN_SCALE.max),
|
||||
angle,
|
||||
});
|
||||
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,
|
||||
fg: hex,
|
||||
bg: hex.nullable(),
|
||||
scale: z.number().min(PATTERN_SCALE.min).max(PATTERN_SCALE.max),
|
||||
angle,
|
||||
}),
|
||||
linear,
|
||||
radial,
|
||||
patterned,
|
||||
]);
|
||||
return { stop, paint };
|
||||
return { stop, paint, linear, radial, patterned };
|
||||
}
|
||||
|
||||
const docSchemas = paintSchemas(z.object, z.string().pipe(z.enum(BOOK_PATTERNS)));
|
||||
|
||||
export const bookPaintStopSchema = docSchemas.stop;
|
||||
export const bookPaintSchema = docSchemas.paint;
|
||||
/** The paints that are more than one colour, as the letters of a text take them too (book-text-effects.schema.ts). */
|
||||
export const bookLinearPaintSchema = docSchemas.linear;
|
||||
export const bookRadialPaintSchema = docSchemas.radial;
|
||||
export const bookPatternPaintSchema = docSchemas.patterned;
|
||||
export type BookPaint = z.infer<typeof bookPaintSchema>;
|
||||
export type BookPaintStop = z.infer<typeof bookPaintStopSchema>;
|
||||
export type BookGradientPaint = Extract<BookPaint, { type: 'linear' | 'radial' }>;
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('the paint contract', () => {
|
||||
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);
|
||||
expect(BOOK_PATTERNS).toHaveLength(14);
|
||||
for (const pattern of BOOK_PATTERNS) expect(bookPaintSchema.safeParse({ ...stripes, pattern }).success).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -145,7 +145,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);
|
||||
|
||||
const { backgroundPaint: _old, ...rest } = spread;
|
||||
// The layout's background replaces the page's, so the page's link to a theme
|
||||
// colour goes with it: kept, the next theme would paint over the layout's colour.
|
||||
const { backgroundPaint: _old, backgroundToken: _unlinked, ...rest } = spread;
|
||||
const backgroundPaint = layout.backgroundPaint ? { backgroundPaint: layout.backgroundPaint } : {};
|
||||
return { ...rest, background: layout.background, ...backgroundPaint, elements: out, parked };
|
||||
}
|
||||
|
||||
@@ -43,8 +43,10 @@ import type { BookDocument } from './book.schema';
|
||||
* - 9: text effects and fitting (`transform`, `vAlign`, `outline`, `shadow`, `highlight`,
|
||||
* `curve`, `fillPaint`, `fit`, `fitMax`, book-text-effects.schema.ts), text up to 400 pt and
|
||||
* tracking up to 1.5 em.
|
||||
* - 10: letters filled with a gradient or a pattern (`fillPaint` of type `linear`, `radial`,
|
||||
* `pattern`, book-text-effects.schema.ts).
|
||||
*/
|
||||
export const BOOK_SCHEMA_REV = 9;
|
||||
export const BOOK_SCHEMA_REV = 10;
|
||||
|
||||
/** The `code` a refused save carries in its body when the stored book is newer than the writer. */
|
||||
export const BOOK_SCHEMA_NEWER_CODE = 'book_schema_newer';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import en from '../i18n/en';
|
||||
import { BOOK_FRAME_SHAPES, BOOK_SHAPE_GROUPS } from './book-shape-catalogue';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Every shape the Studio offers has a name in en, which the client's
|
||||
* `shapeName` reads for the tooltip and the screen reader of the shape's
|
||||
* button (client/scripts/i18n-keys.mjs allows the key built from the id).
|
||||
*/
|
||||
describe('the shape catalogue', () => {
|
||||
it('BKSHAPE-001: names every shape of the shelves and every frame shape in en', () => {
|
||||
const ids = new Set([...BOOK_SHAPE_GROUPS.flatMap((g) => g.shapes), ...BOOK_FRAME_SHAPES]);
|
||||
const strings = en as Record<string, string>;
|
||||
for (const id of ids) expect(strings[`journey.studio.shapeName.${id}`], id).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,19 @@ const n = (v: number) => `${Math.round(v * 10) / 10}`;
|
||||
|
||||
const CASES: Record<string, string> = { upper: 'capitals', lower: 'lower case', smallcaps: 'small caps' };
|
||||
|
||||
function stopWords(stops: readonly { color: string }[]): string {
|
||||
return `from ${stops[0]?.color ?? ''} to ${stops[stops.length - 1]?.color ?? ''}`;
|
||||
}
|
||||
|
||||
function fillWords(fill: NonNullable<BookTextEffects['fillPaint']>): string {
|
||||
if (fill.type === 'photo') {
|
||||
return `letters filled with photo ${fill.photoId} (focal ${fill.focalX.toFixed(2)}/${fill.focalY.toFixed(2)}, zoom ${n(fill.zoom)})`;
|
||||
}
|
||||
if (fill.type === 'linear') return `letters in a linear gradient at ${n(fill.angle)} deg, ${stopWords(fill.stops)}`;
|
||||
if (fill.type === 'radial') return `letters in a radial gradient, ${stopWords(fill.stops)}`;
|
||||
if (fill.type === 'pattern') {
|
||||
return `letters patterned ${fill.pattern} in ${fill.fg} on ${fill.bg ?? 'clear'} at scale ${n(fill.scale)}`;
|
||||
}
|
||||
return fill.alpha === 0 ? 'hollow letters (outline only)' : `fill ${fill.color} at alpha ${n(fill.alpha)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { bookTextIsDrawn } from './book-text-effects.schema';
|
||||
import { bookTextEffectWords } from './book-text-describe';
|
||||
import { bookTextIsDrawn, bookTextPaintFill } from './book-text-effects.schema';
|
||||
import { bookTextHasOwnGround } from './book-text-findings';
|
||||
import { bookTextElementSchema } from './book.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
@@ -75,3 +77,36 @@ describe('the effect fields of a text', () => {
|
||||
expect(bookTextIsDrawn(parse({ fillPaint: { type: 'photo', photoId: 3 } }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('letters filled with a gradient or a pattern', () => {
|
||||
const gradient = {
|
||||
type: 'linear',
|
||||
angle: 90,
|
||||
stops: [
|
||||
{ at: 0, color: '#ff6600' },
|
||||
{ at: 1, color: '#ffcc00' },
|
||||
],
|
||||
};
|
||||
const gingham = { type: 'pattern', pattern: 'gingham', fg: '#d62828', bg: '#ffffff', scale: 1, angle: 0 };
|
||||
|
||||
it('BKFX-005: reads a gradient and a pattern in the shape a shape takes them, and draws them as SVG', () => {
|
||||
const lin = parse({ fillPaint: gradient });
|
||||
const pat = parse({ fillPaint: gingham });
|
||||
expect(bookTextPaintFill(lin)).toEqual(gradient);
|
||||
expect(bookTextPaintFill(pat)).toEqual(gingham);
|
||||
expect(bookTextIsDrawn(lin)).toBe(true);
|
||||
expect(bookTextIsDrawn(pat)).toBe(true);
|
||||
expect(bookTextPaintFill(parse({ fillPaint: { type: 'photo', photoId: 3 } }))).toBeNull();
|
||||
expect(() => parse({ fillPaint: { ...gingham, pattern: 'plaid' } })).toThrow();
|
||||
});
|
||||
|
||||
it('BKFX-006: names the paint for a model that cannot see the page, and judges no contrast against it', () => {
|
||||
expect(bookTextEffectWords({ ...parse({ fillPaint: gradient }), size: 40 })).toContain(
|
||||
'linear gradient at 90 deg, from #ff6600 to #ffcc00',
|
||||
);
|
||||
expect(bookTextEffectWords({ ...parse({ fillPaint: gingham }), size: 40 })).toContain(
|
||||
'patterned gingham in #d62828 on #ffffff',
|
||||
);
|
||||
expect(bookTextHasOwnGround(parse({ fillPaint: gingham }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
bookLinearPaintSchema,
|
||||
bookPatternPaintSchema,
|
||||
bookRadialPaintSchema,
|
||||
type BookPaint,
|
||||
} from './book-paint.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
@@ -27,10 +34,11 @@ import { z } from 'zod';
|
||||
*
|
||||
* ── The fill and the paint system ──────────────────────────────────────────
|
||||
*
|
||||
* `fillPaint` takes a solid colour with its own alpha or a photograph today.
|
||||
* Its solid variant has the shape the book's paint type gives a solid fill, so
|
||||
* gradients and patterns join this union as further members, without a
|
||||
* reader of today's fields changing.
|
||||
* `fillPaint` takes a solid colour with its own alpha, a photograph, or one
|
||||
* of the book's paints of more than one colour: a linear or radial gradient or
|
||||
* a pattern, in exactly the shape a shape or a page takes them, so one picker
|
||||
* and one renderer serve all three. A gradient or pattern fill is drawn as SVG
|
||||
* like a photo fill, the paint clipped to the letters.
|
||||
*/
|
||||
|
||||
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
|
||||
@@ -84,7 +92,10 @@ export const bookTextCurveSchema = z.object({
|
||||
});
|
||||
export type BookTextCurve = z.infer<typeof bookTextCurveSchema>;
|
||||
|
||||
/** What fills the letters in place of `color`: a colour with its own alpha (0 leaves an outline only), or a photograph. */
|
||||
/**
|
||||
* What fills the letters in place of `color`: a colour with its own alpha (0
|
||||
* leaves an outline only), a photograph, or a gradient or pattern.
|
||||
*/
|
||||
export const bookTextFillSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('solid'), color: hex, alpha: alpha.default(1) }),
|
||||
z.object({
|
||||
@@ -95,9 +106,18 @@ export const bookTextFillSchema = z.discriminatedUnion('type', [
|
||||
focalY: z.number().min(0).max(1).default(0.5),
|
||||
zoom: z.number().min(1).max(4).default(1),
|
||||
}),
|
||||
bookLinearPaintSchema,
|
||||
bookRadialPaintSchema,
|
||||
bookPatternPaintSchema,
|
||||
]);
|
||||
export type BookTextFill = z.infer<typeof bookTextFillSchema>;
|
||||
|
||||
/** The letters' gradient or pattern, as the paint a shape would take, or null for any other fill. */
|
||||
export function bookTextPaintFill(el: { fillPaint?: BookTextFill | null }): BookPaint | null {
|
||||
const fill = el.fillPaint;
|
||||
return fill && (fill.type === 'linear' || fill.type === 'radial' || fill.type === 'pattern') ? fill : null;
|
||||
}
|
||||
|
||||
/** `shrink` sets the text at the largest size up to `fitMax` that holds the frame, solved on edit and stored in `size`. */
|
||||
export const BOOK_TEXT_FITS = ['none', 'shrink'] as const;
|
||||
|
||||
@@ -133,10 +153,10 @@ export interface BookTextEffects {
|
||||
|
||||
/**
|
||||
* Whether a text is drawn as SVG rather than as a box of HTML type: on a
|
||||
* curve, or filled with a photograph. Such a text is set line by line as
|
||||
* written, never wrapped, which is what word art wants and what the checks
|
||||
* measure it by.
|
||||
* curve, or filled with a photograph, a gradient or a pattern. Such a text is
|
||||
* set line by line as written, never wrapped, which is what word art wants and
|
||||
* what the checks measure it by.
|
||||
*/
|
||||
export function bookTextIsDrawn(el: BookTextEffects): boolean {
|
||||
return !!el.curve || el.fillPaint?.type === 'photo';
|
||||
return !!el.curve || el.fillPaint?.type === 'photo' || !!bookTextPaintFill(el);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ const MIN_FILL_PX_PER_MM = 200 / 25.4;
|
||||
*/
|
||||
export function bookTextHasOwnGround(el: BookTextElement): boolean {
|
||||
if (el.highlight) return true;
|
||||
if (el.fillPaint && (el.fillPaint.type === 'photo' || el.fillPaint.alpha < 0.5)) return true;
|
||||
// A photo, a gradient or a pattern in the letters is no one colour the page could be judged against.
|
||||
if (el.fillPaint && (el.fillPaint.type !== 'solid' || el.fillPaint.alpha < 0.5)) return true;
|
||||
return !!el.outline && el.outline.width >= ptToMm(el.size) * 0.04;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { textHeightNeeded } from './book-findings';
|
||||
import { bookLetterMm, textHeightNeeded } from './book-findings';
|
||||
import { blankBook } from './book-pages';
|
||||
import {
|
||||
bookCasedText,
|
||||
@@ -138,4 +138,12 @@ describe('fitting a page', () => {
|
||||
expect((next.spreads[0]?.elements[0] as BookTextElement).size).toBeLessThan(60);
|
||||
expect(next.spreads[1]).toBe(doc.spreads[1]);
|
||||
});
|
||||
|
||||
it('BKFIT-011: a bold condensed face is measured at its own width, not the regular widened by a guess', () => {
|
||||
const oswald = (weight: number) => bookLetterMm(text({ font: 'oswald', weight, size: 20 }));
|
||||
// Oswald's 700 sets about 13% wider than its 400; a flat 6% would let a bold heading overrun its frame.
|
||||
expect(oswald(700) / oswald(400)).toBeGreaterThan(1.1);
|
||||
const vibes = (weight: number) => bookLetterMm(text({ font: 'vibes', weight, size: 20 }));
|
||||
expect(vibes(700) / vibes(400)).toBeCloseTo(1.06, 6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,9 @@ export const TEXT_EFFECT_PROPS = {
|
||||
shadow: f.shadow.describe('Hard, never blurred; dx, dy in mm.'),
|
||||
highlight: f.highlight.describe('A marker band behind each line; pads in mm.'),
|
||||
curve: f.curve.describe('One line on an arc (bend > 0 arches up) or round a circle (start: degrees, 0 top).'),
|
||||
fillPaint: f.fillPaint.describe('Replaces color: solid (alpha 0 is hollow, with an outline) or a gallery photo.'),
|
||||
fillPaint: f.fillPaint.describe(
|
||||
'Replaces color: solid (alpha 0 is hollow, with an outline), a gallery photo, or a linear/radial gradient or pattern shaped as on a shape.',
|
||||
),
|
||||
fit: f.fit.describe('shrink: the largest size up to size that holds the frame.'),
|
||||
};
|
||||
|
||||
|
||||
@@ -138,4 +138,6 @@ export const BOOK_PATTERN_LINES: Readonly<Record<BookPattern, string>> = {
|
||||
scales: 'Overlapping half circles, fish scales or roof tiles.',
|
||||
sunburst: 'Rays from a centre, a sunburst.',
|
||||
confetti: 'Scattered confetti.',
|
||||
linen: 'Fine woven threads, the texture of a cloth-bound book, best in a colour close to its ground.',
|
||||
grain: 'Fine scattered specks, the grain of paper or kraft card, best in a colour close to its ground.',
|
||||
};
|
||||
|
||||
@@ -356,6 +356,17 @@ describe('design_book', () => {
|
||||
expect(JSON.stringify(again.doc)).toBe(JSON.stringify(banded.doc));
|
||||
});
|
||||
|
||||
it('BOOKDSN-032: lays the cover out on a decorated cover template, and leaves a double-spread one out with a note', () => {
|
||||
const poster = designBook(null, input('rich'), { style: 'minimal', cover: { template: 'poster-pop-sunburst' } });
|
||||
const cover = poster.doc.spreads[0] as BookSpread;
|
||||
expect(cover.elements.some((e) => e.kind === 'photo')).toBe(true);
|
||||
expect(cover.elements.some((e) => e.kind === 'text' && e.binding?.source === 'journey.title')).toBe(true);
|
||||
const again = designBook(null, input('rich'), { style: 'minimal', cover: { template: 'poster-pop-sunburst' } });
|
||||
expect(JSON.stringify(again.doc)).toBe(JSON.stringify(poster.doc));
|
||||
const spread = designBook(null, input('rich'), { style: 'minimal', cover: { template: 'hero-script-word' } });
|
||||
expect(spread.notes.some((n) => n.includes('hero-script-word'))).toBe(true);
|
||||
});
|
||||
|
||||
it('BOOKDSN-014: stays within the spread cap beside many kept pages, the closing pages kept', () => {
|
||||
const stored = storedBook();
|
||||
const lockedPage = stored.spreads[2] as BookSpread;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { idSchema } from '../../common/primitives.schema';
|
||||
import { bookFontIdSchema, bookMetricSchema, bookPagePresetSchema } from '../book-op-enums';
|
||||
import { bookCoverTemplateIdSchema } from '../book-ops.schema';
|
||||
import { bookAnyTemplateIdSchema } from '../book-ops.schema';
|
||||
import { MAX_BOOK_TITLE } from '../book.schema';
|
||||
import { bookThemeCatalogue } from '../theme/book-theme-describe';
|
||||
import { BOOK_THEME_IDS } from '../theme/book-theme.schema';
|
||||
@@ -84,7 +84,7 @@ export const bookDesignDirectivesSchema = z.strictObject({
|
||||
.optional(),
|
||||
cover: z
|
||||
.strictObject({
|
||||
template: bookCoverTemplateIdSchema.optional(),
|
||||
template: bookAnyTemplateIdSchema.optional(),
|
||||
archetype: bookCoverArchetypeSchema.optional(),
|
||||
lockup: bookCoverLockupSchema.optional(),
|
||||
title: z.string().max(MAX_BOOK_TITLE).optional(),
|
||||
|
||||
@@ -6,6 +6,8 @@ import { formatBookDate } from '../book-text';
|
||||
import { bookTravelCountries, bookTravelMap, bookTravelStats } from '../book-travel';
|
||||
import type { BookElement, BookMetric, BookSpread, BookTextElement } from '../book.schema';
|
||||
import type { JourneyStats } from '../journey-stats.schema';
|
||||
import { applyDecoratedTemplate } from '../templates/template-apply';
|
||||
import { findDecoratedTemplate } from '../templates/template-catalogue';
|
||||
import {
|
||||
bleedPage,
|
||||
frameOf,
|
||||
@@ -90,8 +92,22 @@ export function composeCover(
|
||||
*/
|
||||
function withCoverTemplate(c: ComposeCtx, cover: BookSpread): BookSpread {
|
||||
const wanted = c.directives.cover?.template;
|
||||
const tpl = wanted ? BOOK_COVER_TEMPLATES.find((t) => t.id === wanted) : undefined;
|
||||
if (!tpl) return cover;
|
||||
if (!wanted) return cover;
|
||||
const decorated = findDecoratedTemplate(wanted);
|
||||
if (decorated) {
|
||||
if (decorated.kind !== 'single') {
|
||||
c.notes.push(`${wanted} is a double-spread template, so cover.template is left out`);
|
||||
return cover;
|
||||
}
|
||||
// A designed page keeps its own faces and colours, in the book's theme, and takes the cover's photo and words.
|
||||
const opts = { theme: c.look.theme, travel: c.travel, locale: c.locale, newId: c.newId };
|
||||
return { ...applyDecoratedTemplate(cover, decorated, c.page, opts), parked: [] };
|
||||
}
|
||||
const tpl = BOOK_COVER_TEMPLATES.find((t) => t.id === wanted);
|
||||
if (!tpl) {
|
||||
c.notes.push(`${wanted} is a double-spread layout, so cover.template is left out`);
|
||||
return cover;
|
||||
}
|
||||
const laid = applyBookTemplate(cover, tpl, c.page);
|
||||
const elements = laid.elements.map((e): BookElement => {
|
||||
if (e.kind === 'text') return { ...e, id: c.newId('t'), color: c.look.ink };
|
||||
|
||||
@@ -614,4 +614,250 @@ export const ASIA_STICKERS: readonly BookStickerDef[] = [
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
{
|
||||
id: 'tiger',
|
||||
theme: 'asia',
|
||||
tags: ['tiger', 'bengal tiger', 'india', 'jungle', 'safari', 'wildlife', 'animal', 'big cat'],
|
||||
descr:
|
||||
'A friendly Bengal tiger walking left with its face turned to the viewer, orange fur with black stripes, white cheeks and belly and a curled striped tail.',
|
||||
box: [110, 80],
|
||||
slots: [
|
||||
{ name: 'fur', color: '#f08a24' },
|
||||
{ name: 'belly', color: '#fbf6ee' },
|
||||
{ name: 'stripes', color: '#262229' },
|
||||
{ name: 'nose', color: '#e8828a' },
|
||||
],
|
||||
layers: [
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M18 75.5C18 76.94 36.8 78.1 60 78.1C83.2 78.1 102 76.94 102 75.5C102 74.06 83.2 72.9 60 72.9C36.8 72.9 18 74.06 18 75.5Z',
|
||||
o: 0.12,
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M46 46L48 70C48 73 49.5 74.5 52.5 74.5L57.5 74.5C59.5 74.5 59.5 71.5 57.5 70.5L56 46ZM80 44L86 70C86.6 73 88 74.5 91 74.5L95.5 74.5C97.5 74.5 97.5 71.5 95.5 70.5L91 44Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M46 46L48 70C48 73 49.5 74.5 52.5 74.5L57.5 74.5C59.5 74.5 59.5 71.5 57.5 70.5L56 46ZM80 44L86 70C86.6 73 88 74.5 91 74.5L95.5 74.5C97.5 74.5 97.5 71.5 95.5 70.5L91 44Z',
|
||||
o: 0.28,
|
||||
},
|
||||
{ p: 0, d: 'M88 37C99 34 101 22 99 14C97.8 9 101 5.5 105 7', w: 4.4 },
|
||||
{
|
||||
p: 2,
|
||||
d: 'M96.9 30.4L97.3 29.7L97.6 29.1L98 28.4M99.3 24.1L99.4 23.4L99.5 22.7L99.6 21.9M99.6 17.6L99.5 16.9L99.4 16.2L99.3 15.5M100.1 8.1L100.8 7.4L101.7 6.9L102.7 6.7L103.8 6.7L105 7',
|
||||
w: 4.4,
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M32 43C32 51.01 45.43 57.5 62 57.5C78.57 57.5 92 51.01 92 43C92 34.99 78.57 28.5 62 28.5C45.43 28.5 32 34.99 32 43Z',
|
||||
},
|
||||
{ p: 0, d: 'M34 36C38 31 45 30 50 31.5L50 52L35 52Z' },
|
||||
{
|
||||
p: 0,
|
||||
d: 'M72 36C84 31 95 38 93 50C92.5 53 91 56 89 58L91.5 70C92 73 90.5 74.5 88 74.5L82 74.5C79.5 74.5 79.5 71.5 82 70.5L80.5 58C74 56 69 46 72 36Z',
|
||||
},
|
||||
{ p: 0, d: 'M33 42L30.5 70C30.3 73 31.8 74.5 34.8 74.5L41.3 74.5C43.3 74.5 43.3 71.5 41.3 70.5L44.5 44Z' },
|
||||
{
|
||||
p: 1,
|
||||
d: 'M40 52.3L43 53.6L46 54.7L49 55.5L52 56.1L55 56.5L58 56.8L61 56.9L64 56.9L67 56.7L70 56.4L73 55.9L76 55.2L79 54.3L82 53.2L82 53.2L79 53.4L76 53.4L73 53.3L70 53.1L67 52.9L64 52.8L61 52.7L58 52.7L55 52.7L52 52.8L49 52.8L46 52.8L43 52.7L40 52.3Z',
|
||||
},
|
||||
{ p: 'light', d: 'M48 33.2C56 31 66 30.8 74 32', w: 1.4, o: 0.3 },
|
||||
{
|
||||
p: 2,
|
||||
d: 'M43.6 32.3L46.4 31.4C45.4 34.53 44.27 37.77 43 41.1C43.2 37.77 43.4 34.83 43.6 32.3ZM49.1 30.7L52.9 30C51.7 34.67 50.53 39.5 49.4 44.5C49.47 39.5 49.37 34.9 49.1 30.7ZM55.2 29.7L57.8 29.4C57.2 31.93 56.43 34.7 55.5 37.7C55.37 34.7 55.27 32.03 55.2 29.7ZM60 29.3L64 29.3C63.13 34.1 62.33 39.17 61.6 44.5C61.27 39.17 60.73 34.1 60 29.3ZM66.1 29.4L68.9 29.7C68.7 32.37 68.37 35.37 67.9 38.7C67.3 35.37 66.7 32.27 66.1 29.4ZM71.1 30L74.9 30.7C74.57 34.57 74.27 38.83 74 43.5C73.2 38.83 72.23 34.33 71.1 30ZM77.2 31.3L79.8 32.1C80 34.3 80.03 36.9 79.9 39.9C78.97 36.9 78.07 34.03 77.2 31.3ZM82.4 33.2L85.6 34.8C85.8 37 85.93 39.77 86 43.1C84.87 39.77 83.67 36.47 82.4 33.2ZM87.9 36.5L90.1 38.7C90.5 39.37 90.67 40.7 90.6 42.7C89.6 40.7 88.7 38.63 87.9 36.5ZM47 54.6C48.6 51.6 50.4 49.6 53 48.6C51.6 51 51 53 51 55.6ZM58 56.6C59.4 53 61.4 50.6 64.4 49.4C62.8 52.2 62.2 54.6 62.2 57.4ZM69 56.4C70.2 53.2 72 51.2 74.6 50C73.2 52.4 72.8 54.4 72.8 56.8ZM86 40.5C82 43 80 47 80.5 51.5C82 47.5 84.5 44 88.2 42ZM90.4 46C86.8 48.4 85 51.6 85 55C86.8 52 88.6 50 91.6 49ZM32.4 56L37.4 56.6L32.3 58.4ZM31.8 62L37 62.6L31.7 64.4ZM43.6 52L39.6 53L43.5 54.6ZM80.9 62L85.5 62.4L81.1 64.2ZM81.3 66.4L85.4 66.8L81.4 68.4ZM90.3 61L86.6 62L90.6 63.4Z',
|
||||
},
|
||||
{ p: 'shade', d: 'M33.6 50C44 57.6 70 59 86.6 52.4C80 56.2 74 57.4 62 57.5C50 57.5 40 55 33.6 50Z', o: 0.12 },
|
||||
{ p: 'shade', d: 'M35.3 72.6L35.3 74M38.3 72.6L38.3 74M84 72.6L84 74M87 72.6L87 74', w: 0.7, o: 0.35 },
|
||||
{
|
||||
p: 0,
|
||||
d: 'M7.4 23.5C7.4 26.59 9.91 29.1 13 29.1C16.09 29.1 18.6 26.59 18.6 23.5C18.6 20.41 16.09 17.9 13 17.9C9.91 17.9 7.4 20.41 7.4 23.5ZM25.4 23.5C25.4 26.59 27.91 29.1 31 29.1C34.09 29.1 36.6 26.59 36.6 23.5C36.6 20.41 34.09 17.9 31 17.9C27.91 17.9 25.4 20.41 25.4 23.5Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M9.6 23.5C9.6 24.71 10.25 25.84 11.3 26.44C12.35 27.05 13.65 27.05 14.7 26.44C15.75 25.84 16.4 24.71 16.4 23.5C16.4 22.29 15.75 21.16 14.7 20.56C13.65 19.95 12.35 19.95 11.3 20.56C10.25 21.16 9.6 22.29 9.6 23.5ZM27.6 23.5C27.6 24.71 28.25 25.84 29.3 26.44C30.35 27.05 31.65 27.05 32.7 26.44C33.75 25.84 34.4 24.71 34.4 23.5C34.4 22.29 33.75 21.16 32.7 20.56C31.65 19.95 30.35 19.95 29.3 20.56C28.25 21.16 27.6 22.29 27.6 23.5Z',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
d: 'M11.4 24C11.4 25.1 12.3 26 13.4 26C14.5 26 15.4 25.1 15.4 24C15.4 22.9 14.5 22 13.4 22C12.3 22 11.4 22.9 11.4 24ZM28.6 24C28.6 25.1 29.5 26 30.6 26C31.7 26 32.6 25.1 32.6 24C32.6 22.9 31.7 22 30.6 22C29.5 22 28.6 22.9 28.6 24Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M22 20C30.5 20 36 25.5 36 33C36 35 37.5 37 39 38.4C36.6 38.6 35.6 39.4 35.4 40.6C37 41.8 37.2 43.4 36.4 44.6C34 49 28.6 51.4 22 51.4C15.4 51.4 10 49 7.6 44.6C6.8 43.4 7 41.8 8.6 40.6C8.4 39.4 7.4 38.6 5 38.4C6.5 37 8 35 8 33C8 25.5 13.5 20 22 20Z',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
d: 'M22 38C25.4 34.8 31.6 36.2 32 41C32.4 46.6 27.8 50.6 22 50.6C16.2 50.6 11.6 46.6 12 41C12.4 36.2 18.6 34.8 22 38Z',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
d: 'M12 30.2C12 30.99 12.69 31.71 13.8 32.11C14.91 32.5 16.29 32.5 17.4 32.11C18.51 31.71 19.2 30.99 19.2 30.2C19.2 29.41 18.51 28.69 17.4 28.29C16.29 27.9 14.91 27.9 13.8 28.29C12.69 28.69 12 29.41 12 30.2ZM24.8 30.2C24.8 30.99 25.49 31.71 26.6 32.11C27.71 32.5 29.09 32.5 30.2 32.11C31.31 31.71 32 30.99 32 30.2C32 29.41 31.31 28.69 30.2 28.29C29.09 27.9 27.71 27.9 26.6 28.29C25.49 28.69 24.8 29.41 24.8 30.2Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M19.2 21.5L22 28L24.8 21.5C22.93 21.17 21.07 21.17 19.2 21.5ZM15.4 22.6L18.4 26.4L18.6 21.6ZM28.6 22.6L25.6 26.4L25.4 21.6ZM8.2 31.2L13.6 33.6L8.3 34.4ZM35.8 31.2L30.4 33.6L35.7 34.4ZM8.6 37.4L12.6 38.6L9 39.8ZM35.4 37.4L31.4 38.6L35 39.8Z',
|
||||
},
|
||||
{
|
||||
p: 'ink',
|
||||
d: 'M14.5 33C14.5 34.05 15.35 34.9 16.4 34.9C17.45 34.9 18.3 34.05 18.3 33C18.3 31.95 17.45 31.1 16.4 31.1C15.35 31.1 14.5 31.95 14.5 33ZM25.7 33C25.7 34.05 26.55 34.9 27.6 34.9C28.65 34.9 29.5 34.05 29.5 33C29.5 31.95 28.65 31.1 27.6 31.1C26.55 31.1 25.7 31.95 25.7 33Z',
|
||||
},
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M16.3 32.3C16.3 32.55 16.43 32.78 16.65 32.91C16.87 33.03 17.13 33.03 17.35 32.91C17.57 32.78 17.7 32.55 17.7 32.3C17.7 32.05 17.57 31.82 17.35 31.69C17.13 31.57 16.87 31.57 16.65 31.69C16.43 31.82 16.3 32.05 16.3 32.3ZM27.5 32.3C27.5 32.55 27.63 32.78 27.85 32.91C28.07 33.03 28.33 33.03 28.55 32.91C28.77 32.78 28.9 32.55 28.9 32.3C28.9 32.05 28.77 31.82 28.55 31.69C28.33 31.57 28.07 31.57 27.85 31.69C27.63 31.82 27.5 32.05 27.5 32.3Z',
|
||||
},
|
||||
{ p: 3, d: 'M18.6 39.6C18.6 38.4 25.4 38.4 25.4 39.6C25.4 41 23.4 42.6 22 42.6C20.6 42.6 18.6 41 18.6 39.6Z' },
|
||||
{ p: 'ink', d: 'M22 42.6L22 44.4M22 44.4C21 46 18.6 46 18 44.6M22 44.4C23 46 25.4 46 26 44.6', w: 0.8 },
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M15.1 44.5C15.1 44.72 15.28 44.9 15.5 44.9C15.72 44.9 15.9 44.72 15.9 44.5C15.9 44.28 15.72 44.1 15.5 44.1C15.28 44.1 15.1 44.28 15.1 44.5ZM13.6 43C13.6 43.22 13.78 43.4 14 43.4C14.22 43.4 14.4 43.22 14.4 43C14.4 42.78 14.22 42.6 14 42.6C13.78 42.6 13.6 42.78 13.6 43ZM28.1 44.5C28.1 44.64 28.18 44.77 28.3 44.85C28.42 44.92 28.58 44.92 28.7 44.85C28.82 44.77 28.9 44.64 28.9 44.5C28.9 44.36 28.82 44.23 28.7 44.15C28.58 44.08 28.42 44.08 28.3 44.15C28.18 44.23 28.1 44.36 28.1 44.5ZM29.6 43C29.6 43.14 29.68 43.27 29.8 43.35C29.92 43.42 30.08 43.42 30.2 43.35C30.32 43.27 30.4 43.14 30.4 43C30.4 42.86 30.32 42.73 30.2 42.65C30.08 42.58 29.92 42.58 29.8 42.65C29.68 42.73 29.6 42.86 29.6 43Z',
|
||||
o: 0.35,
|
||||
},
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
{
|
||||
id: 'monkey',
|
||||
theme: 'asia',
|
||||
tags: ['monkey', 'macaque', 'india', 'temple', 'jungle', 'wildlife', 'animal', 'banana'],
|
||||
descr:
|
||||
'A cheerful brown macaque sitting with its knees up, holding a yellow banana in both hands, its long tail curling out in a spiral beside it.',
|
||||
box: [100, 105],
|
||||
slots: [
|
||||
{ name: 'fur', color: '#9c6b45' },
|
||||
{ name: 'belly', color: '#d9b58c' },
|
||||
{ name: 'skin', color: '#f2c4a8' },
|
||||
{ name: 'banana', color: '#f5cf45' },
|
||||
],
|
||||
layers: [
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M18 98.6C18 99.46 24.86 100.25 36 100.68C47.14 101.11 60.86 101.11 72 100.68C83.14 100.25 90 99.46 90 98.6C90 97.74 83.14 96.95 72 96.52C60.86 96.09 47.14 96.09 36 96.52C24.86 96.95 18 97.74 18 98.6Z',
|
||||
o: 0.12,
|
||||
},
|
||||
{ p: 0, d: 'M60 92C76 98 92 94 93 78C94 66 84 60 77 64C71 68 73 77 80 76', w: 4.4 },
|
||||
{ p: 'shade', d: 'M60 92C76 98 92 94 93 78C94 66 84 60 77 64C71 68 73 77 80 76', w: 4.4, o: 0.12 },
|
||||
{ p: 0, d: 'M32 94C25 84 26 64 36 55C42 50 54 50 60 55C70 64 71 84 64 94C56 97 40 97 32 94Z' },
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M60 55C70 64 71 84 64 94C61 95 58 95.6 56 96C62 86 63 68 56 54C57.6 54 59 54.4 60 55Z',
|
||||
o: 0.14,
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
d: 'M38 75C38 82.46 42.48 88.5 48 88.5C53.52 88.5 58 82.46 58 75C58 67.54 53.52 61.5 48 61.5C42.48 61.5 38 67.54 38 75Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M25.5 84C25.5 89.25 29.75 93.5 35 93.5C40.25 93.5 44.5 89.25 44.5 84C44.5 78.75 40.25 74.5 35 74.5C29.75 74.5 25.5 78.75 25.5 84Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M51.5 84C51.5 89.25 55.75 93.5 61 93.5C66.25 93.5 70.5 89.25 70.5 84C70.5 78.75 66.25 74.5 61 74.5C55.75 74.5 51.5 78.75 51.5 84Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M27 88C29 93 33 94 37 93.4C41 93 44 89.6 44.4 86C42 90 37 91.6 33 91C30 90.6 28 89.4 27 88Z',
|
||||
o: 0.16,
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M52 88C54 93 58 94 62 93.4C66 93 69 89.6 70.4 86C68 90 63 91.6 59 91C56 90.6 54 89.4 52 88Z',
|
||||
o: 0.16,
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M24 95C24 96.29 25.33 97.47 27.5 98.12C29.67 98.76 32.33 98.76 34.5 98.12C36.67 97.47 38 96.29 38 95C38 93.71 36.67 92.53 34.5 91.88C32.33 91.24 29.67 91.24 27.5 91.88C25.33 92.53 24 93.71 24 95ZM58 95C58 96.29 59.33 97.47 61.5 98.12C63.67 98.76 66.33 98.76 68.5 98.12C70.67 97.47 72 96.29 72 95C72 93.71 70.67 92.53 68.5 91.88C66.33 91.24 63.67 91.24 61.5 91.88C59.33 92.53 58 93.71 58 95Z',
|
||||
},
|
||||
{ p: 'shade', d: 'M26.5 94.6L26.5 97M29.5 95L29.5 97.6M62.5 95L62.5 97.6M65.5 94.6L65.5 97', w: 0.7, o: 0.3 },
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M27.4 80C29 76.4 32 75 35 75C38.4 75 41.6 77 43.2 80.4M52.8 80.4C54.4 77 57.6 75 61 75C64 75 67 76.4 68.6 80',
|
||||
w: 1,
|
||||
o: 0.2,
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M39.4 60.4C33.4 65.4 34.6 72.6 42.6 71.6M56.6 60.4C62.6 65.4 61.4 72.6 53.4 71.6',
|
||||
w: 8.6,
|
||||
o: 0.22,
|
||||
},
|
||||
{ p: 0, d: 'M39.4 60.4C33.4 65.4 34.6 72.6 42.6 71.6M56.6 60.4C62.6 65.4 61.4 72.6 53.4 71.6', w: 7 },
|
||||
{ p: 3, d: 'M35.4 63.4C39.4 74.6 56.4 76.4 62.4 62.4C58.4 67.6 42.4 69 35.4 63.4Z' },
|
||||
{ p: 'shade', d: 'M37.2 67.6C42 73.6 53.6 74.8 59.6 68.4C54.6 75.8 41.6 74.6 37.2 67.6Z', o: 0.2 },
|
||||
{ p: '#6b4a2b', d: 'M62 62.9L63.6 60.4', w: 1.5 },
|
||||
{
|
||||
p: '#6b4a2b',
|
||||
d: 'M35 63.9C35 64.22 35.17 64.52 35.45 64.68C35.73 64.84 36.07 64.84 36.35 64.68C36.63 64.52 36.8 64.22 36.8 63.9C36.8 63.58 36.63 63.28 36.35 63.12C36.07 62.96 35.73 62.96 35.45 63.12C35.17 63.28 35 63.58 35 63.9Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M39.4 71C39.4 72.99 41.01 74.6 43 74.6C44.99 74.6 46.6 72.99 46.6 71C46.6 69.01 44.99 67.4 43 67.4C41.01 67.4 39.4 69.01 39.4 71Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M49.4 71C49.4 72.99 51.01 74.6 53 74.6C54.99 74.6 56.6 72.99 56.6 71C56.6 69.01 54.99 67.4 53 67.4C51.01 67.4 49.4 69.01 49.4 71Z',
|
||||
},
|
||||
{ p: 'shade', d: 'M41 69.4L44.6 69.8M41 71.8L44.6 72M51.4 69.8L55 69.4M51.4 72L55 71.8', w: 0.6, o: 0.3 },
|
||||
{
|
||||
p: 0,
|
||||
d: 'M23.7 37C23.7 39.43 25 41.67 27.1 42.89C29.2 44.1 31.8 44.1 33.9 42.89C36 41.67 37.3 39.43 37.3 37C37.3 34.57 36 32.33 33.9 31.11C31.8 29.9 29.2 29.9 27.1 31.11C25 32.33 23.7 34.57 23.7 37ZM58.7 37C58.7 39.43 60 41.67 62.1 42.89C64.2 44.1 66.8 44.1 68.9 42.89C71 41.67 72.3 39.43 72.3 37C72.3 34.57 71 32.33 68.9 31.11C66.8 29.9 64.2 29.9 62.1 31.11C60 32.33 58.7 34.57 58.7 37Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M26.6 37.2C26.6 38.7 27.4 40.09 28.7 40.84C30 41.59 31.6 41.59 32.9 40.84C34.2 40.09 35 38.7 35 37.2C35 35.7 34.2 34.31 32.9 33.56C31.6 32.81 30 32.81 28.7 33.56C27.4 34.31 26.6 35.7 26.6 37.2ZM61 37.2C61 39.52 62.88 41.4 65.2 41.4C67.52 41.4 69.4 39.52 69.4 37.2C69.4 34.88 67.52 33 65.2 33C62.88 33 61 34.88 61 37.2Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M29.4 37.6C29.4 38.7 30.3 39.6 31.4 39.6C32.5 39.6 33.4 38.7 33.4 37.6C33.4 36.5 32.5 35.6 31.4 35.6C30.3 35.6 29.4 36.5 29.4 37.6ZM62.6 37.6C62.6 38.31 62.98 38.97 63.6 39.33C64.22 39.69 64.98 39.69 65.6 39.33C66.22 38.97 66.6 38.31 66.6 37.6C66.6 36.89 66.22 36.23 65.6 35.87C64.98 35.51 64.22 35.51 63.6 35.87C62.98 36.23 62.6 36.89 62.6 37.6Z',
|
||||
o: 0.15,
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M42.6 20.6C43.4 17 45.8 15.4 47 13.6C47.6 15.6 48 16.6 49.6 16.4C50.6 14.8 52.6 14 54.4 14.6C53 16.4 54 18.6 54.4 20.6Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M31 36.5C31 45.89 38.61 53.5 48 53.5C57.39 53.5 65 45.89 65 36.5C65 27.11 57.39 19.5 48 19.5C38.61 19.5 31 27.11 31 36.5Z',
|
||||
},
|
||||
{ p: 'light', d: 'M37 26.6C39.4 23.6 42.8 22 46 21.6', w: 1.6, o: 0.3 },
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M60.6 25C67.6 33 65.6 47.6 54.4 52.2C50.6 53.8 46 53.8 42 52.6C52 52.6 62 44 60.6 25Z',
|
||||
o: 0.12,
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M35 35C35 37.57 36.37 39.95 38.6 41.24C40.83 42.52 43.57 42.52 45.8 41.24C48.03 39.95 49.4 37.57 49.4 35C49.4 32.43 48.03 30.05 45.8 28.76C43.57 27.48 40.83 27.48 38.6 28.76C36.37 30.05 35 32.43 35 35Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M46.6 35C46.6 37.57 47.97 39.95 50.2 41.24C52.43 42.52 55.17 42.52 57.4 41.24C59.63 39.95 61 37.57 61 35C61 32.43 59.63 30.05 57.4 28.76C55.17 27.48 52.43 27.48 50.2 28.76C47.97 30.05 46.6 32.43 46.6 35Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M38 44.2C38 48.51 42.48 52 48 52C53.52 52 58 48.51 58 44.2C58 39.89 53.52 36.4 48 36.4C42.48 36.4 38 39.89 38 44.2Z',
|
||||
},
|
||||
{
|
||||
p: '#e98f8f',
|
||||
d: 'M36.8 43.6C36.8 44.48 37.96 45.2 39.4 45.2C40.84 45.2 42 44.48 42 43.6C42 42.72 40.84 42 39.4 42C37.96 42 36.8 42.72 36.8 43.6ZM54 43.6C54 44.48 55.16 45.2 56.6 45.2C58.04 45.2 59.2 44.48 59.2 43.6C59.2 42.72 58.04 42 56.6 42C55.16 42 54 42.72 54 43.6Z',
|
||||
o: 0.45,
|
||||
},
|
||||
{
|
||||
p: 'ink',
|
||||
d: 'M40.3 35.6C40.3 36.87 41.33 37.9 42.6 37.9C43.87 37.9 44.9 36.87 44.9 35.6C44.9 34.33 43.87 33.3 42.6 33.3C41.33 33.3 40.3 34.33 40.3 35.6ZM51.1 35.6C51.1 36.87 52.13 37.9 53.4 37.9C54.67 37.9 55.7 36.87 55.7 35.6C55.7 34.33 54.67 33.3 53.4 33.3C52.13 33.3 51.1 34.33 51.1 35.6Z',
|
||||
},
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M42.5 34.8C42.5 35.24 42.86 35.6 43.3 35.6C43.74 35.6 44.1 35.24 44.1 34.8C44.1 34.36 43.74 34 43.3 34C42.86 34 42.5 34.36 42.5 34.8ZM53.3 34.8C53.3 35.24 53.66 35.6 54.1 35.6C54.54 35.6 54.9 35.24 54.9 34.8C54.9 34.36 54.54 34 54.1 34C53.66 34 53.3 34.36 53.3 34.8Z',
|
||||
},
|
||||
{ p: 0, d: 'M38.8 30.2C40.8 28.6 43.6 28.4 45.6 29.6M50.4 29.6C52.4 28.4 55.2 28.6 57.2 30.2', w: 1.1 },
|
||||
{
|
||||
p: 'ink',
|
||||
d: 'M45.8 42.2C45.8 42.81 46.16 43.3 46.6 43.3C47.04 43.3 47.4 42.81 47.4 42.2C47.4 41.59 47.04 41.1 46.6 41.1C46.16 41.1 45.8 41.59 45.8 42.2ZM48.6 42.2C48.6 42.81 48.96 43.3 49.4 43.3C49.84 43.3 50.2 42.81 50.2 42.2C50.2 41.59 49.84 41.1 49.4 41.1C48.96 41.1 48.6 41.59 48.6 42.2Z',
|
||||
o: 0.8,
|
||||
},
|
||||
{ p: 'ink', d: 'M43.4 46.4C46 49.4 50 49.4 52.6 46.4', w: 0.9 },
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -208,4 +208,185 @@ export const DOODLES_STICKERS: readonly BookStickerDef[] = [
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
{
|
||||
id: 'doodlePalm',
|
||||
theme: 'doodles',
|
||||
tags: ['palm', 'palm tree', 'doodle', 'hand drawn', 'beach', 'tropical', 'island', 'sketch'],
|
||||
descr:
|
||||
'A hand-drawn palm tree doodle with a curved ringed trunk, four notched green fronds, three coconuts and a small sandy island line.',
|
||||
box: [100, 108],
|
||||
slots: [
|
||||
{ name: 'leaf', color: '#2a9d5c' },
|
||||
{ name: 'trunk', color: '#8a5a2b' },
|
||||
],
|
||||
layers: [
|
||||
{
|
||||
p: 1,
|
||||
w: 2.4,
|
||||
d: 'M13.8 101C15.5 100.5 20.1 99 23.8 98C27.6 97.1 32 96 36.4 95.4C40.7 94.8 45.4 94.6 50 94.6C54.6 94.5 59.7 94.6 64 95.2C68.3 95.8 71.7 97.2 75.7 98.2C79.7 99.2 85.8 100.7 87.9 101.2',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
w: 2.2,
|
||||
d: 'M20 104.6C22.3 104.4 29.4 103.7 33.9 103.8C38.4 103.8 44.8 104.5 46.9 104.7M56 104.4C58.3 104.4 65.8 104 70 104C74.1 104.1 79.1 104.7 81 104.9',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
w: 2.4,
|
||||
d: 'M41.8 97C41.6 95.7 41.2 92.1 41 89.6C40.7 87.1 40.3 84.5 40.4 82.1C40.5 79.7 41.1 77.4 41.4 75.2C41.6 72.9 41.5 70.7 42 68.5C42.5 66.3 43.5 64.1 44.2 62C44.9 59.9 45.5 57.8 46.3 55.9C47 54 47.8 52.5 48.7 50.8C49.6 49 50.8 46.9 51.7 45.2C52.7 43.6 53.3 42.2 54.4 40.7C55.4 39.2 57.4 36.9 58 36.1',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
w: 2.4,
|
||||
d: 'M52.2 95C52 93.9 51.1 90.4 50.8 88.2C50.5 86 50.4 83.8 50.3 81.8C50.3 79.7 50.5 78 50.6 76.1C50.7 74.2 50.6 72.2 50.8 70.3C51 68.3 51.2 66.3 51.7 64.5C52.2 62.6 53.1 60.7 53.8 59C54.4 57.2 54.9 55.5 55.6 53.8C56.3 52.1 57.1 50.4 57.9 48.8C58.8 47.3 59.7 46 60.5 44.5C61.4 43.1 62.6 40.7 63 40',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
w: 1.8,
|
||||
d: 'M41.3 89.4C42 89 44.5 87.5 45.5 87.4C46.6 87.2 47.3 88.5 47.7 88.7M41.1 82.1C41.8 81.9 44.4 80.7 45.4 80.7C46.4 80.7 46.8 81.8 47.1 82.1M41.5 75.2C42.2 75 44.9 74.2 45.9 74.3C46.8 74.4 47 75.5 47.3 75.7M42.7 68.6C43.4 68.5 45.9 67.7 46.8 67.8C47.7 68 47.9 69.3 48.1 69.5M44.3 62.2C44.9 62.1 47.5 61.6 48.3 61.8C49.2 62 49.2 63.3 49.4 63.7M46.5 56.2C47.1 56.3 49.3 56.1 50.1 56.4C50.9 56.7 51 57.7 51.1 58M49 50.7C49.6 50.7 52 50.6 52.7 50.9C53.5 51.2 53.1 52.4 53.2 52.7M51.7 45.6C52.3 45.6 54.7 45.4 55.4 45.8C56.1 46.1 55.7 47.4 55.7 47.7M55 40.7C55.5 40.8 57.4 40.6 58 41C58.6 41.4 58.3 42.7 58.4 43',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
w: 2.3,
|
||||
d: 'M57.9 33.1C58.2 32.4 57.7 31.2 56.7 30.2C55.7 29.2 53.8 27.9 52.2 27C50.6 26.1 48.7 25.3 47 24.8C45.2 24.3 43.3 24.2 41.5 24.2C39.7 24.2 37.7 24.3 35.9 24.7C34.2 25.2 32.6 26 31 26.9C29.4 27.8 27.9 28.8 26.6 30.1C25.2 31.4 24.1 32.9 23 34.5C21.9 36.1 20.8 37.8 20.1 39.6C19.3 41.5 17.9 45 18.5 45.5C19.2 46 21.9 43.1 23.8 42.7C25.8 42.2 29 43.2 30.4 42.7C31.8 42.1 31.2 39.5 32.4 39.3C33.7 39.2 36.6 42.2 37.8 41.9C38.9 41.6 38.5 37.9 39.3 37.7C40.2 37.4 41.6 40.9 42.8 40.6C43.9 40.3 45 36.5 46.1 36C47.2 35.5 48.1 37.8 49.6 37.6C51.1 37.4 53.7 35.4 55 34.6C56.4 33.9 57.7 33.9 57.9 33.1Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
w: 1.9,
|
||||
d: 'M58.4 33.4C57.3 33 54.1 31.6 51.9 31C49.8 30.4 47.6 30.1 45.5 30C43.4 29.9 41.5 30.1 39.5 30.5C37.5 30.8 35.4 31.4 33.6 32.2C31.8 32.9 30.2 33.7 28.5 34.9C26.8 36.2 24.3 38.8 23.4 39.5',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
w: 2.3,
|
||||
d: 'M59.9 32.4C60.5 32.1 60.7 31.6 60.8 30.6C60.8 29.6 60.4 27.9 60.1 26.7C59.7 25.5 59.2 24.4 58.8 23.4C58.4 22.5 58.1 21.7 57.7 20.8C57.3 19.9 56.8 18.9 56.3 18.2C55.7 17.4 54.9 16.8 54.3 16.2C53.6 15.7 53.1 15.4 52.4 14.9C51.7 14.5 50.9 13.8 50.1 13.6C49.4 13.4 48.6 13.6 47.9 13.6C47.2 13.6 46.2 13.2 45.8 13.7C45.4 14.2 45.6 15.8 45.6 16.6C45.6 17.4 45.7 18 45.7 18.7C45.8 19.4 45.7 19.9 45.8 20.6C46 21.2 46.5 22 46.7 22.7C47 23.3 47.1 23.9 47.6 24.5C48 25.1 48.6 25.8 49.3 26.4C50 27 50.8 27.4 51.6 28.1C52.5 28.7 53.2 29.7 54.2 30.4C55.1 31.1 56.3 32 57.3 32.4C58.3 32.7 59.3 32.7 59.9 32.4Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
w: 1.9,
|
||||
d: 'M60 32.8C59.7 32.1 58.6 30.1 57.8 28.8C57 27.5 56 26.3 55.3 25.1C54.5 23.9 54.1 22.8 53.4 21.8C52.7 20.8 51.8 20 51.1 19.1C50.4 18.3 49.7 17.4 49.1 16.7C48.5 16.1 47.7 15.5 47.4 15.2',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
w: 2.3,
|
||||
d: 'M62.1 32.5C61.6 32.2 61.5 31.5 61.4 30.6C61.4 29.6 61.6 28.1 61.8 26.9C62 25.7 62.3 24.5 62.7 23.5C63 22.4 63.5 21.5 63.9 20.6C64.3 19.8 64.6 19 65.1 18.2C65.6 17.4 66.1 16.5 66.7 15.9C67.4 15.3 68.3 14.9 69.1 14.5C69.8 14.1 70.5 13.8 71.2 13.6C71.8 13.3 72.3 12.9 73 12.8C73.8 12.8 75 12.7 75.5 13.2C76 13.7 75.7 15.2 75.8 16C75.8 16.8 75.9 17.5 75.8 18.1C75.8 18.8 75.6 19.4 75.5 20C75.4 20.7 75.4 21.3 75.1 21.9C74.8 22.6 74.2 23.5 73.8 24.2C73.3 24.9 72.9 25.5 72.3 26.1C71.8 26.7 71 27.2 70.2 27.9C69.5 28.5 68.8 29.2 67.9 29.9C66.9 30.6 65.6 31.8 64.7 32.2C63.7 32.6 62.7 32.8 62.1 32.5Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
w: 1.9,
|
||||
d: 'M61.8 32.9C62.2 32.1 63.4 29.8 64.2 28.4C64.9 27.1 65.6 26 66.3 24.9C67 23.7 67.7 22.4 68.4 21.4C69 20.4 69.5 19.4 70.1 18.6C70.7 17.9 71.3 17.3 71.9 16.6C72.6 16 73.5 15 73.8 14.6',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
w: 2.3,
|
||||
d: 'M63.6 33.2C63.3 32.6 63.8 31.8 64.6 30.9C65.4 30.1 67 28.9 68.5 28.2C69.9 27.5 71.7 27 73.2 26.7C74.8 26.3 76.3 25.9 77.8 26C79.4 26.1 81 26.8 82.4 27.4C83.9 28 85.5 28.7 86.8 29.6C88.1 30.5 89.3 31.5 90.4 32.7C91.5 34 92.3 35.7 93.2 37.3C94 38.8 94.9 40.3 95.5 42.1C96.1 43.8 97.3 47.4 96.8 47.7C96.3 48.1 94 45 92.2 44.3C90.5 43.6 87.6 44.1 86.3 43.4C85 42.6 85.4 40.1 84.3 39.8C83.2 39.5 80.6 41.9 79.7 41.5C78.7 41.1 79.4 37.8 78.7 37.6C78 37.3 76.4 40.2 75.6 39.9C74.7 39.5 74.4 36 73.6 35.5C72.7 35.1 71.7 37.3 70.4 37.1C69.1 37 67.1 35.2 66 34.6C64.8 33.9 63.8 33.8 63.6 33.2Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
w: 1.9,
|
||||
d: 'M63.1 33.4C64.1 33.1 66.9 32 68.8 31.5C70.6 31.1 72.5 30.6 74.2 30.6C75.9 30.6 77.4 31.2 79 31.6C80.6 32.1 82.2 32.5 83.7 33.4C85.3 34.3 86.7 35.6 88.2 37C89.7 38.3 91.9 40.8 92.6 41.5',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
w: 2,
|
||||
d: 'M59.8 38.7C59.9 39 60.2 40.2 59.9 40.8C59.7 41.4 59 42.1 58.4 42.4C57.8 42.7 56.9 42.7 56.2 42.5C55.6 42.2 54.9 41.6 54.6 41C54.3 40.4 54.2 39.5 54.4 38.9C54.6 38.2 55.3 37.5 55.9 37.2C56.5 36.9 57.5 36.8 58.1 37C58.8 37.2 59.4 37.8 59.8 38.4C60.1 39.1 60 40.3 60.1 40.7M67 39.1C67 39.5 67.2 40.6 67 41.2C66.7 41.8 66.1 42.5 65.5 42.8C64.9 43 64 43.2 63.4 43C62.7 42.7 62 42 61.7 41.4C61.4 40.8 61.4 39.9 61.6 39.3C61.9 38.7 62.5 38 63.1 37.7C63.7 37.3 64.7 37.2 65.3 37.4C66 37.6 66.7 38.2 67 38.8C67.3 39.4 67.1 40.7 67.1 41M63.6 43.3C63.5 43.6 63.7 44.8 63.4 45.4C63.1 46.1 62.6 46.7 62 46.9C61.4 47.2 60.4 47.4 59.8 47.2C59.1 47 58.4 46.3 58.1 45.7C57.8 45.1 57.8 44.1 58 43.5C58.3 42.8 58.9 42.2 59.5 41.9C60.1 41.6 61 41.4 61.7 41.6C62.4 41.8 63.1 42.4 63.4 43C63.7 43.6 63.5 44.9 63.5 45.2',
|
||||
},
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
{
|
||||
id: 'doodleWaves',
|
||||
theme: 'doodles',
|
||||
tags: ['waves', 'sea', 'ocean', 'doodle', 'hand drawn', 'water', 'beach', 'surf'],
|
||||
descr:
|
||||
'Three stacked hand-drawn wave lines in blue pen, each crest leaning forward and rolling over into a small spiral curl.',
|
||||
box: [100, 86],
|
||||
slots: [
|
||||
{ name: 'deep', color: '#2b86d1' },
|
||||
{ name: 'surf', color: '#4fc0e8' },
|
||||
],
|
||||
layers: [
|
||||
{
|
||||
p: 0,
|
||||
w: 2.6,
|
||||
d: 'M4.9 29C5.6 28.8 7.8 28.7 9.1 28.1C10.5 27.6 11.7 26.7 13 25.7C14.3 24.7 15.7 23.1 16.9 22C18.1 21 19 20 20.2 19.3C21.4 18.7 22.9 18.2 24.1 18.2C25.2 18.3 26.6 18.9 27.3 19.5C28 20.2 28.3 21.4 28.3 22.3C28.3 23.2 27.9 24.4 27.3 24.9C26.8 25.5 25.7 25.7 25 25.5C24.4 25.3 23.6 24.4 23.4 23.7C23.2 23.1 23.5 22 24 21.5C24.5 21 25.9 21.1 26.3 21M32.9 29C33.6 28.9 35.7 28.8 37.1 28.2C38.4 27.7 39.8 26.8 41.1 25.7C42.5 24.6 43.8 22.9 45 21.8C46.1 20.6 47 19.4 48.1 18.7C49.2 18 50.5 17.6 51.6 17.8C52.8 17.9 54.3 18.8 55.1 19.5C55.9 20.2 56.3 21.1 56.3 21.9C56.3 22.8 55.8 24.2 55.2 24.8C54.6 25.3 53.4 25.6 52.8 25.3C52.1 25.1 51.5 24.1 51.3 23.4C51.2 22.8 51.6 21.7 52 21.3C52.5 20.8 53.9 20.8 54.3 20.7M60.8 29C61.4 28.9 63.3 28.8 64.7 28.2C66.1 27.6 67.7 26.7 69.1 25.6C70.4 24.6 71.6 22.9 72.8 21.8C74 20.7 74.9 19.8 76.1 19.2C77.2 18.6 78.5 18.3 79.6 18.4C80.8 18.5 82.2 19.1 83 19.7C83.7 20.3 84.2 21.3 84.2 22.1C84.2 22.9 83.6 24 83 24.5C82.5 25.1 81.6 25.5 80.9 25.4C80.2 25.2 79.2 24.3 79 23.6C78.8 22.9 79.2 21.7 79.7 21.2C80.2 20.8 81.7 20.9 82.1 20.9',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
w: 2.4,
|
||||
d: 'M28.2 21.8C28.3 22.3 28.5 24.1 28.8 25C29.2 25.9 29.8 26.6 30.4 27.2C31 27.9 31.6 28.6 32.3 28.9C33.1 29.2 34.5 29.1 34.9 29.1M56.2 21.8C56.3 22.3 56.3 23.8 56.7 24.8C57.1 25.7 57.8 26.7 58.4 27.4C59 28.1 59.6 28.6 60.4 28.9C61.1 29.1 62.4 29 62.8 29M84.1 21.8C84.2 22.3 84.3 24 84.6 24.9C84.9 25.8 85.4 26.8 86.1 27.3C86.8 27.9 88.2 28.3 88.7 28.5',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
w: 2.6,
|
||||
d: 'M10.1 54.9C10.8 54.8 12.9 54.7 14.2 54.2C15.6 53.6 16.9 52.7 18.1 51.7C19.4 50.6 20.8 48.9 22 47.8C23.1 46.7 24 45.6 25.1 44.9C26.3 44.3 27.5 43.8 28.7 43.8C29.8 43.9 31.1 44.5 32 45.2C32.8 45.9 33.4 47 33.5 47.9C33.6 48.8 33.1 50.1 32.5 50.6C31.9 51.1 30.6 51.3 29.9 51.2C29.1 51 28.4 50.3 28.2 49.7C28.1 49 28.5 47.7 29 47.2C29.5 46.7 31 46.8 31.4 46.7M38 55.1C38.6 54.9 40.5 54.8 41.9 54.3C43.2 53.8 44.6 53.1 45.9 52C47.2 50.9 48.6 48.9 49.8 47.7C51 46.6 51.8 45.7 53 45.1C54.2 44.5 55.8 44 57 44C58.1 44.1 59.2 44.8 60 45.5C60.8 46.2 61.5 47.4 61.5 48.3C61.6 49.2 61 50.2 60.4 50.7C59.8 51.3 58.7 51.6 58 51.5C57.3 51.3 56.2 50.4 56.1 49.7C55.9 49 56.4 47.6 56.9 47.1C57.4 46.6 58.8 46.8 59.2 46.8M65.8 55.1C66.5 55 68.5 54.9 69.9 54.4C71.3 53.8 72.8 53 74.1 51.9C75.4 50.8 76.6 48.9 77.8 47.7C79 46.6 79.9 45.5 81.1 44.9C82.2 44.3 83.4 43.9 84.6 44C85.7 44 87.3 44.6 88.1 45.3C88.8 46 89 47.3 89 48.2C89.1 49.1 88.9 49.9 88.4 50.4C87.9 51 86.6 51.6 85.9 51.5C85.1 51.3 84.3 50.2 84.1 49.5C83.9 48.8 84.2 47.8 84.7 47.3C85.2 46.9 86.7 46.8 87.1 46.7',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
w: 2.4,
|
||||
d: 'M33.3 47.8C33.4 48.3 33.6 49.9 33.9 50.9C34.3 51.8 34.8 52.9 35.4 53.5C36 54.1 36.8 54.5 37.6 54.7C38.3 55 39.5 55 39.9 55.1M61.2 47.8C61.3 48.3 61.5 50.1 61.8 51C62.1 51.9 62.6 52.6 63.2 53.2C63.8 53.8 64.6 54.3 65.3 54.6C66.1 54.9 67.4 55 67.8 55.1M89 47.7C89.1 48.3 89.2 50.1 89.6 51C90 51.9 90.7 52.8 91.3 53.3C92 53.9 93.2 54.3 93.6 54.5',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
w: 2.6,
|
||||
d: 'M3 81C3.6 80.9 5.5 80.8 6.9 80.3C8.3 79.8 9.9 78.9 11.2 77.8C12.5 76.8 13.6 75 14.8 73.9C16 72.8 17.1 71.7 18.3 71.2C19.4 70.6 20.5 70.4 21.7 70.5C22.8 70.6 24.4 71 25.1 71.6C25.9 72.2 26.2 73.3 26.3 74.2C26.4 75.1 26.2 76.5 25.6 77C25 77.5 23.6 77.5 22.9 77.3C22.2 77.1 21.6 76.2 21.4 75.6C21.3 75 21.4 74 21.9 73.6C22.3 73.2 23.9 73.1 24.3 73M30.9 81C31.6 80.9 33.7 80.6 35.1 80.1C36.4 79.5 37.7 78.5 39 77.5C40.3 76.4 41.4 74.9 42.6 73.7C43.8 72.6 44.9 71.2 46.1 70.5C47.3 69.9 48.7 69.8 49.9 69.9C51.1 70 52.4 70.6 53.2 71.2C53.9 71.9 54.4 73.1 54.5 74C54.6 74.9 54.1 75.9 53.6 76.4C53 76.9 51.8 77.4 51.1 77.2C50.4 77 49.5 75.9 49.3 75.2C49.1 74.5 49.5 73.5 50 73C50.5 72.6 51.8 72.6 52.2 72.5M58.8 81C59.5 80.8 61.4 80.6 62.8 80C64.2 79.4 65.7 78.6 67 77.4C68.3 76.3 69.4 74.4 70.6 73.2C71.7 72.1 72.7 71 73.8 70.4C75 69.8 76.5 69.4 77.7 69.5C78.8 69.6 80.3 70.1 81 70.8C81.8 71.5 82.1 72.7 82.1 73.6C82.2 74.6 81.9 75.9 81.3 76.5C80.7 77.1 79.3 77.4 78.6 77.2C77.9 77.1 77.2 76.1 77.1 75.3C76.9 74.6 77.1 73.4 77.6 72.9C78.1 72.4 79.7 72.5 80.1 72.4',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
w: 2.4,
|
||||
d: 'M26.3 73.8C26.4 74.4 26.6 76 27 76.9C27.3 77.8 27.8 78.6 28.3 79.2C28.9 79.9 29.6 80.5 30.3 80.8C31.1 81.1 32.5 81 33 81.1M54.2 73.8C54.3 74.3 54.5 76.1 54.9 77C55.2 77.9 55.6 78.7 56.2 79.3C56.8 79.9 57.7 80.3 58.4 80.6C59.2 80.9 60.4 81 60.8 81.1M82.1 73.8C82.2 74.3 82.5 76.1 82.8 77C83.1 78 83.5 78.7 84.1 79.2C84.7 79.8 86.2 80.3 86.6 80.5',
|
||||
},
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
{
|
||||
id: 'doodleSunRays',
|
||||
theme: 'doodles',
|
||||
tags: ['sun', 'sunrise', 'sunset', 'doodle', 'hand drawn', 'summer', 'rays', 'riviera'],
|
||||
descr:
|
||||
'A hand-drawn half sun rising over a wobbly horizon line, filled yellow with orange outline, long and short rays and sea dashes below.',
|
||||
box: [100, 82],
|
||||
slots: [
|
||||
{ name: 'sun', color: '#ffc93c' },
|
||||
{ name: 'ray', color: '#f0772b' },
|
||||
],
|
||||
layers: [
|
||||
{
|
||||
p: 0,
|
||||
d: 'M28.9 64.4C29.2 63.2 29.6 59.5 30.4 57.3C31.2 55 32.4 52.8 33.9 50.9C35.5 49 37.5 47.3 39.6 46.1C41.7 44.8 44.1 43.7 46.5 43.2C48.9 42.8 51.5 42.8 53.8 43.2C56.1 43.7 58.4 44.6 60.4 45.8C62.5 47.1 64.5 48.9 66.1 50.8C67.7 52.7 69 54.8 69.9 57C70.7 59.3 70.8 63.2 71 64.5L70.5 65.4L29.5 65.4Z',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
w: 2.6,
|
||||
d: 'M28.9 64.3C29.1 63.1 29.1 59.1 29.9 56.9C30.7 54.6 32.3 52.6 33.9 50.8C35.5 49 37.6 47.3 39.6 46.1C41.6 44.9 43.7 44 46 43.5C48.3 43 51.1 42.7 53.6 43.2C56 43.6 58.7 44.9 60.8 46.2C62.9 47.4 64.5 48.9 66 50.7C67.6 52.4 69.3 54.5 70.1 56.8C71 59.1 70.9 63.3 71 64.6',
|
||||
},
|
||||
{
|
||||
p: 'light',
|
||||
o: 0.6,
|
||||
w: 2,
|
||||
d: 'M38.6 56.7C38.8 56.5 39.5 55.6 40 55.1C40.5 54.6 41 54.1 41.6 53.6C42.2 53.1 42.9 52.5 43.5 52.2C44.1 51.8 44.6 51.7 45.3 51.4C45.9 51.2 47.1 50.9 47.5 50.7',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
w: 2.6,
|
||||
d: 'M76.3 60.7C77.5 60.4 81.3 59.4 83.8 59.1C86.3 58.8 89.9 58.9 91.1 58.8M71.8 48.9C72.7 48.1 75.4 45.6 77.5 44.2C79.6 42.7 83.3 41 84.5 40.3M62.3 40.5C62.8 39.3 64.2 35.7 65.3 33.5C66.3 31.3 68.1 28.3 68.6 27.2M50 37.5C49.9 36.3 49.5 32.6 49.5 30.1C49.5 27.6 50.1 23.8 50.2 22.5M37.7 40.5C37.1 39.4 34.9 36.1 33.8 33.9C32.7 31.7 31.6 28.3 31.2 27.2M28.2 48.9C27.2 48.2 23.9 46.3 21.9 44.9C19.8 43.5 16.8 41.1 15.8 40.3M23.7 60.7C22.5 60.6 18.9 60.5 16.4 60.2C14 59.9 10.1 59 8.8 58.8',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
w: 2.2,
|
||||
d: 'M74.7 54.5C75.4 54.2 77.6 53.2 78.9 52.7C80.3 52.1 82.3 51.5 83 51.3M67.5 44.1C68 43.5 69.2 41.4 70.2 40.3C71.3 39.2 73.3 37.9 73.9 37.4M56.3 38.3C56.4 37.5 56.6 35.4 57.1 34C57.5 32.5 58.8 30.3 59.1 29.5M43.7 38.3C43.4 37.6 42.4 35.6 42.1 34.1C41.9 32.7 42.2 30.3 42.2 29.5M32.5 44.1C31.9 43.6 30.1 42.4 29.2 41.3C28.3 40.2 27.3 38 26.9 37.4M25.3 54.5C24.6 54.3 22.5 53.7 21.1 53.2C19.6 52.6 17.5 51.6 16.7 51.3',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
w: 2.6,
|
||||
d: 'M7 65.5C9.5 65.3 16.5 64.4 21.7 64.4C26.9 64.4 33.4 65.5 38.1 65.5C42.8 65.5 45.7 64.6 50 64.5C54.3 64.4 58.7 64.9 63.7 65C68.6 65 74.9 64.8 79.8 64.9C84.7 65 90.8 65.3 92.9 65.3',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
w: 2.2,
|
||||
d: 'M24 71.6C25.5 71.5 29.9 71.2 32.9 71.2C35.9 71.2 40.5 71.5 42 71.5M55 71.3C56.7 71.3 61.7 70.9 65 71C68.3 71 73.3 71.4 75 71.5',
|
||||
},
|
||||
{ p: 1, w: 2, d: 'M38 77C40 76.9 46.1 76.7 49.9 76.7C53.7 76.7 59.1 77 61 77' },
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -79,31 +79,40 @@ export const FOOD_STICKERS: readonly BookStickerDef[] = [
|
||||
id: 'croissant',
|
||||
theme: 'food',
|
||||
tags: ['croissant', 'pastry', 'bakery', 'france', 'paris', 'breakfast', 'boulangerie', 'cafe'],
|
||||
descr: 'A golden, flaky croissant curved into a crescent, its rolled layers shaded darker at each fold.',
|
||||
descr: 'A golden croissant curved into a crescent, its rolled layers marked by darker folds and a baked underside.',
|
||||
box: [110, 70],
|
||||
slots: [
|
||||
{ name: 'pastry', color: '#e2a04a' },
|
||||
{ name: 'crust', color: '#b86b24' },
|
||||
],
|
||||
layers: [
|
||||
{ p: 0, d: 'M8 50C4 44 8 36 16 36C18 40 22 44 28 46C26 52 18 56 12 54Z' },
|
||||
{ p: 0, d: 'M102 50C106 44 102 36 94 36C92 40 88 44 82 46C84 52 92 56 98 54Z' },
|
||||
{ p: 0, d: 'M18 34C20 24 28 18 36 18C38 26 40 34 40 46C32 48 24 44 18 34Z' },
|
||||
{ p: 0, d: 'M92 34C90 24 82 18 74 18C72 26 70 34 70 46C78 48 86 44 92 34Z' },
|
||||
{ p: 0, d: 'M36 16C42 8 50 6 55 6C60 6 68 8 74 16C72 28 70 40 68 50C60 54 50 54 42 50C40 40 38 28 36 16Z' },
|
||||
// One closed crescent, so no seam between two pieces can let the page show through.
|
||||
{
|
||||
p: 1,
|
||||
d: 'M28 46C24 44 20 40 16 36M40 46C40 34 38 26 36 18M70 46C70 34 72 26 74 18M82 46C86 44 90 40 94 36',
|
||||
o: 0.8,
|
||||
w: 1.6,
|
||||
p: 0,
|
||||
d: 'M6 54C3 46 10 38 20 34C27 21 39 11 55 10C71 11 83 21 90 34C100 38 107 46 104 54C98 57 92 55 86 50C78 54 66 56 55 56C44 56 32 54 24 50C18 55 12 57 6 54Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M8 51C14 53 19 51 24 47C32 51 44 53 55 53C66 53 78 51 86 47C91 51 96 53 102 51L104 54C98 57 92 55 86 50C78 54 66 56 55 56C44 56 32 54 24 50C18 55 12 57 6 54Z',
|
||||
o: 0.22,
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M20 34C26 36 30 40 32 52C28 52 26 51 24 50C21 46 20 40 20 34ZM90 34C84 36 80 40 78 52C82 52 84 51 86 50C89 46 90 40 90 34Z',
|
||||
o: 0.12,
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
d: 'M18 34C24 44 32 48 40 46L42 50C32 52 22 46 18 34ZM92 34C86 44 78 48 70 46L68 50C78 52 88 46 92 34ZM8 50C10 54 14 55 18 54L12 54C10 53 9 52 8 50Z',
|
||||
o: 0.6,
|
||||
d: 'M23 49C19 44 18.5 38.5 21 34M39 54C35 44 34.5 28 38.5 14.5M71 54C75 44 75.5 28 71.5 14.5M87 49C91 44 91.5 38.5 89 34',
|
||||
o: 0.85,
|
||||
w: 2,
|
||||
},
|
||||
{
|
||||
p: 'light',
|
||||
d: 'M47 15.5C50.5 13 59.5 13 63 15.5M26.5 31C28.5 27 31.5 24 35 22M75 22C78.5 24 81.5 27 83.5 31M9.5 46C10.5 43 13 40.5 16 39M94 39C97 40.5 99.5 43 100.5 46',
|
||||
o: 0.5,
|
||||
w: 1.8,
|
||||
},
|
||||
{ p: 'shade', d: 'M42 50C50 54 60 54 68 50L68 52C60 57 50 57 42 52Z', o: 0.2 },
|
||||
{ p: 'light', d: 'M44 14C48 10 54 9 58 9M24 28C26 24 30 21 34 20M78 22C82 23 86 26 88 30', o: 0.45, w: 1.6 },
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
@@ -122,7 +131,7 @@ export const FOOD_STICKERS: readonly BookStickerDef[] = [
|
||||
{ p: 0, d: 'M8 28L18 62C20 66 24 68 30 68L70 68C76 68 80 66 82 62L92 28Z' },
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M14 30L22 66M23 30L29.4 66M32 30L36.8 66M41 30L44.2 66M50 30L51.6 66M59 30L59 66M68 30L66.4 66M77 30L73.8 66M86 30L81.2 66',
|
||||
d: 'M14 30L21.2 62M23 30L29.4 66M32 30L36.8 66M41 30L44.2 66M50 30L51.6 66M59 30L59 66M68 30L66.4 66M77 30L73.8 66M86 30L81.8 62',
|
||||
o: 0.18,
|
||||
w: 1,
|
||||
},
|
||||
@@ -395,6 +404,8 @@ export const FOOD_STICKERS: readonly BookStickerDef[] = [
|
||||
],
|
||||
layers: [
|
||||
{ p: '#c9a77c', d: 'M22 8L98 36M30 4L102 30', w: 2.4 },
|
||||
// The bowl's rim behind the broth, and its front edge reaching over the broth below.
|
||||
{ p: 0, d: 'M102 46C102 53.2 81 59 55 59C29 59 8 53.2 8 46C8 38.8 29 33 55 33C81 33 102 38.8 102 46Z' },
|
||||
{ p: 1, d: 'M101 46C101 52.6 80.4 58 55 58C29.6 58 9 52.6 9 46C9 39.4 29.6 34 55 34C80.4 34 101 39.4 101 46Z' },
|
||||
{
|
||||
p: '#f3e3b8',
|
||||
@@ -424,7 +435,7 @@ export const FOOD_STICKERS: readonly BookStickerDef[] = [
|
||||
d: 'M21.6 50C21.6 50.9 20.9 51.6 20 51.6C19.1 51.6 18.4 50.9 18.4 50C18.4 49.1 19.1 48.4 20 48.4C20.9 48.4 21.6 49.1 21.6 50ZM20.7 50C20.7 50.4 20.4 50.7 20 50.7C19.6 50.7 19.3 50.4 19.3 50C19.3 49.6 19.6 49.3 20 49.3C20.4 49.3 20.7 49.6 20.7 50ZM25.6 48C25.6 48.9 24.9 49.6 24 49.6C23.1 49.6 22.4 48.9 22.4 48C22.4 47.1 23.1 46.4 24 46.4C24.9 46.4 25.6 47.1 25.6 48ZM24.7 48C24.7 48.4 24.4 48.7 24 48.7C23.6 48.7 23.3 48.4 23.3 48C23.3 47.6 23.6 47.3 24 47.3C24.4 47.3 24.7 47.6 24.7 48ZM53.6 52C53.6 52.9 52.9 53.6 52 53.6C51.1 53.6 50.4 52.9 50.4 52C50.4 51.1 51.1 50.4 52 50.4C52.9 50.4 53.6 51.1 53.6 52ZM52.7 52C52.7 52.4 52.4 52.7 52 52.7C51.6 52.7 51.3 52.4 51.3 52C51.3 51.6 51.6 51.3 52 51.3C52.4 51.3 52.7 51.6 52.7 52ZM87.6 44C87.6 44.9 86.9 45.6 86 45.6C85.1 45.6 84.4 44.9 84.4 44C84.4 43.1 85.1 42.4 86 42.4C86.9 42.4 87.6 43.1 87.6 44ZM86.7 44C86.7 44.4 86.4 44.7 86 44.7C85.6 44.7 85.3 44.4 85.3 44C85.3 43.6 85.6 43.3 86 43.3C86.4 43.3 86.7 43.6 86.7 44ZM91.6 48C91.6 48.9 90.9 49.6 90 49.6C89.1 49.6 88.4 48.9 88.4 48C88.4 47.1 89.1 46.4 90 46.4C90.9 46.4 91.6 47.1 91.6 48ZM90.7 48C90.7 48.4 90.4 48.7 90 48.7C89.6 48.7 89.3 48.4 89.3 48C89.3 47.6 89.6 47.3 90 47.3C90.4 47.3 90.7 47.6 90.7 48ZM57.6 40C57.6 40.9 56.9 41.6 56 41.6C55.1 41.6 54.4 40.9 54.4 40C54.4 39.1 55.1 38.4 56 38.4C56.9 38.4 57.6 39.1 57.6 40ZM56.7 40C56.7 40.4 56.4 40.7 56 40.7C55.6 40.7 55.3 40.4 55.3 40C55.3 39.6 55.6 39.3 56 39.3C56.4 39.3 56.7 39.6 56.7 40Z',
|
||||
eo: 1,
|
||||
},
|
||||
{ p: 0, d: 'M8 46C8 72 30 86 55 86C80 86 102 72 102 46C96 54 80 60 55 60C30 60 14 54 8 46Z' },
|
||||
{ p: 0, d: 'M8 46C8 72 30 86 55 86C80 86 102 72 102 46C96 53 80 57 55 57C30 57 14 53 8 46Z' },
|
||||
{ p: 'shade', d: 'M72 59C84 56 98 52 102 46C102 72 80 86 55 86C74 80 78 70 72 59Z', o: 0.18 },
|
||||
{
|
||||
p: '#fbf8f0',
|
||||
|
||||
@@ -843,4 +843,423 @@ export const LANDMARKS_STICKERS: readonly BookStickerDef[] = [
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
{
|
||||
id: 'parthenon',
|
||||
theme: 'landmarks',
|
||||
tags: ['parthenon', 'acropolis', 'athens', 'greece', 'temple', 'ancient', 'columns', 'landmark'],
|
||||
descr:
|
||||
'The Parthenon on the Acropolis in Athens, a row of fluted Doric columns in warm marble under a low pediment, standing on stepped base and rock with olive shrubs.',
|
||||
box: [110, 84],
|
||||
slots: [
|
||||
{ name: 'marble', color: '#efdfbf' },
|
||||
{ name: 'rock', color: '#b99a74' },
|
||||
{ name: 'detail', color: '#a98a62' },
|
||||
{ name: 'olive', color: '#7f8f4e' },
|
||||
],
|
||||
layers: [
|
||||
{ p: 'shade', d: 'M8 82C39.33 83.6 70.67 83.6 102 82C70.67 81.07 39.33 81.07 8 82Z', o: 0.1 },
|
||||
{
|
||||
p: 1,
|
||||
d: 'M3 81C2.4 77 4 73.5 7 71.5C9 69 11.5 67.5 15 66.8L40 65.4L72 65.4L95 66.8C98.5 67.5 101 69 103 71.5C106 73.5 107.6 77 107 81C90 82.6 20 82.6 3 81Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M3 81C2.4 77 4 73.5 7 71.5C9 69 11.5 67.5 15 66.8L17.5 69.5C15 72 16.5 76 21 81.6C12 81.6 6 81.4 3 81ZM107 81C107.6 77 106 73.5 103 71.5C101 69 98.5 67.5 95 66.8L92.5 69.5C95 72 93.5 76 89 81.6C98 81.6 104 81.4 107 81Z',
|
||||
o: 0.2,
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M4.2 77.6C25 79.6 85 79.6 105.8 77.6C106.6 78.8 107 79.8 107 81C90 82.6 20 82.6 3 81C3 79.8 3.4 78.6 4.2 77.6Z',
|
||||
o: 0.14,
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M26 72.6C32.67 71.67 39.33 71.73 46 72.8M58 75.4C66 74.47 74.67 74.6 84 75.8M31 78.4C37 77.73 43.33 77.8 50 78.6M66 79.4C70 79 74 79.07 78 79.6M88 72C90 71.6 91.83 71.6 93.5 72',
|
||||
o: 0.7,
|
||||
w: 0.9,
|
||||
},
|
||||
{ p: 'light', d: 'M12 69C24 67.6 36.67 66.9 50 66.9M62 66.9C75.33 66.9 87.33 67.6 98 69', o: 0.35, w: 0.9 },
|
||||
{
|
||||
p: 3,
|
||||
d: 'M5.1 72.7C3.4 69.9 6.2 67.2 8.4 67.9C9.5 65.5 13.4 66.1 13.5 68.8C15.7 69.2 15.7 72.7 13.9 72.7ZM96.5 72.5C95 70 97.5 67.5 99.5 68.2C100.5 66 104 66.5 104.1 69C106.1 69.4 106.1 72.5 104.5 72.5ZM13.5 69.3C12.4 67.5 14.3 65.6 15.8 66.1C16.5 64.5 19.1 64.8 19.2 66.7C20.7 67 20.7 69.3 19.5 69.3ZM90.9 69.4C89.9 67.8 91.6 66.1 92.9 66.6C93.5 65.2 95.8 65.5 95.8 67.1C97.1 67.4 97.1 69.4 96.1 69.4Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M5.5 72.3C7.5 73.4 11.5 73.4 13.6 72.3L13.9 72.6C11.5 73.9 7.5 73.9 5.2 72.6ZM96.8 72.2C98.6 73.2 102.4 73.2 104.2 72.2L104.5 72.5C102.4 73.7 98.6 73.7 96.5 72.5Z',
|
||||
o: 0.3,
|
||||
},
|
||||
{ p: 0, d: 'M12 61.5L98 61.5L98 66.5L12 66.5Z' },
|
||||
{ p: 0, d: 'M14 58L96 58L96 63L14 63Z' },
|
||||
{ p: 0, d: 'M16 54.5L94 54.5L94 59.5L16 59.5Z' },
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M12 65L98 65L98 66.5L12 66.5ZM14 61.5L96 61.5L96 62.7L14 62.7ZM16 58L94 58L94 59.2L16 59.2Z',
|
||||
o: 0.2,
|
||||
},
|
||||
{
|
||||
p: 'light',
|
||||
d: 'M12 61.6L98 61.6L98 62.2L12 62.2ZM14 58.1L96 58.1L96 58.7L14 58.7ZM16 54.6L94 54.6L94 55.2L16 55.2Z',
|
||||
o: 0.5,
|
||||
},
|
||||
{ p: 0, d: 'M19 26L91 26L91 56L19 56Z' },
|
||||
{ p: 'shade', d: 'M19 26L91 26L91 56L19 56Z', o: 0.38 },
|
||||
{
|
||||
p: 0,
|
||||
d: 'M19.1 28.5L24.3 28.5L24.9 55.5L18.5 55.5ZM28.6 28.5L33.8 28.5L34.4 55.5L28 55.5ZM38.1 28.5L43.3 28.5L43.9 55.5L37.5 55.5ZM47.6 28.5L52.8 28.5L53.4 55.5L47 55.5ZM57.2 28.5L62.4 28.5L63 55.5L56.6 55.5ZM66.7 28.5L71.9 28.5L72.5 55.5L66.1 55.5ZM76.2 28.5L81.4 28.5L82 55.5L75.6 55.5ZM85.7 28.5L90.9 28.5L91.5 55.5L85.1 55.5Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M22.5 28.5L24.3 28.5L24.9 55.5L22.6 55.5ZM32 28.5L33.8 28.5L34.4 55.5L32.1 55.5ZM41.5 28.5L43.3 28.5L43.9 55.5L41.6 55.5ZM51 28.5L52.8 28.5L53.4 55.5L51.1 55.5ZM60.5 28.5L62.4 28.5L63 55.5L60.7 55.5ZM70 28.5L71.9 28.5L72.5 55.5L70.2 55.5ZM79.6 28.5L81.4 28.5L82 55.5L79.7 55.5ZM89.1 28.5L90.9 28.5L91.5 55.5L89.2 55.5Z',
|
||||
o: 0.16,
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M20.6 30.5L20.6 54M21.7 30.5L21.7 54M30.1 30.5L30.1 54M31.2 30.5L31.2 54M39.6 30.5L39.6 54M40.7 30.5L40.7 54M49.2 30.5L49.2 54M50.2 30.5L50.2 54M58.7 30.5L58.7 54M59.8 30.5L59.8 54M68.2 30.5L68.2 54M69.3 30.5L69.3 54M77.7 30.5L77.7 54M78.8 30.5L78.8 54M87.2 30.5L87.2 54M88.3 30.5L88.3 54',
|
||||
o: 0.14,
|
||||
w: 0.45,
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M17.9 26.3L25.5 26.3L25.5 28.2C25.1 29.27 24.67 29.87 24.2 30L19.2 30C18.73 29.87 18.3 29.27 17.9 28.2ZM27.4 26.3L35 26.3L35 28.2C34.6 29.27 34.17 29.87 33.7 30L28.7 30C28.23 29.87 27.8 29.27 27.4 28.2ZM36.9 26.3L44.5 26.3L44.5 28.2C44.1 29.27 43.67 29.87 43.2 30L38.2 30C37.73 29.87 37.3 29.27 36.9 28.2ZM46.4 26.3L54 26.3L54 28.2C53.6 29.27 53.17 29.87 52.7 30L47.7 30C47.23 29.87 46.8 29.27 46.4 28.2ZM56 26.3L63.6 26.3L63.6 28.2C63.2 29.27 62.77 29.87 62.3 30L57.3 30C56.83 29.87 56.4 29.27 56 28.2ZM65.5 26.3L73.1 26.3L73.1 28.2C72.7 29.27 72.27 29.87 71.8 30L66.8 30C66.33 29.87 65.9 29.27 65.5 28.2ZM75 26.3L82.6 26.3L82.6 28.2C82.2 29.27 81.77 29.87 81.3 30L76.3 30C75.83 29.87 75.4 29.27 75 28.2ZM84.5 26.3L92.1 26.3L92.1 28.2C91.7 29.27 91.27 29.87 90.8 30L85.8 30C85.33 29.87 84.9 29.27 84.5 28.2Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M17.9 28.2L25.5 28.2L25.5 29L17.9 29ZM27.4 28.2L35 28.2L35 29L27.4 29ZM36.9 28.2L44.5 28.2L44.5 29L36.9 29ZM46.4 28.2L54 28.2L54 29L46.4 29ZM56 28.2L63.6 28.2L63.6 29L56 29ZM65.5 28.2L73.1 28.2L73.1 29L65.5 29ZM75 28.2L82.6 28.2L82.6 29L75 29ZM84.5 28.2L92.1 28.2L92.1 29L84.5 29Z',
|
||||
o: 0.14,
|
||||
},
|
||||
{ p: 0, d: 'M15 21L95 21L95 27L15 27Z' },
|
||||
{ p: 'shade', d: 'M15 25.4L95 25.4L95 27L15 27Z', o: 0.18 },
|
||||
{ p: 0, d: 'M15.5 16.5L94.5 16.5L94.5 21.7L15.5 21.7Z' },
|
||||
{
|
||||
p: 2,
|
||||
d: 'M15.3 17.3L17.1 17.3L17.1 21.1L15.3 21.1ZM20.1 17.3L21.9 17.3L21.9 21.1L20.1 21.1ZM24.9 17.3L26.7 17.3L26.7 21.1L24.9 21.1ZM29.7 17.3L31.5 17.3L31.5 21.1L29.7 21.1ZM34.6 17.3L36.4 17.3L36.4 21.1L34.6 21.1ZM39.4 17.3L41.2 17.3L41.2 21.1L39.4 21.1ZM44.2 17.3L46 17.3L46 21.1L44.2 21.1ZM49 17.3L50.8 17.3L50.8 21.1L49 21.1ZM53.8 17.3L55.6 17.3L55.6 21.1L53.8 21.1ZM58.6 17.3L60.4 17.3L60.4 21.1L58.6 21.1ZM63.4 17.3L65.2 17.3L65.2 21.1L63.4 21.1ZM68.2 17.3L70 17.3L70 21.1L68.2 21.1ZM73.1 17.3L74.9 17.3L74.9 21.1L73.1 21.1ZM77.9 17.3L79.7 17.3L79.7 21.1L77.9 21.1ZM82.7 17.3L84.5 17.3L84.5 21.1L82.7 21.1ZM87.5 17.3L89.3 17.3L89.3 21.1L87.5 21.1ZM92.3 17.3L94.1 17.3L94.1 21.1L92.3 21.1Z',
|
||||
o: 0.55,
|
||||
},
|
||||
{ p: 'light', d: 'M15 21L95 21L95 21.6L15 21.6Z', o: 0.55 },
|
||||
{ p: 0, d: 'M13 14.4L97 14.4L97 17.2L13 17.2Z' },
|
||||
{ p: 'shade', d: 'M13 16.4L97 16.4L97 17.2L13 17.2Z', o: 0.2 },
|
||||
{ p: 0, d: 'M13.5 15L55 4L83 11.4L85 12.6L88 12.4L90 13.6L96.5 15Z' },
|
||||
{ p: 'shade', d: 'M19.5 14.6L55 6.2L80.5 12.3L83.2 14.6Z', o: 0.2 },
|
||||
{
|
||||
p: 2,
|
||||
d: 'M40 14.6L40 11.4M44.5 14.6L44.5 10.4M49 14.6L49 9.4M53.5 14.6L53.5 8.6M58 14.6L58 8.8M62.5 14.6L62.5 9.8M67 14.6L67 10.9M36 14.6C37 13.4 37.87 13.07 38.6 13.6M71 14.6C71.8 13.27 72.73 12.87 73.8 13.4',
|
||||
o: 0.45,
|
||||
w: 0.9,
|
||||
},
|
||||
{ p: 'light', d: 'M16 14.7L55 5.3L81.5 11.6', o: 0.6, w: 0.6 },
|
||||
{ p: 'light', d: 'M13 14.4L97 14.4L97 15L13 15Z', o: 0.5 },
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
{
|
||||
id: 'milanDuomo',
|
||||
theme: 'landmarks',
|
||||
tags: ['milan', 'duomo', 'cathedral', 'italy', 'gothic', 'lombardy', 'church', 'landmark'],
|
||||
descr:
|
||||
'The pale pink marble Gothic facade of Milan Cathedral, bristling with slender spires and statues, five portals, and the golden Madonnina atop the central spire.',
|
||||
box: [100, 104],
|
||||
slots: [
|
||||
{ name: 'marble', color: '#f6ebe4' },
|
||||
{ name: 'trim', color: '#e3c9bd' },
|
||||
{ name: 'openings', color: '#5c4a4a' },
|
||||
{ name: 'gold', color: '#e4b23a' },
|
||||
],
|
||||
layers: [
|
||||
{ p: 0, d: 'M47 46L47.6 26L48.6 16L49.4 11L50.6 11L51.4 16L52.4 26L53 46Z' },
|
||||
{ p: 'shade', d: 'M50 11.5L50.6 11L51.4 16L52.4 26L53 46L50 46Z', o: 0.14 },
|
||||
{ p: 1, d: 'M48 34.5L52 34.5M48.4 26.5L51.6 26.5M48.9 19.5L51.1 19.5', w: 0.6, o: 0.9 },
|
||||
{
|
||||
p: 3,
|
||||
d: 'M48.8 4.6C48.8 3.94 49.34 3.4 50 3.4C50.66 3.4 51.2 3.94 51.2 4.6C51.2 5.26 50.66 5.8 50 5.8C49.34 5.8 48.8 5.26 48.8 4.6ZM48.6 6.2C49.53 5.53 50.47 5.53 51.4 6.2L51.9 11.6L48.1 11.6ZM48.7 6.6L46.8 5.4L46.6 5.9L48.6 7.6ZM51.3 6.6L53.2 5.4L53.4 5.9L51.4 7.6Z',
|
||||
},
|
||||
{ p: 0, d: 'M9 96L9 50L23 46L23 44L37 40L50 30.5L63 40L77 44L77 46L91 50L91 96Z' },
|
||||
{ p: 1, d: 'M9 50L23 46M23 44L37 40L50 30.5L63 40L77 44M77 46L91 50', w: 1.3 },
|
||||
{
|
||||
p: 0,
|
||||
d: 'M8.2 38L11.2 38L11.2 96L8.2 96ZM22.2 34L25.2 34L25.2 96L22.2 96ZM36.2 28L39.8 28L39.8 96L36.2 96ZM60.6 28L64.2 28L64.2 96L60.6 96ZM74.6 34L77.6 34L77.6 96L74.6 96ZM88.6 38L91.6 38L91.6 96L88.6 96ZM8.4 39L8.9 33.5L9.7 30L10.5 33.5L11 39ZM22.4 35L22.9 29.5L23.7 26L24.5 29.5L25 35ZM36.4 29L36.9 23.5L38 20L39.1 23.5L39.6 29ZM60.8 29L61.3 23.5L62.4 20L63.5 23.5L64 29ZM74.8 35L75.3 29.5L76.1 26L76.9 29.5L77.4 35ZM88.8 39L89.3 33.5L90.1 30L90.9 33.5L91.4 39Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M9 27.8C8.88 27.47 8.99 27.09 9.27 26.88C9.55 26.67 9.95 26.67 10.23 26.88C10.51 27.09 10.62 27.47 10.5 27.8C10.62 28.13 10.51 28.51 10.23 28.72C9.95 28.93 9.55 28.93 9.27 28.72C8.99 28.51 8.88 28.13 9 27.8ZM9.2 28.1L10.2 28.1L10.2 30.5L9.2 30.5ZM23 23.8C22.88 23.47 22.99 23.09 23.27 22.88C23.55 22.67 23.95 22.67 24.23 22.88C24.51 23.09 24.62 23.47 24.5 23.8C24.62 24.13 24.51 24.51 24.23 24.72C23.95 24.93 23.55 24.93 23.27 24.72C22.99 24.51 22.88 24.13 23 23.8ZM23.2 24.1L24.2 24.1L24.2 26.5L23.2 26.5ZM37.3 17.8C37.18 17.47 37.29 17.09 37.57 16.88C37.85 16.67 38.25 16.67 38.53 16.88C38.81 17.09 38.92 17.47 38.8 17.8C38.92 18.13 38.81 18.51 38.53 18.72C38.25 18.93 37.85 18.93 37.57 18.72C37.29 18.51 37.18 18.13 37.3 17.8ZM37.5 18.1L38.5 18.1L38.5 20.5L37.5 20.5ZM61.7 17.8C61.58 17.47 61.69 17.09 61.97 16.88C62.25 16.67 62.65 16.67 62.93 16.88C63.21 17.09 63.32 17.47 63.2 17.8C63.32 18.13 63.21 18.51 62.93 18.72C62.65 18.93 62.25 18.93 61.97 18.72C61.69 18.51 61.58 18.13 61.7 17.8ZM61.9 18.1L62.9 18.1L62.9 20.5L61.9 20.5ZM75.4 23.8C75.28 23.47 75.39 23.09 75.67 22.88C75.95 22.67 76.35 22.67 76.63 22.88C76.91 23.09 77.02 23.47 76.9 23.8C77.02 24.13 76.91 24.51 76.63 24.72C76.35 24.93 75.95 24.93 75.67 24.72C75.39 24.51 75.28 24.13 75.4 23.8ZM75.6 24.1L76.6 24.1L76.6 26.5L75.6 26.5ZM89.4 27.8C89.28 27.47 89.39 27.09 89.67 26.88C89.95 26.67 90.35 26.67 90.63 26.88C90.91 27.09 91.02 27.47 90.9 27.8C91.02 28.13 90.91 28.51 90.63 28.72C90.35 28.93 89.95 28.93 89.67 28.72C89.39 28.51 89.28 28.13 89.4 27.8ZM89.6 28.1L90.6 28.1L90.6 30.5L89.6 30.5Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M9.8 38L11.2 38L11.2 96L9.8 96ZM23.8 34L25.2 34L25.2 96L23.8 96ZM38.1 28L39.8 28L39.8 96L38.1 96ZM62.5 28L64.2 28L64.2 96L62.5 96ZM76.2 34L77.6 34L77.6 96L76.2 96ZM90.2 38L91.6 38L91.6 96L90.2 96Z',
|
||||
o: 0.1,
|
||||
},
|
||||
{ p: 1, d: 'M8.4 36L11 36M22.4 32L25 32M36.4 26L39.6 26M60.8 26L64 26M74.8 32L77.4 32M88.8 36L91.4 36', w: 0.6 },
|
||||
{
|
||||
p: 0,
|
||||
d: 'M13.2 49.6L13.3 45.8L14 42.3L14.7 45.8L14.8 49.6ZM17.7 48.5L17.8 44.7L18.5 41.2L19.2 44.7L19.3 48.5ZM27.2 44.3L27.3 40.5L28 37L28.7 40.5L28.8 44.3ZM31.7 43L31.8 39.2L32.5 35.7L33.2 39.2L33.3 43ZM41.2 38.3L41.3 34.5L42 31L42.7 34.5L42.8 38.3ZM44.7 35.8L44.8 32L45.5 28.5L46.2 32L46.3 35.8ZM53.7 35.8L53.8 32L54.5 28.5L55.2 32L55.3 35.8ZM57.2 38.3L57.3 34.5L58 31L58.7 34.5L58.8 38.3ZM66.7 43L66.8 39.2L67.5 35.7L68.2 39.2L68.3 43ZM71.2 44.3L71.3 40.5L72 37L72.7 40.5L72.8 44.3ZM80.7 48.5L80.8 44.7L81.5 41.2L82.2 44.7L82.3 48.5ZM85.2 49.6L85.3 45.8L86 42.3L86.7 45.8L86.8 49.6Z',
|
||||
},
|
||||
{ p: 1, d: 'M9 66L91 66L91 67.2L9 67.2ZM9 80L91 80L91 81.1L9 81.1Z', o: 0.9 },
|
||||
{ p: 1, d: 'M42.6 61L50 39.6L57.4 61ZM26.4 62.4L30.7 45L35 62.4ZM65 62.4L69.3 45L73.6 62.4Z', o: 0.75 },
|
||||
{ p: 0, d: 'M44.2 61L50 43.2L55.8 61ZM27.8 62.4L30.7 49L33.6 62.4ZM66.4 62.4L69.3 49L72.2 62.4Z' },
|
||||
{ p: 2, d: 'M45 59L45 50.5C45 46.5 46.67 44 50 43C53.33 44 55 46.5 55 50.5L55 59Z' },
|
||||
{ p: 'light', d: 'M50 44.5L50 58.5M46 51L54 51', o: 0.35, w: 0.5 },
|
||||
{ p: 2, d: 'M28.2 61L28.2 52.8C28.2 50.8 29.03 49.53 30.7 49C32.37 49.53 33.2 50.8 33.2 52.8L33.2 61Z' },
|
||||
{ p: 2, d: 'M66.8 61L66.8 52.8C66.8 50.8 67.63 49.53 69.3 49C70.97 49.53 71.8 50.8 71.8 52.8L71.8 61Z' },
|
||||
{ p: 2, d: 'M14 62.6L14 56.2C14 54.47 14.7 53.4 16.1 53C17.5 53.4 18.2 54.47 18.2 56.2L18.2 62.6Z' },
|
||||
{ p: 2, d: 'M81.8 62.6L81.8 56.2C81.8 54.47 82.5 53.4 83.9 53C85.3 53.4 86 54.47 86 56.2L86 62.6Z' },
|
||||
{ p: 2, d: 'M14.2 76.8L14.2 71.5C14.2 70.45 15.05 69.6 16.1 69.6C17.15 69.6 18 70.45 18 71.5L18 76.8Z', o: 0.85 },
|
||||
{
|
||||
p: 2,
|
||||
d: 'M28.6 76.8L28.6 71.7C28.6 70.54 29.54 69.6 30.7 69.6C31.86 69.6 32.8 70.54 32.8 71.7L32.8 76.8Z',
|
||||
o: 0.85,
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M67.2 76.8L67.2 71.7C67.2 70.54 68.14 69.6 69.3 69.6C70.46 69.6 71.4 70.54 71.4 71.7L71.4 76.8Z',
|
||||
o: 0.85,
|
||||
},
|
||||
{ p: 2, d: 'M82 76.8L82 71.5C82 70.45 82.85 69.6 83.9 69.6C84.95 69.6 85.8 70.45 85.8 71.5L85.8 76.8Z', o: 0.85 },
|
||||
{ p: 2, d: 'M41.5 76.8L41.5 70.6C41.5 69.72 42.22 69 43.1 69C43.98 69 44.7 69.72 44.7 70.6L44.7 76.8Z', o: 0.85 },
|
||||
{ p: 2, d: 'M55.3 76.8L55.3 70.6C55.3 69.72 56.02 69 56.9 69C57.78 69 58.5 69.72 58.5 70.6L58.5 76.8Z', o: 0.85 },
|
||||
{ p: 1, d: 'M11.8 82.5L20.2 82.5L20.2 96L11.8 96ZM11.6 82.6L16 79.5L20.4 82.6Z' },
|
||||
{ p: 2, d: 'M13.2 96L13.2 86.8C13.2 85.25 14.45 84 16 84C17.55 84 18.8 85.25 18.8 86.8L18.8 96Z' },
|
||||
{ p: 1, d: 'M26.2 82.5L35.2 82.5L35.2 96L26.2 96ZM26 82.6L30.7 79.5L35.4 82.6Z' },
|
||||
{ p: 2, d: 'M27.6 96L27.6 87.1C27.6 85.39 28.99 84 30.7 84C32.41 84 33.8 85.39 33.8 87.1L33.8 96Z' },
|
||||
{ p: 1, d: 'M43.2 80.5L56.8 80.5L56.8 96L43.2 96ZM43 80.6L50 77.5L57 80.6Z' },
|
||||
{ p: 2, d: 'M44.6 96L44.6 87.4C44.6 84.42 47.02 82 50 82C52.98 82 55.4 84.42 55.4 87.4L55.4 96Z' },
|
||||
{ p: 1, d: 'M64.8 82.5L73.8 82.5L73.8 96L64.8 96ZM64.6 82.6L69.3 79.5L74 82.6Z' },
|
||||
{ p: 2, d: 'M66.2 96L66.2 87.1C66.2 85.39 67.59 84 69.3 84C71.01 84 72.4 85.39 72.4 87.1L72.4 96Z' },
|
||||
{ p: 1, d: 'M79.8 82.5L88.2 82.5L88.2 96L79.8 96ZM79.6 82.6L84 79.5L88.4 82.6Z' },
|
||||
{ p: 2, d: 'M81.2 96L81.2 86.8C81.2 85.25 82.45 84 84 84C85.55 84 86.8 85.25 86.8 86.8L86.8 96Z' },
|
||||
{ p: 'light', d: 'M50 85.4L50 96', o: 0.3, w: 0.5 },
|
||||
{ p: 1, d: 'M4 95.4L96 95.4L96 98.6L4 98.6Z' },
|
||||
{ p: 1, d: 'M1.5 98L98.5 98L98.5 101.4L1.5 101.4Z' },
|
||||
{ p: 'shade', d: 'M4 97.6L96 97.6L96 98.6L4 98.6ZM1.5 100.4L98.5 100.4L98.5 101.4L1.5 101.4Z', o: 0.15 },
|
||||
{ p: 'light', d: 'M4 95.5L96 95.5L96 96.1L4 96.1ZM1.5 98.1L98.5 98.1L98.5 98.7L1.5 98.7Z', o: 0.5 },
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
{
|
||||
id: 'canalHouses',
|
||||
theme: 'landmarks',
|
||||
tags: ['amsterdam', 'canal houses', 'netherlands', 'dutch', 'gables', 'canal', 'grachten', 'city'],
|
||||
descr:
|
||||
'A row of five narrow Amsterdam canal houses with bell, neck and step gables, tall white framed windows and hoisting beams above a strip of canal.',
|
||||
box: [110, 100],
|
||||
slots: [
|
||||
{ name: 'brick', color: '#a3543a' },
|
||||
{ name: 'slate', color: '#3f5560' },
|
||||
{ name: 'ochre', color: '#c99555' },
|
||||
{ name: 'canal', color: '#4d8392' },
|
||||
],
|
||||
layers: [
|
||||
{ p: 3, d: 'M0 87L110 87L110 100L0 100Z' },
|
||||
{ p: '#8f8a84', d: 'M0 84.5L110 84.5L110 88.5L0 88.5Z' },
|
||||
{ p: 'shade', d: 'M0 87.3L110 87.3L110 88.5L0 88.5Z', o: 0.25 },
|
||||
{ p: 'light', d: 'M0 84.6L110 84.6L110 85.2L0 85.2Z', o: 0.5 },
|
||||
{
|
||||
p: 'light',
|
||||
d: 'M6 92.5L20 92.5M30 95.5L40 95.5M52 92.5L70 92.5M80 96L96 96M14 98L26 98M88 91.5L104 91.5',
|
||||
o: 0.45,
|
||||
w: 0.9,
|
||||
},
|
||||
{ p: 0, d: 'M4 86.5L4 32C8.8 32 11 29 11 22C13.67 18.33 16.33 18.33 19 22C19 29 21.2 32 26 32L26 86.5Z' },
|
||||
{ p: 'shade', d: 'M24.4 32L26 32L26 86.5L24.4 86.5Z', o: 0.14 },
|
||||
{ p: 'paper', d: 'M4.8 32.6C8.8 32 11 29 11 22C13.67 18.33 16.33 18.33 19 22C19 29 21.2 32 26 32', w: 0.9 },
|
||||
{ p: 'paper', d: 'M4.3 32.6L25.7 32.6L25.7 33.9L4.3 33.9Z' },
|
||||
{ p: '#3a2f2a', d: 'M14.4 22.6L15.6 22.6L15.6 24.2L14.4 24.2Z' },
|
||||
{ p: '#3a2f2a', d: 'M15 24L15 26.2', w: 0.35 },
|
||||
{ p: 'paper', d: 'M13.2 26.2L16.8 26.2L16.8 30.6L13.2 30.6Z' },
|
||||
{ p: '#2c3338', d: 'M13.9 26.9L16.1 26.9L16.1 29.9L13.9 29.9Z' },
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M6.1 36L10.7 36L10.7 44.6L6.1 44.6ZM12.7 36L17.3 36L17.3 44.6L12.7 44.6ZM19.4 36L24 36L24 44.6L19.4 44.6ZM6.1 47.5L10.7 47.5L10.7 55.3L6.1 55.3ZM12.7 47.5L17.3 47.5L17.3 55.3L12.7 55.3ZM19.4 47.5L24 47.5L24 55.3L19.4 55.3ZM6.1 59L10.7 59L10.7 66.8L6.1 66.8ZM12.7 59L17.3 59L17.3 66.8L12.7 66.8ZM19.4 59L24 59L24 66.8L19.4 66.8ZM5.6 44.3L11.2 44.3L11.2 45.3L5.6 45.3ZM12.2 44.3L17.8 44.3L17.8 45.3L12.2 45.3ZM18.9 44.3L24.5 44.3L24.5 45.3L18.9 45.3ZM5.6 55L11.2 55L11.2 56L5.6 56ZM12.2 55L17.8 55L17.8 56L12.2 56ZM18.9 55L24.5 55L24.5 56L18.9 56ZM5.6 66.5L11.2 66.5L11.2 67.5L5.6 67.5ZM12.2 66.5L17.8 66.5L17.8 67.5L12.2 67.5ZM18.9 66.5L24.5 66.5L24.5 67.5L18.9 67.5Z',
|
||||
},
|
||||
{
|
||||
p: '#2c3338',
|
||||
d: 'M6.7 36.6L10.1 36.6L10.1 44L6.7 44ZM13.3 36.6L16.7 36.6L16.7 44L13.3 44ZM20 36.6L23.4 36.6L23.4 44L20 44ZM6.7 48.1L10.1 48.1L10.1 54.7L6.7 54.7ZM13.3 48.1L16.7 48.1L16.7 54.7L13.3 54.7ZM20 48.1L23.4 48.1L23.4 54.7L20 54.7ZM6.7 59.6L10.1 59.6L10.1 66.2L6.7 66.2ZM13.3 59.6L16.7 59.6L16.7 66.2L13.3 66.2ZM20 59.6L23.4 59.6L23.4 66.2L20 66.2Z',
|
||||
},
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M8.4 36.6L8.4 44M6.7 39.9L10.1 39.9M15 36.6L15 44M13.3 39.9L16.7 39.9M21.7 36.6L21.7 44M20 39.9L23.4 39.9M8.4 48.1L8.4 54.7M6.7 51L10.1 51M15 48.1L15 54.7M13.3 51L16.7 51M21.7 48.1L21.7 54.7M20 51L23.4 51M8.4 59.6L8.4 66.2M6.7 62.5L10.1 62.5M15 59.6L15 66.2M13.3 62.5L16.7 62.5M21.7 59.6L21.7 66.2M20 62.5L23.4 62.5',
|
||||
w: 0.45,
|
||||
},
|
||||
{
|
||||
p: 'light',
|
||||
d: 'M6.7 36.6L10.1 36.6L10.1 44L6.7 44ZM13.3 36.6L16.7 36.6L16.7 44L13.3 44ZM20 36.6L23.4 36.6L23.4 44L20 44ZM6.7 48.1L10.1 48.1L10.1 54.7L6.7 54.7ZM13.3 48.1L16.7 48.1L16.7 54.7L13.3 54.7ZM20 48.1L23.4 48.1L23.4 54.7L20 54.7ZM6.7 59.6L10.1 59.6L10.1 66.2L6.7 66.2ZM13.3 59.6L16.7 59.6L16.7 66.2L13.3 66.2ZM20 59.6L23.4 59.6L23.4 66.2L20 66.2Z',
|
||||
o: 0.12,
|
||||
},
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M5.5 85.6L5.5 75.6C5.5 73.83 6.93 72.4 8.7 72.4C10.47 72.4 11.9 73.83 11.9 75.6L11.9 85.6ZM14 73L24 73L24 82L14 82Z',
|
||||
},
|
||||
{ p: '#2f4a3f', d: 'M6.1 85.6L6.1 75.6C6.1 74.16 7.26 73 8.7 73C10.14 73 11.3 74.16 11.3 75.6L11.3 85.6Z' },
|
||||
{ p: '#2c3338', d: 'M14.6 73.6L23.4 73.6L23.4 81.4L14.6 81.4Z' },
|
||||
{ p: 'paper', d: 'M19 73.6L19 81.4M14.6 77L23.3 77', w: 0.45 },
|
||||
{ p: 'light', d: 'M8.7 76.5L8.7 85.4', o: 0.25, w: 0.4 },
|
||||
{ p: '#8f8a84', d: 'M4.7 85L12.7 85L12.7 87.2L4.7 87.2Z' },
|
||||
{
|
||||
p: '#e7dac0',
|
||||
d: 'M24 86.5L24 26L26.9 26L26.9 22.5L29.7 22.5L29.7 19L32.1 19L32.1 12L35.9 12L35.9 19L38.3 19L38.3 22.5L41.1 22.5L41.1 26L44 26L44 86.5Z',
|
||||
},
|
||||
{ p: 'shade', d: 'M42.4 26L44 26L44 86.5L42.4 86.5Z', o: 0.14 },
|
||||
{ p: 'paper', d: 'M24.3 26.6L43.7 26.6L43.7 27.9L24.3 27.9Z' },
|
||||
{ p: '#3a2f2a', d: 'M33.4 16.6L34.6 16.6L34.6 18.2L33.4 18.2Z' },
|
||||
{ p: '#3a2f2a', d: 'M34 18L34 20.2', w: 0.35 },
|
||||
{ p: 'paper', d: 'M32.2 20.2L35.8 20.2L35.8 24.6L32.2 24.6Z' },
|
||||
{ p: '#2c3338', d: 'M32.9 20.9L35.1 20.9L35.1 23.9L32.9 23.9Z' },
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M27.1 30L32.5 30L32.5 38.6L27.1 38.6ZM35.5 30L40.9 30L40.9 38.6L35.5 38.6ZM27.1 41.5L32.5 41.5L32.5 49.3L27.1 49.3ZM35.5 41.5L40.9 41.5L40.9 49.3L35.5 49.3ZM27.1 53L32.5 53L32.5 60.8L27.1 60.8ZM35.5 53L40.9 53L40.9 60.8L35.5 60.8ZM26.6 38.3L33 38.3L33 39.3L26.6 39.3ZM35 38.3L41.4 38.3L41.4 39.3L35 39.3ZM26.6 49L33 49L33 50L26.6 50ZM35 49L41.4 49L41.4 50L35 50ZM26.6 60.5L33 60.5L33 61.5L26.6 61.5ZM35 60.5L41.4 60.5L41.4 61.5L35 61.5Z',
|
||||
},
|
||||
{
|
||||
p: '#2c3338',
|
||||
d: 'M27.7 30.6L31.9 30.6L31.9 38L27.7 38ZM36.1 30.6L40.3 30.6L40.3 38L36.1 38ZM27.7 42.1L31.9 42.1L31.9 48.7L27.7 48.7ZM36.1 42.1L40.3 42.1L40.3 48.7L36.1 48.7ZM27.7 53.6L31.9 53.6L31.9 60.2L27.7 60.2ZM36.1 53.6L40.3 53.6L40.3 60.2L36.1 60.2Z',
|
||||
},
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M29.8 30.6L29.8 38M27.7 33.9L31.9 33.9M38.2 30.6L38.2 38M36.1 33.9L40.3 33.9M29.8 42.1L29.8 48.7M27.7 45L31.9 45M38.2 42.1L38.2 48.7M36.1 45L40.3 45M29.8 53.6L29.8 60.2M27.7 56.5L31.9 56.5M38.2 53.6L38.2 60.2M36.1 56.5L40.3 56.5',
|
||||
w: 0.45,
|
||||
},
|
||||
{
|
||||
p: 'light',
|
||||
d: 'M27.7 30.6L31.9 30.6L31.9 38L27.7 38ZM36.1 30.6L40.3 30.6L40.3 38L36.1 38ZM27.7 42.1L31.9 42.1L31.9 48.7L27.7 48.7ZM36.1 42.1L40.3 42.1L40.3 48.7L36.1 48.7ZM27.7 53.6L31.9 53.6L31.9 60.2L27.7 60.2ZM36.1 53.6L40.3 53.6L40.3 60.2L36.1 60.2Z',
|
||||
o: 0.12,
|
||||
},
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M35.1 85.6L35.1 75.6C35.1 73.83 36.53 72.4 38.3 72.4C40.07 72.4 41.5 73.83 41.5 75.6L41.5 85.6ZM27.1 73L33.1 73L33.1 82L27.1 82Z',
|
||||
},
|
||||
{ p: '#2f4a3f', d: 'M35.7 85.6L35.7 75.6C35.7 74.16 36.86 73 38.3 73C39.74 73 40.9 74.16 40.9 75.6L40.9 85.6Z' },
|
||||
{ p: '#2c3338', d: 'M27.7 73.6L32.5 73.6L32.5 81.4L27.7 81.4Z' },
|
||||
{ p: 'paper', d: 'M30.1 73.6L30.1 81.4M27.7 77L32.5 77', w: 0.45 },
|
||||
{ p: 'light', d: 'M38.3 76.5L38.3 85.4', o: 0.25, w: 0.4 },
|
||||
{ p: '#8f8a84', d: 'M34.3 85L42.3 85L42.3 87.2L34.3 87.2Z' },
|
||||
{
|
||||
p: 1,
|
||||
d: 'M42 86.5L42 34C46.2 34 48.3 32 48.3 28L48.3 23L52.5 19L56.7 23L56.7 28C56.7 32 58.8 34 63 34L63 86.5Z',
|
||||
},
|
||||
{ p: 'shade', d: 'M61.4 34L63 34L63 86.5L61.4 86.5Z', o: 0.14 },
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M42.8 34.6C46.47 34.2 48.3 32 48.3 28L48.3 23L52.5 19L56.7 23L56.7 28C56.7 32 58.8 34 63 34',
|
||||
w: 0.9,
|
||||
},
|
||||
{ p: 'paper', d: 'M42.3 34.6L62.7 34.6L62.7 35.9L42.3 35.9Z' },
|
||||
{ p: '#3a2f2a', d: 'M51.9 24.6L53.1 24.6L53.1 26.2L51.9 26.2Z' },
|
||||
{ p: '#3a2f2a', d: 'M52.5 26L52.5 28.2', w: 0.35 },
|
||||
{ p: 'paper', d: 'M50.7 28.2L54.3 28.2L54.3 32.6L50.7 32.6Z' },
|
||||
{ p: '#2c3338', d: 'M51.4 28.9L53.6 28.9L53.6 31.9L51.4 31.9Z' },
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M45.4 38L50.8 38L50.8 46.6L45.4 46.6ZM54.2 38L59.6 38L59.6 46.6L54.2 46.6ZM45.4 49.5L50.8 49.5L50.8 57.3L45.4 57.3ZM54.2 49.5L59.6 49.5L59.6 57.3L54.2 57.3ZM45.4 61L50.8 61L50.8 68.8L45.4 68.8ZM54.2 61L59.6 61L59.6 68.8L54.2 68.8ZM44.9 46.3L51.3 46.3L51.3 47.3L44.9 47.3ZM53.7 46.3L60.1 46.3L60.1 47.3L53.7 47.3ZM44.9 57L51.3 57L51.3 58L44.9 58ZM53.7 57L60.1 57L60.1 58L53.7 58ZM44.9 68.5L51.3 68.5L51.3 69.5L44.9 69.5ZM53.7 68.5L60.1 68.5L60.1 69.5L53.7 69.5Z',
|
||||
},
|
||||
{
|
||||
p: '#2c3338',
|
||||
d: 'M46 38.6L50.2 38.6L50.2 46L46 46ZM54.8 38.6L59 38.6L59 46L54.8 46ZM46 50.1L50.2 50.1L50.2 56.7L46 56.7ZM54.8 50.1L59 50.1L59 56.7L54.8 56.7ZM46 61.6L50.2 61.6L50.2 68.2L46 68.2ZM54.8 61.6L59 61.6L59 68.2L54.8 68.2Z',
|
||||
},
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M48.1 38.6L48.1 46M46 41.9L50.2 41.9M56.9 38.6L56.9 46M54.8 41.9L59 41.9M48.1 50.1L48.1 56.7M46 53L50.2 53M56.9 50.1L56.9 56.7M54.8 53L59 53M48.1 61.6L48.1 68.2M46 64.5L50.2 64.5M56.9 61.6L56.9 68.2M54.8 64.5L59 64.5',
|
||||
w: 0.45,
|
||||
},
|
||||
{
|
||||
p: 'light',
|
||||
d: 'M46 38.6L50.2 38.6L50.2 46L46 46ZM54.8 38.6L59 38.6L59 46L54.8 46ZM46 50.1L50.2 50.1L50.2 56.7L46 56.7ZM54.8 50.1L59 50.1L59 56.7L54.8 56.7ZM46 61.6L50.2 61.6L50.2 68.2L46 68.2ZM54.8 61.6L59 61.6L59 68.2L54.8 68.2Z',
|
||||
o: 0.12,
|
||||
},
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M44.8 85.6L44.8 75.6C44.8 73.83 46.23 72.4 48 72.4C49.77 72.4 51.2 73.83 51.2 75.6L51.2 85.6ZM53.6 73L59.6 73L59.6 82L53.6 82Z',
|
||||
},
|
||||
{ p: '#7a2e2a', d: 'M45.4 85.6L45.4 75.6C45.4 74.16 46.56 73 48 73C49.44 73 50.6 74.16 50.6 75.6L50.6 85.6Z' },
|
||||
{ p: '#2c3338', d: 'M54.2 73.6L59 73.6L59 81.4L54.2 81.4Z' },
|
||||
{ p: 'paper', d: 'M56.6 73.6L56.6 81.4M54.2 77L59 77', w: 0.45 },
|
||||
{ p: 'light', d: 'M48 76.5L48 85.4', o: 0.25, w: 0.4 },
|
||||
{ p: '#8f8a84', d: 'M44 85L52 85L52 87.2L44 87.2Z' },
|
||||
{
|
||||
p: 2,
|
||||
d: 'M61 86.5L61 28L64.3 28L64.3 24.5L67.6 24.5L67.6 21L70.4 21L70.4 14L74.6 14L74.6 21L77.4 21L77.4 24.5L80.7 24.5L80.7 28L84 28L84 86.5Z',
|
||||
},
|
||||
{ p: 'shade', d: 'M82.4 28L84 28L84 86.5L82.4 86.5Z', o: 0.14 },
|
||||
{ p: 'paper', d: 'M61.3 28.6L83.7 28.6L83.7 29.9L61.3 29.9Z' },
|
||||
{ p: '#3a2f2a', d: 'M71.9 18.6L73.1 18.6L73.1 20.2L71.9 20.2Z' },
|
||||
{ p: '#3a2f2a', d: 'M72.5 20L72.5 22.2', w: 0.35 },
|
||||
{ p: 'paper', d: 'M70.7 22.2L74.3 22.2L74.3 26.6L70.7 26.6Z' },
|
||||
{ p: '#2c3338', d: 'M71.4 22.9L73.6 22.9L73.6 25.9L71.4 25.9Z' },
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M63.3 32L67.9 32L67.9 40.6L63.3 40.6ZM70.2 32L74.8 32L74.8 40.6L70.2 40.6ZM77.1 32L81.7 32L81.7 40.6L77.1 40.6ZM63.3 43.5L67.9 43.5L67.9 51.3L63.3 51.3ZM70.2 43.5L74.8 43.5L74.8 51.3L70.2 51.3ZM77.1 43.5L81.7 43.5L81.7 51.3L77.1 51.3ZM63.3 55L67.9 55L67.9 62.8L63.3 62.8ZM70.2 55L74.8 55L74.8 62.8L70.2 62.8ZM77.1 55L81.7 55L81.7 62.8L77.1 62.8ZM62.8 40.3L68.4 40.3L68.4 41.3L62.8 41.3ZM69.7 40.3L75.3 40.3L75.3 41.3L69.7 41.3ZM76.6 40.3L82.2 40.3L82.2 41.3L76.6 41.3ZM62.8 51L68.4 51L68.4 52L62.8 52ZM69.7 51L75.3 51L75.3 52L69.7 52ZM76.6 51L82.2 51L82.2 52L76.6 52ZM62.8 62.5L68.4 62.5L68.4 63.5L62.8 63.5ZM69.7 62.5L75.3 62.5L75.3 63.5L69.7 63.5ZM76.6 62.5L82.2 62.5L82.2 63.5L76.6 63.5Z',
|
||||
},
|
||||
{
|
||||
p: '#2c3338',
|
||||
d: 'M63.9 32.6L67.3 32.6L67.3 40L63.9 40ZM70.8 32.6L74.2 32.6L74.2 40L70.8 40ZM77.7 32.6L81.1 32.6L81.1 40L77.7 40ZM63.9 44.1L67.3 44.1L67.3 50.7L63.9 50.7ZM70.8 44.1L74.2 44.1L74.2 50.7L70.8 50.7ZM77.7 44.1L81.1 44.1L81.1 50.7L77.7 50.7ZM63.9 55.6L67.3 55.6L67.3 62.2L63.9 62.2ZM70.8 55.6L74.2 55.6L74.2 62.2L70.8 62.2ZM77.7 55.6L81.1 55.6L81.1 62.2L77.7 62.2Z',
|
||||
},
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M65.6 32.6L65.6 40M63.9 35.9L67.3 35.9M72.5 32.6L72.5 40M70.8 35.9L74.2 35.9M79.4 32.6L79.4 40M77.7 35.9L81.1 35.9M65.6 44.1L65.6 50.7M63.9 47L67.3 47M72.5 44.1L72.5 50.7M70.8 47L74.2 47M79.4 44.1L79.4 50.7M77.7 47L81.1 47M65.6 55.6L65.6 62.2M63.9 58.5L67.3 58.5M72.5 55.6L72.5 62.2M70.8 58.5L74.2 58.5M79.4 55.6L79.4 62.2M77.7 58.5L81.1 58.5',
|
||||
w: 0.45,
|
||||
},
|
||||
{
|
||||
p: 'light',
|
||||
d: 'M63.9 32.6L67.3 32.6L67.3 40L63.9 40ZM70.8 32.6L74.2 32.6L74.2 40L70.8 40ZM77.7 32.6L81.1 32.6L81.1 40L77.7 40ZM63.9 44.1L67.3 44.1L67.3 50.7L63.9 50.7ZM70.8 44.1L74.2 44.1L74.2 50.7L70.8 50.7ZM77.7 44.1L81.1 44.1L81.1 50.7L77.7 50.7ZM63.9 55.6L67.3 55.6L67.3 62.2L63.9 62.2ZM70.8 55.6L74.2 55.6L74.2 62.2L70.8 62.2ZM77.7 55.6L81.1 55.6L81.1 62.2L77.7 62.2Z',
|
||||
o: 0.12,
|
||||
},
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M75.9 85.6L75.9 75.6C75.9 73.83 77.33 72.4 79.1 72.4C80.87 72.4 82.3 73.83 82.3 75.6L82.3 85.6ZM63.3 73L73.3 73L73.3 82L63.3 82Z',
|
||||
},
|
||||
{ p: '#2f4a3f', d: 'M76.5 85.6L76.5 75.6C76.5 74.16 77.66 73 79.1 73C80.54 73 81.7 74.16 81.7 75.6L81.7 85.6Z' },
|
||||
{ p: '#2c3338', d: 'M63.9 73.6L72.7 73.6L72.7 81.4L63.9 81.4Z' },
|
||||
{ p: 'paper', d: 'M68.3 73.6L68.3 81.4M63.9 77L72.7 77', w: 0.45 },
|
||||
{ p: 'light', d: 'M79.1 76.5L79.1 85.4', o: 0.25, w: 0.4 },
|
||||
{ p: '#8f8a84', d: 'M75.1 85L83.1 85L83.1 87.2L75.1 87.2Z' },
|
||||
{
|
||||
p: '#7f3f2c',
|
||||
d: 'M82 86.5L82 38C87.3 38 89.7 35 89.7 28C92.57 24.33 95.43 24.33 98.3 28C98.3 35 100.7 38 106 38L106 86.5Z',
|
||||
},
|
||||
{ p: 'shade', d: 'M104.4 38L106 38L106 86.5L104.4 86.5Z', o: 0.14 },
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M82.8 38.6C87.3 38 89.7 35 89.7 28C92.57 24.33 95.43 24.33 98.3 28C98.3 35 100.7 38 106 38',
|
||||
w: 0.9,
|
||||
},
|
||||
{ p: 'paper', d: 'M82.3 38.6L105.7 38.6L105.7 39.9L82.3 39.9Z' },
|
||||
{ p: '#3a2f2a', d: 'M93.4 28.6L94.6 28.6L94.6 30.2L93.4 30.2Z' },
|
||||
{ p: '#3a2f2a', d: 'M94 30L94 32.2', w: 0.35 },
|
||||
{ p: 'paper', d: 'M92.2 32.2L95.8 32.2L95.8 36.6L92.2 36.6Z' },
|
||||
{ p: '#2c3338', d: 'M92.9 32.9L95.1 32.9L95.1 35.9L92.9 35.9Z' },
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M84.6 42L89.1 42L89.1 50.6L84.6 50.6ZM91.7 42L96.3 42L96.3 50.6L91.7 50.6ZM98.9 42L103.5 42L103.5 50.6L98.9 50.6ZM84.6 53.5L89.1 53.5L89.1 61.3L84.6 61.3ZM91.7 53.5L96.3 53.5L96.3 61.3L91.7 61.3ZM98.9 53.5L103.5 53.5L103.5 61.3L98.9 61.3ZM84.1 50.3L89.6 50.3L89.6 51.3L84.1 51.3ZM91.2 50.3L96.8 50.3L96.8 51.3L91.2 51.3ZM98.4 50.3L104 50.3L104 51.3L98.4 51.3ZM84.1 61L89.6 61L89.6 62L84.1 62ZM91.2 61L96.8 61L96.8 62L91.2 62ZM98.4 61L104 61L104 62L98.4 62Z',
|
||||
},
|
||||
{
|
||||
p: '#2c3338',
|
||||
d: 'M85.1 42.6L88.6 42.6L88.6 50L85.1 50ZM92.3 42.6L95.7 42.6L95.7 50L92.3 50ZM99.4 42.6L102.9 42.6L102.9 50L99.4 50ZM85.1 54.1L88.6 54.1L88.6 60.7L85.1 60.7ZM92.3 54.1L95.7 54.1L95.7 60.7L92.3 60.7ZM99.4 54.1L102.9 54.1L102.9 60.7L99.4 60.7Z',
|
||||
},
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M86.9 42.6L86.9 50M85.1 45.9L88.6 45.9M94 42.6L94 50M92.3 45.9L95.7 45.9M101.1 42.6L101.1 50M99.4 45.9L102.9 45.9M86.9 54.1L86.9 60.7M85.1 57L88.6 57M94 54.1L94 60.7M92.3 57L95.7 57M101.1 54.1L101.1 60.7M99.4 57L102.9 57',
|
||||
w: 0.45,
|
||||
},
|
||||
{
|
||||
p: 'light',
|
||||
d: 'M85.1 42.6L88.6 42.6L88.6 50L85.1 50ZM92.3 42.6L95.7 42.6L95.7 50L92.3 50ZM99.4 42.6L102.9 42.6L102.9 50L99.4 50ZM85.1 54.1L88.6 54.1L88.6 60.7L85.1 60.7ZM92.3 54.1L95.7 54.1L95.7 60.7L92.3 60.7ZM99.4 54.1L102.9 54.1L102.9 60.7L99.4 60.7Z',
|
||||
o: 0.12,
|
||||
},
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M84 85.6L84 75.6C84 73.83 85.43 72.4 87.2 72.4C88.97 72.4 90.4 73.83 90.4 75.6L90.4 85.6ZM93.5 73L103.5 73L103.5 82L93.5 82Z',
|
||||
},
|
||||
{ p: '#2f4a3f', d: 'M84.6 85.6L84.6 75.6C84.6 74.16 85.76 73 87.2 73C88.64 73 89.8 74.16 89.8 75.6L89.8 85.6Z' },
|
||||
{ p: '#2c3338', d: 'M94.1 73.6L102.9 73.6L102.9 81.4L94.1 81.4Z' },
|
||||
{ p: 'paper', d: 'M98.5 73.6L98.5 81.4M94.1 77L102.9 77', w: 0.45 },
|
||||
{ p: 'light', d: 'M87.1 76.5L87.1 85.4', o: 0.25, w: 0.4 },
|
||||
{ p: '#8f8a84', d: 'M83.1 85L91.1 85L91.1 87.2L83.1 87.2Z' },
|
||||
{ p: 'shade', d: 'M58 96.6C66.67 97.4 75.33 97.4 84 96.6', o: 0.18, w: 1.2 },
|
||||
{ p: '#2f3b3f', d: 'M56.5 91.6L86.5 91.6C86 94.6 83.6 96.4 80 96.4L62.4 96.4C59 96.4 57 94.6 56.5 91.6Z' },
|
||||
{ p: 2, d: 'M56.5 91.6L86.5 91.6L86.5 93L56.5 93Z' },
|
||||
{ p: 'paper', d: 'M66 88.6L78 88.6L78 92.2L66 92.2Z' },
|
||||
{
|
||||
p: '#2c3338',
|
||||
d: 'M67 89.4L69.8 89.4L69.8 91.2L67 91.2ZM70.6 89.4L73.4 89.4L73.4 91.2L70.6 91.2ZM74.2 89.4L77 89.4L77 91.2L74.2 91.2Z',
|
||||
},
|
||||
{ p: 'light', d: 'M60 94.4L83 94.4', o: 0.25, w: 0.5 },
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -900,4 +900,331 @@ export const MEDITERRANEAN_STICKERS: readonly BookStickerDef[] = [
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
{
|
||||
id: 'gondola',
|
||||
theme: 'mediterranean',
|
||||
tags: ['gondola', 'venice', 'italy', 'gondolier', 'canal', 'boat', 'lagoon', 'romantic'],
|
||||
descr:
|
||||
'A black Venetian gondola with a silver ferro on its bow and a red seat, rowed by a gondolier in a striped shirt and straw boater on green blue water.',
|
||||
box: [110, 90],
|
||||
slots: [
|
||||
{ name: 'hull', color: '#24242c' },
|
||||
{ name: 'water', color: '#2c9c94' },
|
||||
{ name: 'stripes', color: '#22407e' },
|
||||
{ name: 'straw', color: '#e6c271' },
|
||||
],
|
||||
layers: [
|
||||
{
|
||||
p: 1,
|
||||
d: 'M3 76C10 73.6 17 73.6 24 76C31 78.4 38 78.4 45 76C52 73.6 59 73.6 66 76C73 78.4 80 78.4 87 76C94 73.6 101 73.6 107 76L107 84C107 86.2 105.2 88 103 88L7 88C4.8 88 3 86.2 3 84Z',
|
||||
},
|
||||
{ p: 'shade', d: 'M16 79.5C30 81.5 80 81.5 94 79.5C90 83 22 83 16 79.5Z', o: 0.25 },
|
||||
{ p: 'light', d: 'M10 84L22 84M30 85.5L44 85.5M68 85L82 85M90 83.5L99 83.5', w: 1.1, o: 0.45 },
|
||||
{
|
||||
p: 0,
|
||||
d: 'M4.6 44.5C6 60 15 70 30 71.5L80 71.5C92 70.5 99 61 100.5 45L103.5 45.5C103.5 63 96 78.5 78 79.5L32 79.5C14 79 4 66 2 45C2 43.4 4.4 43 4.6 44.5Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M7 66C14 75 22 79 32 79.5L78 79.5C90 79 97 73 100.6 64C96 72 88 75.5 78 76L32 76C20 75.6 12 72 7 66Z',
|
||||
o: 0.3,
|
||||
},
|
||||
{ p: '#e2b04a', d: 'M8 59C14 66 21 70 31 71.5M80 71.5C90 70.6 96 64 99.3 55', w: 0.9 },
|
||||
{ p: 0, d: 'M40 72.5L40 66C40 64.5 41 63.5 42.5 63.5L44 63.5L44 72.5Z' },
|
||||
{
|
||||
p: '#b3283a',
|
||||
d: 'M44.7 64.6L56.3 64.6C56.88 64.6 57.44 64.83 57.86 65.24C58.27 65.66 58.5 66.22 58.5 66.8L58.5 70C58.5 70.58 58.27 71.14 57.86 71.56C57.44 71.97 56.88 72.2 56.3 72.2L44.7 72.2C44.12 72.2 43.56 71.97 43.14 71.56C42.73 71.14 42.5 70.58 42.5 70L42.5 66.8C42.5 66.22 42.73 65.66 43.14 65.24C43.56 64.83 44.12 64.6 44.7 64.6Z',
|
||||
},
|
||||
{ p: 'light', d: 'M44.8 66.6L56.2 66.6', w: 1, o: 0.4 },
|
||||
{ p: 0, d: 'M57 72.5L57 64.5C57 63.5 57.6 62.8 58.6 62.8C59.8 62.8 60.6 63.6 60.6 64.8L60.6 72.5Z' },
|
||||
{
|
||||
p: '#e2b04a',
|
||||
d: 'M57.5 62.6C57.5 63.32 58.08 63.9 58.8 63.9C59.52 63.9 60.1 63.32 60.1 62.6C60.1 61.88 59.52 61.3 58.8 61.3C58.08 61.3 57.5 61.88 57.5 62.6Z',
|
||||
},
|
||||
{ p: '#e2b04a', d: 'M68 74.4C70 72.6 72.6 72.6 74.6 74.4C72.6 76.2 70 76.2 68 74.4Z' },
|
||||
{ p: '#e2b04a', d: 'M3.4 47.6L5.2 47.4', w: 1.2 },
|
||||
{
|
||||
p: '#cfd5dc',
|
||||
d: 'M97 52.5L97.6 34C97.9 30 100.8 28 103.8 29.4L105.6 30.6L102.4 31.6L102.4 34.2L105.6 34.2L105.6 36.2L102.4 36.2L102.4 38.6L105.6 38.6L105.6 40.6L102.4 40.6L102.4 43L105.6 43L105.6 45L102.4 45L102.4 47.4L105.6 47.4L105.6 49.4L102.2 49.4L101.8 52.5Z',
|
||||
},
|
||||
{ p: 'shade', d: 'M100.2 31L102.4 31.6L102.4 49.4L102.2 49.4L101.8 52.5L100 52.5Z', o: 0.18 },
|
||||
{ p: 'light', d: 'M98.9 36L98.6 50', w: 0.8, o: 0.6 },
|
||||
{ p: '#b98048', d: 'M30.5 36L66 82', w: 2 },
|
||||
{
|
||||
p: '#b98048',
|
||||
d: 'M60.6 74.4C62.4 73.4 64.6 74 66.6 76.4L71 82.4C71.8 83.8 70.6 85.2 69 84.6C66.6 83.6 64.2 81.2 62.2 78.8Z',
|
||||
},
|
||||
{ p: 1, d: 'M58 80.6C62 79.6 66 79.6 70 80.6C74 81.6 78 81.6 81 80.6L81 86L58 86Z', o: 0.75 },
|
||||
{ p: 'light', d: 'M60 81.6C64 80.8 68 80.8 72 81.8', w: 0.9, o: 0.6 },
|
||||
{ p: 0, d: 'M19.5 49.5L29.2 49.5L29 60L28.4 70.5L25.4 70.5L24.6 57L23.6 70.5L20.6 70.5L19.6 59Z' },
|
||||
{ p: 'light', d: 'M24.4 51.5L24.5 56.5', w: 0.6, o: 0.25 },
|
||||
{ p: 0, d: 'M19.6 69.2L24.4 69.2C25 69.2 25.2 70 24.8 70.6L24.2 72.8L19.4 72.8Z' },
|
||||
{ p: 0, d: 'M25 69.2L30 69.2C30.8 69.2 31 70.2 30.4 70.8L29.8 72.8L25 72.8Z' },
|
||||
{ p: '#fbf8f0', d: 'M18.6 36.4C18.6 33.6 20.2 32 23 32L25.4 32C28 32 29.6 33.6 29.6 36.4L29.8 51L18.4 51Z' },
|
||||
{
|
||||
p: 2,
|
||||
d: 'M18.5 35.4L29.8 35.4L29.8 36.9L18.5 36.9ZM18.5 38.5L29.8 38.5L29.8 40L18.5 40ZM18.5 41.6L29.8 41.6L29.8 43.1L18.5 43.1ZM18.5 44.7L29.8 44.7L29.8 46.2L18.5 46.2ZM18.5 47.8L29.8 47.8L29.8 49.3L18.5 49.3Z',
|
||||
},
|
||||
{ p: 'shade', d: 'M18.6 36.4C18.6 33.6 20.2 32 23 32L22 32C21 34 21 42 21.6 51L18.4 51Z', o: 0.12 },
|
||||
{ p: 0, d: 'M18.3 49.6L29.9 49.6L29.9 51.4L18.3 51.4Z' },
|
||||
{ p: '#efbf98', d: 'M22.6 28.8L25.6 28.8L25.6 33L22.6 33Z' },
|
||||
{
|
||||
p: '#efbf98',
|
||||
d: 'M20.1 26.2C20.1 28.46 21.94 30.3 24.2 30.3C26.46 30.3 28.3 28.46 28.3 26.2C28.3 23.94 26.46 22.1 24.2 22.1C21.94 22.1 20.1 23.94 20.1 26.2Z',
|
||||
},
|
||||
{ p: '#efbf98', d: 'M28 25.6L29.3 27.3L28 27.9Z' },
|
||||
{ p: '#4a3022', d: 'M20.2 25.6C20 23.6 20.6 22.6 21.6 22.2L22 26.8C21.2 27.6 20.4 27 20.2 25.6Z' },
|
||||
{
|
||||
p: 'ink',
|
||||
d: 'M26 25.6C26 25.81 26.11 26.01 26.3 26.12C26.49 26.23 26.71 26.23 26.9 26.12C27.09 26.01 27.2 25.81 27.2 25.6C27.2 25.39 27.09 25.19 26.9 25.08C26.71 24.97 26.49 24.97 26.3 25.08C26.11 25.19 26 25.39 26 25.6Z',
|
||||
},
|
||||
{
|
||||
p: '#d9826b',
|
||||
d: 'M25.5 27.8C25.5 28.05 25.63 28.28 25.85 28.41C26.07 28.53 26.33 28.53 26.55 28.41C26.77 28.28 26.9 28.05 26.9 27.8C26.9 27.55 26.77 27.32 26.55 27.19C26.33 27.07 26.07 27.07 25.85 27.19C25.63 27.32 25.5 27.55 25.5 27.8Z',
|
||||
o: 0.5,
|
||||
},
|
||||
{ p: 3, d: 'M19.8 22.8L20.2 18.4C20.3 17.4 21 16.8 22 16.8L26.6 16.8C27.6 16.8 28.3 17.4 28.4 18.4L28.8 22.8Z' },
|
||||
{ p: 2, d: 'M20 20.2L28.6 20.2L28.8 22L19.8 22Z' },
|
||||
{
|
||||
p: 3,
|
||||
d: 'M15.9 22.9C15.9 23.78 19.66 24.5 24.3 24.5C28.94 24.5 32.7 23.78 32.7 22.9C32.7 22.02 28.94 21.3 24.3 21.3C19.66 21.3 15.9 22.02 15.9 22.9Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M16.4 23.2C18 24.2 30.6 24.2 32.2 23.2C31 24.3 27.5 24.5 24.3 24.5C21 24.5 17.6 24.3 16.4 23.2Z',
|
||||
o: 0.25,
|
||||
},
|
||||
{ p: 'light', d: 'M21.4 18.2L27.2 18.2', w: 0.7, o: 0.5 },
|
||||
{ p: '#e3ae86', d: 'M21 36.4C21.6 41 27 43.6 35 43', w: 2.4 },
|
||||
{ p: '#fbf8f0', d: 'M27.4 34.4L30 39.4', w: 3 },
|
||||
{ p: 2, d: 'M27.2 35.6L29.6 34.6M28.6 38.2L30.8 37.2', w: 1 },
|
||||
{ p: '#efbf98', d: 'M30.2 40.2L33.4 39.4', w: 2.4 },
|
||||
{
|
||||
p: '#efbf98',
|
||||
d: 'M31.8 39.6C31.8 40.17 32.1 40.7 32.6 40.99C33.1 41.27 33.7 41.27 34.2 40.99C34.7 40.7 35 40.17 35 39.6C35 39.03 34.7 38.5 34.2 38.21C33.7 37.93 33.1 37.93 32.6 38.21C32.1 38.5 31.8 39.03 31.8 39.6Z',
|
||||
},
|
||||
{
|
||||
p: '#e3ae86',
|
||||
d: 'M33.8 42.6C33.8 43.48 34.52 44.2 35.4 44.2C36.28 44.2 37 43.48 37 42.6C37 41.72 36.28 41 35.4 41C34.52 41 33.8 41.72 33.8 42.6Z',
|
||||
},
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
{
|
||||
id: 'lisbonTram',
|
||||
theme: 'mediterranean',
|
||||
tags: ['tram', 'lisbon', 'portugal', 'streetcar', 'yellow', 'alfama', 'transport', 'trolley'],
|
||||
descr:
|
||||
'The yellow Lisbon tram 28 in side view, a rounded body with a cream window band and framed windows, its trolley pole reaching the overhead cable.',
|
||||
box: [110, 90],
|
||||
slots: [
|
||||
{ name: 'body', color: '#f6c31c' },
|
||||
{ name: 'cream', color: '#f6efdd' },
|
||||
{ name: 'frame', color: '#3d4048' },
|
||||
{ name: 'glass', color: '#8fb7c8' },
|
||||
],
|
||||
layers: [
|
||||
{ p: 'ink', d: 'M2 6.5L108 6.5', w: 0.8 },
|
||||
{ p: 2, d: 'M38 22L72 7.2', w: 1.5 },
|
||||
{ p: 2, d: 'M70 6.2L74.6 5.6L75 8L71.6 8.8Z' },
|
||||
{ p: 2, d: 'M34 25L34 21.6C34 20.6 34.8 20 35.8 20L41 20C42 20 42.6 20.8 42.4 21.8L42 25Z' },
|
||||
{ p: 'ink', d: 'M4 87.2L106 87.2', w: 2 },
|
||||
{ p: 2, d: 'M20 70L90 70L90 79C90 80.2 89.2 81 88 81L22 81C20.8 81 20 80.2 20 79Z' },
|
||||
{
|
||||
p: 2,
|
||||
d: 'M23.6 81.4C23.6 83.69 24.82 85.8 26.8 86.94C28.78 88.09 31.22 88.09 33.2 86.94C35.18 85.8 36.4 83.69 36.4 81.4C36.4 79.11 35.18 77 33.2 75.86C31.22 74.71 28.78 74.71 26.8 75.86C24.82 77 23.6 79.11 23.6 81.4ZM73.6 81.4C73.6 84.93 76.47 87.8 80 87.8C83.53 87.8 86.4 84.93 86.4 81.4C86.4 77.87 83.53 75 80 75C76.47 75 73.6 77.87 73.6 81.4Z',
|
||||
},
|
||||
{
|
||||
p: 'light',
|
||||
d: 'M27.6 81.4C27.6 82.26 28.06 83.05 28.8 83.48C29.54 83.91 30.46 83.91 31.2 83.48C31.94 83.05 32.4 82.26 32.4 81.4C32.4 80.54 31.94 79.75 31.2 79.32C30.46 78.89 29.54 78.89 28.8 79.32C28.06 79.75 27.6 80.54 27.6 81.4ZM77.6 81.4C77.6 82.73 78.67 83.8 80 83.8C81.33 83.8 82.4 82.73 82.4 81.4C82.4 80.07 81.33 79 80 79C78.67 79 77.6 80.07 77.6 81.4Z',
|
||||
o: 0.4,
|
||||
},
|
||||
{ p: 'shade', d: 'M42 77L68 77L68 80.6L42 80.6Z', o: 0.3 },
|
||||
{ p: 1, d: 'M16 32L16 27.5C16 24.6 18 23 21 23L89 23C92 23 94 24.6 94 27.5L94 32Z' },
|
||||
{ p: 'shade', d: 'M16 29.5L94 29.5L94 32L16 32Z', o: 0.15 },
|
||||
{ p: 'shade', d: 'M28 23.2L72 23.2L72 25.4L28 25.4Z', o: 0.12 },
|
||||
{
|
||||
p: 0,
|
||||
d: 'M11 41C11 34 15 30 22 30L88 30C95 30 99 34 99 41L99 70.5C99 72.6 97.6 74 95.5 74L14.5 74C12.4 74 11 72.6 11 70.5Z',
|
||||
},
|
||||
{ p: 1, d: 'M11 41C11 34 15 30 22 30L88 30C95 30 99 34 99 41L99 54L11 54Z' },
|
||||
{
|
||||
p: 3,
|
||||
d: 'M21 50.5L21 38.6C21 37 22 36 23.4 36L27.8 36C29.2 36 30.2 37 30.2 38.6L30.2 50.5ZM32.6 50.5L32.6 38.6C32.6 37 33.6 36 35 36L39.4 36C40.8 36 41.8 37 41.8 38.6L41.8 50.5ZM44.2 50.5L44.2 38.6C44.2 37 45.2 36 46.6 36L51 36C52.4 36 53.4 37 53.4 38.6L53.4 50.5ZM55.8 50.5L55.8 38.6C55.8 37 56.8 36 58.2 36L62.6 36C64 36 65 37 65 38.6L65 50.5ZM67.4 50.5L67.4 38.6C67.4 37 68.4 36 69.8 36L74.2 36C75.6 36 76.6 37 76.6 38.6L76.6 50.5ZM79 50.5L79 38.6C79 37 80 36 81.4 36L85.8 36C87.2 36 88.2 37 88.2 38.6L88.2 50.5ZM91 50.5L91 36L93.5 36C95.8 36.2 97 38 97 40.6L97 50.5ZM19 50.5L19 36L16.5 36C14.2 36.2 13 38 13 40.6L13 50.5Z',
|
||||
},
|
||||
{
|
||||
p: 'light',
|
||||
d: 'M23 47L27.5 38.8M34.6 47L39.1 38.8M46.2 47L50.7 38.8M57.8 47L62.3 38.8M69.4 47L73.9 38.8M81 47L85.5 38.8',
|
||||
w: 1.2,
|
||||
o: 0.45,
|
||||
},
|
||||
{ p: 'shade', d: 'M13 49L97 49L97 50.5L13 50.5Z', o: 0.12 },
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M92 30.4C95.8 31.6 99 35 99 41L99 70.5C99 72.6 97.6 74 95.5 74L93 74C94.6 60 94.4 42 92 30.4Z',
|
||||
o: 0.1,
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
d: 'M21 43.4L30.2 43.4M32.6 43.4L41.8 43.4M44.2 43.4L53.4 43.4M55.8 43.4L65 43.4M67.4 43.4L76.6 43.4M79 43.4L88.2 43.4',
|
||||
w: 0.9,
|
||||
},
|
||||
{ p: 2, d: 'M11.6 54.2L98.4 54.2', w: 1.3 },
|
||||
{ p: 2, d: 'M11.6 67.2L98.4 67.2', w: 0.9 },
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M22.2 57.4L29 57.4C29.32 57.4 29.62 57.53 29.85 57.75C30.07 57.98 30.2 58.28 30.2 58.6L30.2 63.2C30.2 63.52 30.07 63.82 29.85 64.05C29.62 64.27 29.32 64.4 29 64.4L22.2 64.4C21.88 64.4 21.58 64.27 21.35 64.05C21.13 63.82 21 63.52 21 63.2L21 58.6C21 58.28 21.13 57.98 21.35 57.75C21.58 57.53 21.88 57.4 22.2 57.4ZM33.8 57.4L40.6 57.4C41.26 57.4 41.8 57.94 41.8 58.6L41.8 63.2C41.8 63.86 41.26 64.4 40.6 64.4L33.8 64.4C33.14 64.4 32.6 63.86 32.6 63.2L32.6 58.6C32.6 57.94 33.14 57.4 33.8 57.4ZM45.4 57.4L52.2 57.4C52.86 57.4 53.4 57.94 53.4 58.6L53.4 63.2C53.4 63.86 52.86 64.4 52.2 64.4L45.4 64.4C44.74 64.4 44.2 63.86 44.2 63.2L44.2 58.6C44.2 57.94 44.74 57.4 45.4 57.4ZM57 57.4L63.8 57.4C64.12 57.4 64.42 57.53 64.65 57.75C64.87 57.98 65 58.28 65 58.6L65 63.2C65 63.52 64.87 63.82 64.65 64.05C64.42 64.27 64.12 64.4 63.8 64.4L57 64.4C56.68 64.4 56.38 64.27 56.15 64.05C55.93 63.82 55.8 63.52 55.8 63.2L55.8 58.6C55.8 58.28 55.93 57.98 56.15 57.75C56.38 57.53 56.68 57.4 57 57.4ZM68.6 57.4L75.4 57.4C76.06 57.4 76.6 57.94 76.6 58.6L76.6 63.2C76.6 63.86 76.06 64.4 75.4 64.4L68.6 64.4C67.94 64.4 67.4 63.86 67.4 63.2L67.4 58.6C67.4 57.94 67.94 57.4 68.6 57.4ZM80.2 57.4L87 57.4C87.32 57.4 87.62 57.53 87.85 57.75C88.07 57.98 88.2 58.28 88.2 58.6L88.2 63.2C88.2 63.52 88.07 63.82 87.85 64.05C87.62 64.27 87.32 64.4 87 64.4L80.2 64.4C79.88 64.4 79.58 64.27 79.35 64.05C79.13 63.82 79 63.52 79 63.2L79 58.6C79 58.28 79.13 57.98 79.35 57.75C79.58 57.53 79.88 57.4 80.2 57.4Z',
|
||||
o: 0.1,
|
||||
},
|
||||
{ p: 2, d: 'M91.2 55.6L96.4 55.6L96.4 72.6L91.2 72.6Z', o: 0.55 },
|
||||
{ p: 2, d: 'M13.6 55.6L18.8 55.6L18.8 72.6L13.6 72.6Z', o: 0.55 },
|
||||
{ p: 'shade', d: 'M11 68L99 68L99 70.5C99 72.6 97.6 74 95.5 74L14.5 74C12.4 74 11 72.6 11 70.5Z', o: 0.18 },
|
||||
{ p: 'light', d: 'M20 32.4L90 32.4', w: 1, o: 0.6 },
|
||||
{
|
||||
p: '#fff4c2',
|
||||
d: 'M96 60.5C96 61.38 96.72 62.1 97.6 62.1C98.48 62.1 99.2 61.38 99.2 60.5C99.2 59.62 98.48 58.9 97.6 58.9C96.72 58.9 96 59.62 96 60.5Z',
|
||||
},
|
||||
{ p: 2, d: 'M89.6 72.6L98.2 72.6L98.2 76.4L89.6 76.4ZM11.8 72.6L20.4 72.6L20.4 76.4L11.8 76.4Z' },
|
||||
{ p: 1, d: 'M90.2 56.4L90.2 71M19.8 56.4L19.8 71', w: 0.8 },
|
||||
{
|
||||
p: 2,
|
||||
d: 'M53 20.4L57 20.4C57.55 20.4 58 20.85 58 21.4L58 22.8C58 23.35 57.55 23.8 57 23.8L53 23.8C52.45 23.8 52 23.35 52 22.8L52 21.4C52 20.85 52.45 20.4 53 20.4Z',
|
||||
},
|
||||
{
|
||||
p: '#fff4c2',
|
||||
d: 'M54.1 21.6C54.1 21.92 54.27 22.22 54.55 22.38C54.83 22.54 55.17 22.54 55.45 22.38C55.73 22.22 55.9 21.92 55.9 21.6C55.9 21.28 55.73 20.98 55.45 20.82C55.17 20.66 54.83 20.66 54.55 20.82C54.27 20.98 54.1 21.28 54.1 21.6Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M79.2 24.6L89.8 24.6C90.12 24.6 90.42 24.73 90.65 24.95C90.87 25.18 91 25.48 91 25.8L91 30C91 30.32 90.87 30.62 90.65 30.85C90.42 31.07 90.12 31.2 89.8 31.2L79.2 31.2C78.88 31.2 78.58 31.07 78.35 30.85C78.13 30.62 78 30.32 78 30L78 25.8C78 25.48 78.13 25.18 78.35 24.95C78.58 24.73 78.88 24.6 79.2 24.6Z',
|
||||
},
|
||||
{
|
||||
p: '#fbf8f0',
|
||||
d: 'M80.6 26.6C80.8 25.8 83.6 25.6 83.6 27C83.6 28.2 80.6 28.6 80.6 29.8L83.8 29.8M86.8 27.8C85.4 27.8 85.4 25.9 86.8 25.9C88.2 25.9 88.2 27.8 86.8 27.8C85.2 27.8 85.2 29.9 86.8 29.9C88.4 29.9 88.4 27.8 86.8 27.8',
|
||||
w: 0.75,
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M98.4 68.6L100.4 68.6C100.95 68.6 101.4 69.05 101.4 69.6L101.4 70.8C101.4 71.35 100.95 71.8 100.4 71.8L98.4 71.8C97.85 71.8 97.4 71.35 97.4 70.8L97.4 69.6C97.4 69.05 97.85 68.6 98.4 68.6ZM9.6 68.6L11.6 68.6C12.15 68.6 12.6 69.05 12.6 69.6L12.6 70.8C12.6 71.35 12.15 71.8 11.6 71.8L9.6 71.8C9.05 71.8 8.6 71.35 8.6 70.8L8.6 69.6C8.6 69.05 9.05 68.6 9.6 68.6Z',
|
||||
},
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
{
|
||||
id: 'camel',
|
||||
theme: 'mediterranean',
|
||||
tags: ['camel', 'dromedary', 'morocco', 'desert', 'sahara', 'road trip', 'animal', 'caravan'],
|
||||
descr:
|
||||
'A dromedary camel standing in profile facing left, its single hump draped in a red saddle blanket with gold trim, teal diamonds and tassels, wearing a red halter.',
|
||||
box: [106, 90],
|
||||
slots: [
|
||||
{ name: 'fur', color: '#d6a467' },
|
||||
{ name: 'blanket', color: '#c8233a' },
|
||||
{ name: 'trim', color: '#f0b740' },
|
||||
{ name: 'pattern', color: '#1f8a8a' },
|
||||
],
|
||||
layers: [
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M26 86.2C26 87.42 43.01 88.4 64 88.4C84.99 88.4 102 87.42 102 86.2C102 84.98 84.99 84 64 84C43.01 84 26 84.98 26 86.2Z',
|
||||
o: 0.12,
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M53.8 52L54.6 71C53.4 73.6 54 75.6 54.8 77L54.6 84L59.6 84L59.2 77C60 75.6 60.6 73.6 59.4 71L60.2 52ZM53.2 84.2C53.2 84.88 54.04 85.51 55.4 85.85C56.76 86.18 58.44 86.18 59.8 85.85C61.16 85.51 62 84.88 62 84.2C62 83.52 61.16 82.89 59.8 82.55C58.44 82.22 56.76 82.22 55.4 82.55C54.04 82.89 53.2 83.52 53.2 84.2ZM76.8 52L77.6 71C76.4 73.6 77 75.6 77.8 77L77.6 84L82.6 84L82.2 77C83 75.6 83.6 73.6 82.4 71L83.2 52ZM76.2 84.2C76.2 84.88 77.04 85.51 78.4 85.85C79.76 86.18 81.44 86.18 82.8 85.85C84.16 85.51 85 84.88 85 84.2C85 83.52 84.16 82.89 82.8 82.55C81.44 82.22 79.76 82.22 78.4 82.55C77.04 82.89 76.2 83.52 76.2 84.2Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M53.8 52L54.6 71C53.4 73.6 54 75.6 54.8 77L54.6 84L59.6 84L59.2 77C60 75.6 60.6 73.6 59.4 71L60.2 52ZM53.2 84.2C53.2 84.88 54.04 85.51 55.4 85.85C56.76 86.18 58.44 86.18 59.8 85.85C61.16 85.51 62 84.88 62 84.2C62 83.52 61.16 82.89 59.8 82.55C58.44 82.22 56.76 82.22 55.4 82.55C54.04 82.89 53.2 83.52 53.2 84.2ZM76.8 52L77.6 71C76.4 73.6 77 75.6 77.8 77L77.6 84L82.6 84L82.2 77C83 75.6 83.6 73.6 82.4 71L83.2 52ZM76.2 84.2C76.2 84.88 77.04 85.51 78.4 85.85C79.76 86.18 81.44 86.18 82.8 85.85C84.16 85.51 85 84.88 85 84.2C85 83.52 84.16 82.89 82.8 82.55C81.44 82.22 79.76 82.22 78.4 82.55C77.04 82.89 76.2 83.52 76.2 84.2Z',
|
||||
o: 0.3,
|
||||
},
|
||||
{ p: 0, d: 'M98 44C101.6 50 102 56 100 62', w: 1.8 },
|
||||
{ p: 0, d: 'M100 60.6C98.6 63.6 99.4 67 100.6 68C101.6 66.4 102.6 63.4 101.6 60.4Z' },
|
||||
{ p: 'shade', d: 'M100 60.6C98.6 63.6 99.4 67 100.6 68C101.6 66.4 102.6 63.4 101.6 60.4Z', o: 0.25 },
|
||||
{
|
||||
p: 0,
|
||||
d: 'M36 46C38 36 48 32 56 31C62 15 80 14 88 28C96 31 101 39 99 49C97 56 91 59 83 59L46 59C39 59 35 54 36 46Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M43.8 52L44.6 71C43.4 73.6 44 75.6 44.8 77L44.6 84L49.6 84L49.2 77C50 75.6 50.6 73.6 49.4 71L50.2 52ZM85.8 52L86.6 71C85.4 73.6 86 75.6 86.8 77L86.6 84L91.6 84L91.2 77C92 75.6 92.6 73.6 91.4 71L92.2 52Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M43.2 84.2C43.2 84.88 44.04 85.51 45.4 85.85C46.76 86.18 48.44 86.18 49.8 85.85C51.16 85.51 52 84.88 52 84.2C52 83.52 51.16 82.89 49.8 82.55C48.44 82.22 46.76 82.22 45.4 82.55C44.04 82.89 43.2 83.52 43.2 84.2ZM85.2 84.2C85.2 84.88 86.04 85.51 87.4 85.85C88.76 86.18 90.44 86.18 91.8 85.85C93.16 85.51 94 84.88 94 84.2C94 83.52 93.16 82.89 91.8 82.55C90.44 82.22 88.76 82.22 87.4 82.55C86.04 82.89 85.2 83.52 85.2 84.2Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M44.6 72C45.4 73.2 47.4 73.6 49.4 72.4M86.6 72C87.4 73.2 89.4 73.6 91.4 72.4',
|
||||
w: 0.8,
|
||||
o: 0.35,
|
||||
},
|
||||
{ p: 'shade', d: 'M43.6 84.6L50.6 84.6M85.6 84.6L92.6 84.6', w: 0.8, o: 0.3 },
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M37 50C40 56 44 58.4 50 58.4L84 58.4C90 58.4 95 57 97.6 53C95 57.4 90 59 83 59L46 59C41.4 59 38 55.4 37 50Z',
|
||||
o: 0.25,
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M48 53C38 57 27 54 23.4 44C21 37.6 20 31 15.4 24.6L26 19.4C27.6 27 29.6 35 33.6 39.4C37.6 43.4 42 42 45 37Z',
|
||||
},
|
||||
{ p: 'shade', d: 'M24 45C27.6 52.6 36 56 45.6 53.8C38 57.4 27.4 54.6 24 45Z', o: 0.2 },
|
||||
{
|
||||
p: 0,
|
||||
d: 'M22.7 12.6C22.7 14.15 23.55 15.4 24.6 15.4C25.65 15.4 26.5 14.15 26.5 12.6C26.5 11.05 25.65 9.8 24.6 9.8C23.55 9.8 22.7 11.05 22.7 12.6Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M4.6 20.6C4.6 15.6 9 12.6 15 12.4C20.4 12.2 25.6 14 27.6 18C29 21.4 27.4 25.4 23 26.4L13.4 27.6C8 28 4.6 25.4 4.6 20.6Z',
|
||||
},
|
||||
{ p: 'shade', d: 'M5.2 23C6 25.4 8.4 27 12 26.6L17 26C12.6 27.8 6.6 27.8 5.2 23Z', o: 0.25 },
|
||||
{ p: 'shade', d: 'M6.4 20.4C7.8 21.2 9.6 21.2 11 20.4', w: 0.8, o: 0.4 },
|
||||
{ p: 'ink', d: 'M7.2 17.2C7.8 16.6 8.6 16.6 9 17.2', w: 0.8 },
|
||||
{ p: 1, d: 'M11.8 13.4C11 17.6 11 22.6 12.4 27.4M11.6 21.4L27.2 20.6', w: 1.4 },
|
||||
{
|
||||
p: 2,
|
||||
d: 'M10.4 21.4C10.4 22.06 10.94 22.6 11.6 22.6C12.26 22.6 12.8 22.06 12.8 21.4C12.8 20.74 12.26 20.2 11.6 20.2C10.94 20.2 10.4 20.74 10.4 21.4Z',
|
||||
},
|
||||
{ p: 2, d: 'M12.4 27.4L12.6 31', w: 0.9 },
|
||||
{
|
||||
p: 1,
|
||||
d: 'M11.5 32C11.5 32.57 11.71 33.1 12.05 33.39C12.39 33.67 12.81 33.67 13.15 33.39C13.49 33.1 13.7 32.57 13.7 32C13.7 31.43 13.49 30.9 13.15 30.61C12.81 30.33 12.39 30.33 12.05 30.61C11.71 30.9 11.5 31.43 11.5 32Z',
|
||||
},
|
||||
{
|
||||
p: 'ink',
|
||||
d: 'M18.2 17.2C18.2 18.08 18.83 18.8 19.6 18.8C20.37 18.8 21 18.08 21 17.2C21 16.32 20.37 15.6 19.6 15.6C18.83 15.6 18.2 16.32 18.2 17.2Z',
|
||||
},
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M19.6 16.6C19.6 16.88 19.82 17.1 20.1 17.1C20.38 17.1 20.6 16.88 20.6 16.6C20.6 16.32 20.38 16.1 20.1 16.1C19.82 16.1 19.6 16.32 19.6 16.6Z',
|
||||
},
|
||||
{ p: 0, d: 'M17.4 15.6C18.6 14.6 20.8 14.6 22 15.8', w: 1 },
|
||||
{ p: 'ink', d: 'M17.6 15.4L17 14.4M19.2 14.8L19 13.8M20.8 14.9L21.2 13.9', w: 0.5 },
|
||||
{
|
||||
p: 1,
|
||||
d: 'M55.4 30.6C61.6 14.2 80.4 13.2 88.6 27.6C90.2 34.6 91.4 42 92.2 50.4L89.4 53.8L86.4 50.6L83.4 53.8L80.4 50.6L77.4 53.8L74.4 50.6L71.4 53.8L68.4 50.6L65.4 53.8L62.4 50.6L59.4 53.8L56.4 50.6L53.4 53.4C53.4 45 54 37.4 55.4 30.6Z',
|
||||
},
|
||||
{ p: 'light', d: 'M60.6 28.4C63.4 23.6 67.6 21.2 72.6 20.8', w: 1.4, o: 0.3 },
|
||||
{ p: 2, d: 'M55.2 46.6L91.2 46.6M56.4 33.6C64 30.4 80 29.8 89.4 32.4', w: 1.5 },
|
||||
{
|
||||
p: 3,
|
||||
d: 'M59 37L61.2 39.8L59 42.6L56.8 39.8ZM64 37L66.2 39.8L64 42.6L61.8 39.8ZM69 37L71.2 39.8L69 42.6L66.8 39.8ZM74 37L76.2 39.8L74 42.6L71.8 39.8ZM79 37L81.2 39.8L79 42.6L76.8 39.8ZM84 37L86.2 39.8L84 42.6L81.8 39.8Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M60.8 39.8C60.8 40.19 61.11 40.5 61.5 40.5C61.89 40.5 62.2 40.19 62.2 39.8C62.2 39.41 61.89 39.1 61.5 39.1C61.11 39.1 60.8 39.41 60.8 39.8ZM65.8 39.8C65.8 40.19 66.11 40.5 66.5 40.5C66.89 40.5 67.2 40.19 67.2 39.8C67.2 39.41 66.89 39.1 66.5 39.1C66.11 39.1 65.8 39.41 65.8 39.8ZM70.8 39.8C70.8 40.19 71.11 40.5 71.5 40.5C71.89 40.5 72.2 40.19 72.2 39.8C72.2 39.41 71.89 39.1 71.5 39.1C71.11 39.1 70.8 39.41 70.8 39.8ZM75.8 39.8C75.8 40.19 76.11 40.5 76.5 40.5C76.89 40.5 77.2 40.19 77.2 39.8C77.2 39.41 76.89 39.1 76.5 39.1C76.11 39.1 75.8 39.41 75.8 39.8ZM80.8 39.8C80.8 40.19 81.11 40.5 81.5 40.5C81.89 40.5 82.2 40.19 82.2 39.8C82.2 39.41 81.89 39.1 81.5 39.1C81.11 39.1 80.8 39.41 80.8 39.8Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M53.4 53.4C52.2 54.8 52.2 56.6 53.4 57.2C54.6 56.6 54.6 54.8 53.4 53.4ZM59.4 53.4C58.2 54.8 58.2 56.6 59.4 57.2C60.6 56.6 60.6 54.8 59.4 53.4ZM65.4 53.4C64.2 54.8 64.2 56.6 65.4 57.2C66.6 56.6 66.6 54.8 65.4 53.4ZM71.4 53.4C70.2 54.8 70.2 56.6 71.4 57.2C72.6 56.6 72.6 54.8 71.4 53.4ZM77.4 53.4C76.2 54.8 76.2 56.6 77.4 57.2C78.6 56.6 78.6 54.8 77.4 53.4ZM83.4 53.4C82.2 54.8 82.2 56.6 83.4 57.2C84.6 56.6 84.6 54.8 83.4 53.4ZM89.4 53.4C88.2 54.8 88.2 56.6 89.4 57.2C90.6 56.6 90.6 54.8 89.4 53.4Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M55 48.4C68 49.6 80 49.6 91.8 48.4L92.2 50.4L89.4 53.8L86.4 50.6L83.4 53.8L80.4 50.6L77.4 53.8L74.4 50.6L71.4 53.8L68.4 50.6L65.4 53.8L62.4 50.6L59.4 53.8L56.4 50.6L53.4 53.4C53.4 51.6 53.6 50 55 48.4Z',
|
||||
o: 0.18,
|
||||
},
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -476,4 +476,82 @@ export const NORDIC_STICKERS: readonly BookStickerDef[] = [
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
{
|
||||
id: 'celticHarp',
|
||||
theme: 'nordic',
|
||||
tags: ['harp', 'celtic', 'ireland', 'irish', 'music', 'instrument', 'folk', 'dublin'],
|
||||
descr:
|
||||
'An Irish Celtic harp in golden brown wood with a curved carved forepillar, an arched neck with brass tuning pins and pale strings over the soundbox.',
|
||||
box: [90, 110],
|
||||
slots: [
|
||||
{ name: 'wood', color: '#b8783a' },
|
||||
{ name: 'darkwood', color: '#6e4220' },
|
||||
{ name: 'strings', color: '#f4e7c4' },
|
||||
{ name: 'brass', color: '#e8b84a' },
|
||||
],
|
||||
layers: [
|
||||
{
|
||||
p: 2,
|
||||
d: 'M29 12.9L29 92.2M32.3 14.8L32.3 87M35.6 16.4L35.6 81.8M38.9 17.8L38.9 76.7M42.2 18.9L42.2 71.5M45.5 19.5L45.5 66.3M48.8 19.7L48.8 61.2M52.1 19.3L52.1 56M55.4 18.4L55.4 50.8M58.7 16.8L58.7 45.7M62 15.1L62 40.5M65.3 13.8L65.3 35.3M68.6 12.9L68.6 30.2M71.9 12.5L71.9 25',
|
||||
w: 0.75,
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M24 98L77 15C79 13 82 13 84 15L85.4 18C86 19.6 85.8 21 85 22.4L37 103C36 104.6 34.6 105.2 33 105L27 104.2C24.4 103.8 23 101 24 98Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M31 99.6L80.6 18.4C82 16.6 84 16.6 85 18.4C85.8 19.8 85.8 21 85 22.4L37 103C36 104.6 34.6 105.2 33 105L31 104.8C29.6 103.4 29.8 101.6 31 99.6Z',
|
||||
o: 0.25,
|
||||
},
|
||||
{ p: 1, d: 'M27.2 97.2L78.6 16.6', w: 1.1 },
|
||||
{
|
||||
p: 1,
|
||||
d: 'M44.6 74.4C45.9 72.4 48 72.8 47.6 74.6C47.2 76.4 45 77.2 44.6 74.4ZM58.2 52.6C59.5 50.6 61.6 51 61.2 52.8C60.8 54.6 58.6 55.4 58.2 52.6ZM69 35.2C70.1 33.6 71.8 34 71.5 35.4C71.2 36.8 69.3 37.4 69 35.2Z',
|
||||
},
|
||||
{ p: 3, d: 'M31.4 101.4L36.8 102.6M76.6 20.4L83.8 21.8', w: 1.3 },
|
||||
{
|
||||
p: 1,
|
||||
d: 'M18 101.4C18 99.8 19.2 99 20.8 99L36 99C37.6 99 38.6 100.2 38.6 101.6L38.6 105.4C38.6 106.6 37.8 107.4 36.6 107.4L19.6 107.4C18.6 107.4 18 106.6 18 105.6Z',
|
||||
},
|
||||
{ p: 0, d: 'M7 12C1.5 40 5.5 74 20.4 103L31.2 101.6C18.4 75 14 44 16.8 16Z' },
|
||||
{ p: 'shade', d: 'M12.6 14.5C10.4 44 14.6 75 26.6 102.2L31.2 101.6C18.4 75 14 44 16.8 16Z', o: 0.25 },
|
||||
{
|
||||
p: 3,
|
||||
d: 'M7.2 34C7.2 31.1 12 36.9 12 34C12 31.1 7.2 36.9 7.2 34ZM7.4 48C7.4 45.1 12.2 50.9 12.2 48C12.2 45.1 7.4 50.9 7.4 48ZM9 62C9 59.1 13.8 64.9 13.8 62C13.8 59.1 9 64.9 9 62ZM12 76C12 73.1 16.8 78.9 16.8 76C16.8 73.1 12 78.9 12 76ZM16.2 89C16.2 86.1 21 91.9 21 89C21 86.1 16.2 91.9 16.2 89Z',
|
||||
w: 0.9,
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
d: 'M5.6 28.8L13.8 28.4M5.8 39.4L14 39M5.8 42.8L14 42.4M6 53.4L14.2 53M7.4 56.8L15.6 56.4M7.6 67.4L15.8 67M10.4 70.8L18.6 70.4M10.6 81.4L18.8 81M14.6 83.8L22.8 83.4M14.8 94.4L23 94',
|
||||
w: 0.9,
|
||||
o: 0.85,
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M7 11C10 3 20 2 27 6C37 12 47 18 57 12C66 6 78 4 86 12L82 18C76 12 66 14 58 19C47 25 36 19 26 13C20 10 15 11 14 16.5Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M14 16.5C15 11 20 10 26 13C36 19 47 25 58 19C66 14 76 12 82 18L83.6 15.6C76 10 66 11 58 16C47 22 36 16.4 26 10.4C20 7 15.6 9 14 16.5Z',
|
||||
o: 0.22,
|
||||
},
|
||||
{ p: 'light', d: 'M11 8.6C15 4.6 21 4.4 27 7.6C37 13.4 47 18.6 57 13C65 8.4 74 6.6 80.6 9.2', w: 0.9, o: 0.45 },
|
||||
{
|
||||
p: 3,
|
||||
d: 'M28.1 11C28.1 11.32 28.27 11.62 28.55 11.78C28.83 11.94 29.17 11.94 29.45 11.78C29.73 11.62 29.9 11.32 29.9 11C29.9 10.68 29.73 10.38 29.45 10.22C29.17 10.06 28.83 10.06 28.55 10.22C28.27 10.38 28.1 10.68 28.1 11ZM31.4 12.8C31.4 13.3 31.8 13.7 32.3 13.7C32.8 13.7 33.2 13.3 33.2 12.8C33.2 12.3 32.8 11.9 32.3 11.9C31.8 11.9 31.4 12.3 31.4 12.8ZM34.7 14.5C34.7 14.82 34.87 15.12 35.15 15.28C35.43 15.44 35.77 15.44 36.05 15.28C36.33 15.12 36.5 14.82 36.5 14.5C36.5 14.18 36.33 13.88 36.05 13.72C35.77 13.56 35.43 13.56 35.15 13.72C34.87 13.88 34.7 14.18 34.7 14.5ZM38 16C38 16.32 38.17 16.62 38.45 16.78C38.73 16.94 39.07 16.94 39.35 16.78C39.63 16.62 39.8 16.32 39.8 16C39.8 15.68 39.63 15.38 39.35 15.22C39.07 15.06 38.73 15.06 38.45 15.22C38.17 15.38 38 15.68 38 16ZM41.3 17.1C41.3 17.6 41.7 18 42.2 18C42.7 18 43.1 17.6 43.1 17.1C43.1 16.6 42.7 16.2 42.2 16.2C41.7 16.2 41.3 16.6 41.3 17.1ZM44.6 17.8C44.6 18.12 44.77 18.42 45.05 18.58C45.33 18.74 45.67 18.74 45.95 18.58C46.23 18.42 46.4 18.12 46.4 17.8C46.4 17.48 46.23 17.18 45.95 17.02C45.67 16.86 45.33 16.86 45.05 17.02C44.77 17.18 44.6 17.48 44.6 17.8ZM47.9 18C47.9 18.5 48.3 18.9 48.8 18.9C49.3 18.9 49.7 18.5 49.7 18C49.7 17.5 49.3 17.1 48.8 17.1C48.3 17.1 47.9 17.5 47.9 18ZM51.2 17.6C51.2 17.92 51.37 18.22 51.65 18.38C51.93 18.54 52.27 18.54 52.55 18.38C52.83 18.22 53 17.92 53 17.6C53 17.28 52.83 16.98 52.55 16.82C52.27 16.66 51.93 16.66 51.65 16.82C51.37 16.98 51.2 17.28 51.2 17.6ZM54.5 16.5C54.5 16.82 54.67 17.12 54.95 17.28C55.23 17.44 55.57 17.44 55.85 17.28C56.13 17.12 56.3 16.82 56.3 16.5C56.3 16.18 56.13 15.88 55.85 15.72C55.57 15.56 55.23 15.56 54.95 15.72C54.67 15.88 54.5 16.18 54.5 16.5ZM57.8 14.8C57.8 15.3 58.2 15.7 58.7 15.7C59.2 15.7 59.6 15.3 59.6 14.8C59.6 14.3 59.2 13.9 58.7 13.9C58.2 13.9 57.8 14.3 57.8 14.8ZM61.1 13.1C61.1 13.42 61.27 13.72 61.55 13.88C61.83 14.04 62.17 14.04 62.45 13.88C62.73 13.72 62.9 13.42 62.9 13.1C62.9 12.78 62.73 12.48 62.45 12.32C62.17 12.16 61.83 12.16 61.55 12.32C61.27 12.48 61.1 12.78 61.1 13.1ZM64.4 11.8C64.4 12.12 64.57 12.42 64.85 12.58C65.13 12.74 65.47 12.74 65.75 12.58C66.03 12.42 66.2 12.12 66.2 11.8C66.2 11.48 66.03 11.18 65.75 11.02C65.47 10.86 65.13 10.86 64.85 11.02C64.57 11.18 64.4 11.48 64.4 11.8ZM67.7 10.9C67.7 11.22 67.87 11.52 68.15 11.68C68.43 11.84 68.77 11.84 69.05 11.68C69.33 11.52 69.5 11.22 69.5 10.9C69.5 10.58 69.33 10.28 69.05 10.12C68.77 9.96 68.43 9.96 68.15 10.12C67.87 10.28 67.7 10.58 67.7 10.9ZM71 10.5C71 10.82 71.17 11.12 71.45 11.28C71.73 11.44 72.07 11.44 72.35 11.28C72.63 11.12 72.8 10.82 72.8 10.5C72.8 10.18 72.63 9.88 72.35 9.72C72.07 9.56 71.73 9.56 71.45 9.72C71.17 9.88 71 10.18 71 10.5Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M4 11.4C4 14.38 6.42 16.8 9.4 16.8C12.38 16.8 14.8 14.38 14.8 11.4C14.8 8.42 12.38 6 9.4 6C6.42 6 4 8.42 4 11.4Z',
|
||||
},
|
||||
{
|
||||
p: 3,
|
||||
d: 'M9.4 11.4C9.4 10.2 11 10.2 11 11.6C11 13.4 8 13.6 7.6 11.4C7.2 8.6 11.4 7.8 12.6 10.4C13.6 12.8 12 15.6 9.4 15.6',
|
||||
w: 1,
|
||||
},
|
||||
{ p: 3, d: 'M14.6 16.2L18.4 17.2', w: 1.3 },
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -493,6 +493,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'نسخ للصق في أي صفحة (Ctrl+C)',
|
||||
'journey.studio.paste': 'لصق (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'انسخ شيئًا أولًا، ثم الصقه في أي صفحة',
|
||||
'journey.studio.pageFull':
|
||||
'هذه الصفحة ممتلئة: تحتوي بالفعل على أقصى عدد من العناصر. احذف شيئًا أولًا أو استخدم صفحة أخرى.',
|
||||
'journey.studio.myLayouts': 'تخطيطاتي',
|
||||
'journey.studio.myLayoutsEmpty': 'احتفظ بصفحة رتّبتها، ورتّب صفحات أخرى بالطريقة نفسها. تبقى صورها ونصوصها كما هي.',
|
||||
'journey.studio.saveLayout': 'حفظ هذه الصفحة كتخطيط',
|
||||
|
||||
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
|
||||
'journey.studio.pattern.scales': 'حراشف',
|
||||
'journey.studio.pattern.sunburst': 'أشعة الشمس',
|
||||
'journey.studio.pattern.confetti': 'قصاصات ملونة',
|
||||
'journey.studio.pattern.linen': 'كتان',
|
||||
'journey.studio.pattern.grain': 'حبيبات الورق',
|
||||
};
|
||||
export default studioPaint;
|
||||
|
||||
@@ -66,5 +66,118 @@ const studioShapes: TranslationStrings = {
|
||||
'journey.studio.removePoint': 'انقر مرتين للإزالة',
|
||||
'assistant.card.book.finding.hairline': 'خط أرق من أن يُطبع',
|
||||
'assistant.card.book.finding.edge_unused': 'حافة لا يقبلها هذا الشكل',
|
||||
'journey.studio.shapeName.rect': 'مستطيل',
|
||||
'journey.studio.shapeName.squircle': 'مربع مستدير',
|
||||
'journey.studio.shapeName.ellipse': 'شكل بيضاوي',
|
||||
'journey.studio.shapeName.capsule': 'كبسولة',
|
||||
'journey.studio.shapeName.triangle': 'مثلث',
|
||||
'journey.studio.shapeName.triangle-down': 'مثلث لأسفل',
|
||||
'journey.studio.shapeName.diamond': 'معين',
|
||||
'journey.studio.shapeName.parallelogram': 'متوازي أضلاع',
|
||||
'journey.studio.shapeName.trapezoid': 'شبه منحرف',
|
||||
'journey.studio.shapeName.arch': 'قوس',
|
||||
'journey.studio.shapeName.half-circle': 'نصف دائرة',
|
||||
'journey.studio.shapeName.quarter-circle': 'ربع دائرة',
|
||||
'journey.studio.shapeName.pentagon': 'خماسي',
|
||||
'journey.studio.shapeName.hexagon': 'سداسي',
|
||||
'journey.studio.shapeName.hexagon-flat': 'سداسي مسطح',
|
||||
'journey.studio.shapeName.heptagon': 'سباعي',
|
||||
'journey.studio.shapeName.octagon': 'ثماني',
|
||||
'journey.studio.shapeName.star-4': 'نجمة رباعية',
|
||||
'journey.studio.shapeName.star-5': 'نجمة خماسية',
|
||||
'journey.studio.shapeName.star-6': 'نجمة سداسية',
|
||||
'journey.studio.shapeName.star-8': 'نجمة ثمانية',
|
||||
'journey.studio.shapeName.star-12': 'نجمة اثنا عشرية',
|
||||
'journey.studio.shapeName.burst': 'انفجار',
|
||||
'journey.studio.shapeName.seal': 'ختم',
|
||||
'journey.studio.shapeName.sparkle': 'بريق',
|
||||
'journey.studio.shapeName.star-rounded': 'نجمة مستديرة',
|
||||
'journey.studio.shapeName.star-twinkle': 'نجمة متلألئة',
|
||||
'journey.studio.shapeName.arrow-right': 'سهم لليمين',
|
||||
'journey.studio.shapeName.arrow-left': 'سهم لليسار',
|
||||
'journey.studio.shapeName.arrow-up': 'سهم لأعلى',
|
||||
'journey.studio.shapeName.arrow-down': 'سهم لأسفل',
|
||||
'journey.studio.shapeName.arrow-both': 'سهم مزدوج',
|
||||
'journey.studio.shapeName.chevron-right': 'شيفرون لليمين',
|
||||
'journey.studio.shapeName.chevron-left': 'شيفرون لليسار',
|
||||
'journey.studio.shapeName.arrow-bent': 'سهم منحني الزاوية',
|
||||
'journey.studio.shapeName.arrow-curve': 'سهم منحنٍ',
|
||||
'journey.studio.shapeName.arrow-circle': 'سهم دائري',
|
||||
'journey.studio.shapeName.arrow-notched': 'سهم مفرّغ',
|
||||
'journey.studio.shapeName.arrow-thin': 'سهم رفيع',
|
||||
'journey.studio.shapeName.bubble-round': 'فقاعة حوار مستديرة',
|
||||
'journey.studio.shapeName.bubble-square': 'فقاعة حوار مربعة',
|
||||
'journey.studio.shapeName.bubble-oval': 'فقاعة حوار بيضاوية',
|
||||
'journey.studio.shapeName.bubble-think': 'فقاعة تفكير',
|
||||
'journey.studio.shapeName.bubble-shout': 'فقاعة صراخ',
|
||||
'journey.studio.shapeName.bubble-side': 'فقاعة حوار جانبية',
|
||||
'journey.studio.shapeName.bubble-capsule': 'فقاعة حوار كبسولة',
|
||||
'journey.studio.shapeName.pin': 'دبوس خريطة',
|
||||
'journey.studio.shapeName.compass': 'بوصلة',
|
||||
'journey.studio.shapeName.mountain': 'جبل',
|
||||
'journey.studio.shapeName.sun': 'شمس',
|
||||
'journey.studio.shapeName.moon': 'قمر',
|
||||
'journey.studio.shapeName.cloud': 'سحابة',
|
||||
'journey.studio.shapeName.cloud-puffy': 'سحابة منتفخة',
|
||||
'journey.studio.shapeName.drop': 'قطرة',
|
||||
'journey.studio.shapeName.wave': 'موجة',
|
||||
'journey.studio.shapeName.leaf': 'ورقة',
|
||||
'journey.studio.shapeName.shield': 'درع',
|
||||
'journey.studio.shapeName.ticket': 'تذكرة',
|
||||
'journey.studio.shapeName.heart': 'قلب',
|
||||
'journey.studio.shapeName.flower-5': 'زهرة بخمس بتلات',
|
||||
'journey.studio.shapeName.flower-6': 'زهرة بست بتلات',
|
||||
'journey.studio.shapeName.cross': 'صليب',
|
||||
'journey.studio.shapeName.plus': 'علامة زائد',
|
||||
'journey.studio.shapeName.gear': 'ترس',
|
||||
'journey.studio.shapeName.blob-1': 'شكل عضوي 1',
|
||||
'journey.studio.shapeName.blob-2': 'شكل عضوي 2',
|
||||
'journey.studio.shapeName.blob-3': 'شكل عضوي 3',
|
||||
'journey.studio.shapeName.blob-4': 'شكل عضوي 4',
|
||||
'journey.studio.shapeName.heart-round': 'قلب مستدير',
|
||||
'journey.studio.shapeName.heart-pair': 'قلبان',
|
||||
'journey.studio.shapeName.flower-8': 'زهرة بثماني بتلات',
|
||||
'journey.studio.shapeName.banner-ribbon': 'لافتة شريط',
|
||||
'journey.studio.shapeName.banner-pennant': 'راية مثلثة',
|
||||
'journey.studio.shapeName.banner-bookmark': 'فاصل كتاب',
|
||||
'journey.studio.shapeName.banner-flag': 'لافتة علم',
|
||||
'journey.studio.shapeName.ribbon-ends': 'شريط بأطراف مطوية',
|
||||
'journey.studio.shapeName.ribbon-arc': 'شريط مقوس',
|
||||
'journey.studio.shapeName.wavy-band': 'شريط متموج',
|
||||
'journey.studio.shapeName.torn-strip': 'شريط ممزق',
|
||||
'journey.studio.shapeName.arch-tall': 'قوس عالٍ',
|
||||
'journey.studio.shapeName.arch-pointed': 'قوس مدبب',
|
||||
'journey.studio.shapeName.arch-double': 'قوس مزدوج',
|
||||
'journey.studio.shapeName.arch-half': 'نصف قوس',
|
||||
'journey.studio.shapeName.arch-rainbow': 'قوس قزح',
|
||||
'journey.studio.shapeName.leaf-palm': 'سعفة نخيل',
|
||||
'journey.studio.shapeName.leaf-oak': 'ورقة بلوط',
|
||||
'journey.studio.shapeName.leaf-long': 'ورقة طويلة',
|
||||
'journey.studio.shapeName.leaf-monstera': 'ورقة مونستيرا',
|
||||
'journey.studio.shapeName.coral-1': 'مرجان 1',
|
||||
'journey.studio.shapeName.coral-2': 'مرجان 2',
|
||||
'journey.studio.shapeName.seaweed': 'أعشاب بحرية',
|
||||
'journey.studio.shapeName.star-organic': 'نجمة عضوية',
|
||||
'journey.studio.shapeName.sun-cutout': 'شمس مفرّغة',
|
||||
'journey.studio.shapeName.organic-1': 'شكل عضوي 1',
|
||||
'journey.studio.shapeName.organic-2': 'شكل عضوي 2',
|
||||
'journey.studio.shapeName.organic-3': 'شكل عضوي 3',
|
||||
'journey.studio.shapeName.stamp-rect': 'ختم مستطيل',
|
||||
'journey.studio.shapeName.stamp-circle': 'ختم دائري',
|
||||
'journey.studio.shapeName.postmark': 'ختم بريدي',
|
||||
'journey.studio.shapeName.rosette': 'وردة شرفية',
|
||||
'journey.studio.shapeName.scallop-circle': 'دائرة مسننة',
|
||||
'journey.studio.shapeName.badge-shield': 'شارة درع',
|
||||
'journey.studio.shapeName.label-tab': 'لسان ملصق',
|
||||
'journey.studio.shapeName.ticket-stub': 'قسيمة تذكرة',
|
||||
'journey.studio.shapeName.sunburst-12': 'أشعة شمس 12',
|
||||
'journey.studio.shapeName.sunburst-24': 'أشعة شمس 24',
|
||||
'journey.studio.shapeName.frame-notched': 'إطار مشطوف الزوايا',
|
||||
'journey.studio.shapeName.frame-chamfer': 'إطار مشطّب',
|
||||
'journey.studio.shapeName.frame-cushion': 'إطار وسادي',
|
||||
'journey.studio.shapeName.flourish-scroll': 'زخرفة لفافة',
|
||||
'journey.studio.shapeName.laurel': 'إكليل غار',
|
||||
'journey.studio.shapeName.fleuron': 'زخرفة نباتية',
|
||||
'journey.studio.shapeName.corner-flourish': 'زخرفة زاوية',
|
||||
};
|
||||
export default studioShapes;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user