Compare commits

...

33 Commits

Author SHA1 Message Date
Maurice 3f3e2f0bcf chore(client): hold the refs count and the stylesheet size at what they are 2026-10-24 14:06:40 +02:00
Maurice f956ab07f3 fix(studio): tidy the head row, the layouts tab and the theme panel
Every piece of the head row is one raised tile of one height: the format
picker moved beside the tools, the assistant and help stand alone, the
close button is square like the rest, and the beta mark is gone. The
layouts tab lays the book's own and the built-in layouts out in sections
and in the same grid as the designed pages. In the theme panel the accent
dots sit in the sheet's corner, the three accents share one name, a font
keeps its name ahead of its kind, a doubled rule is gone, and the
inspector's empty hint is shorter.
2026-10-24 13:54:49 +02:00
Maurice 736594696e fix(tours): one summit per mountain in the places along a tour
The index holds a mountain as many pins named by whoever listed it, at its
foot as much as on top, so a hike up Mt. Takao listed it five times under
names like Mt.Takao, 東京都 高尾山 and 高尾山 (Mt. Takao). Rows that are one
place now merge as a group: like names in one spot for every kind, and for a
summit of the index like names anywhere on the mountain, linked through a
name that carries both scripts. The row kept is an OpenStreetMap object over
an index pin, then the plainest name. Two OpenStreetMap summits stay two.
2026-10-24 13:54:48 +02:00
Maurice 1be997f7d8 test(studio): the pattern picker test counts the tiles against BOOK_PATTERNS 2026-10-24 11:36:30 +02:00
Maurice 5a8ff16afa test(studio): the shape swap test finds the ellipse by its translated name 2026-10-24 11:28:13 +02:00
Maurice 4435dc32b8 refactor(studio): gradient stops built by a plain function; the letters' fill in a helper 2026-10-24 11:07:16 +02:00
Maurice f5a33e0c1e refactor(studio): a paint's SVG parts in a module of their own; the pattern letters a component of their own 2026-10-24 11:07:16 +02:00
Maurice a8326b6f5a chore(client): the shape name entry at the end of the dynamic key list, beside the sticker one 2026-10-24 11:07:16 +02:00
Maurice 1e999d968a fix(studio): the new stickers drawn with the path commands the catalogue takes; the text fill test without Array.at 2026-10-24 11:07:16 +02:00
Maurice d0f74c9f02 feat(studio): add_book_pages, set_book_cover and design_book's cover take the decorated templates too 2026-10-24 11:07:16 +02:00
Maurice f14f1e41b9 fix(studio): bold text measured at each family's own bold width, so a bold condensed heading no longer overruns its frame 2026-10-24 11:07:16 +02:00
Maurice 9138e722bb feat(studio): twelve new stickers: Parthenon, Milan Cathedral, canal houses, gondola, Lisbon tram, Celtic harp, tiger, monkey, camel and three doodles 2026-10-24 11:07:16 +02:00
Maurice 9695e269e3 fix(studio): shapes named in every language, the align target kept across selections, up and down in a tab grid 2026-10-24 11:07:16 +02:00
Maurice 82bdf62c7b feat(studio): linen and paper grain, two quiet textures among the patterns 2026-10-24 11:07:15 +02:00
Maurice 8893e3afe0 feat(studio): letters filled with a gradient or a pattern 2026-10-24 11:07:15 +02:00
Maurice cd1e7d8fa8 refactor(pdf): call the markdown row directly 2026-10-24 10:39:36 +02:00
Maurice 059e72df69 refactor(pdf): the markdown rows and styles in a module of their own; the shared stop's markdown test beside its component 2026-10-24 10:39:36 +02:00
Maurice dc519b3ea7 fix(journey): a journey's created_at is an epoch number 2026-10-24 10:39:36 +02:00
Maurice ad4074f107 fix: journey year helper takes what a journey row carries; PDF markdown test without casts 2026-10-24 10:39:36 +02:00
Maurice 0534d7b3a7 fix: test strings, logical sides in the PDF markdown styles, one export per markdown module 2026-10-24 10:39:36 +02:00
Maurice a0554c2346 fix: four reported bugs that dev still had
- Dashboard: a browser that names no time zone (Chrome on Windows for a zone
  it cannot map) crashed the world clocks and the whole dashboard. One helper
  now reads the browser zone and every reader picks its fallback; a stored
  zone the browser does not know is left out instead of throwing (#2694).
- Phone: a leg of a multi-leg train or flight showed only its route, and its
  sheet showed the first leg's seat. The day plan subtitle is one helper for
  desktop and phone now, so a leg names its own train or flight, platform and
  seat, translated instead of the hardcoded "Gl."/"Sitz"; the sheet opened
  from a leg shows that leg's number, seat, platform and times (#2701). The
  PDF export translates platform and seat too.
- Journey list: the year badge named the year a journey was written, not the
  years of its trips; a trip over New Year shows both (#2685).
- A place's description and notes are markdown in the planner but printed as
  raw syntax in the PDF export and on a shared trip. Both render it now through
  the app's sanitized markdown pipeline, raw HTML stays text (#2692).

Closes #2694
Closes #2701
Closes #2685
Closes #2692
2026-10-24 10:39:35 +02:00
Maurice bc5af23896 chore(client): lower the test non-null assertion baseline to what the tree holds 2026-10-24 10:08:00 +02:00
Maurice 92be8a7ca5 fix(studio): lint and test fixes: zoom bar labels without the studio object, tile zooms in a helper, tests without non-null assertions or the window toast 2026-10-24 10:07:59 +02:00
Maurice 44ea1ea0ba fix(places): search_place asks for one place per query, so the assistant stops bundling four names into one search 2026-10-24 10:07:59 +02:00
Maurice 3bee600096 fix(assistant): a step that searched the web and called a tool no longer sends ChatGPT an answer to the search, which it refused 2026-10-24 10:07:59 +02:00
Maurice 5b4030f86b fix(studio): the full page notice names no number, so it needs no plural forms 2026-10-24 10:07:59 +02:00
Maurice e2c00ba18b feat(studio): a full page says so instead of swallowing the click 2026-10-24 10:07:59 +02:00
Maurice 1050029842 fix(studio): a saved layout's background unlinks the page from its theme colour 2026-10-24 10:07:58 +02:00
Maurice 64edf899ac fix(studio): stickers without gaps: the ramen bowl overlaps its broth, the croissant is one closed crescent, the nata's ribs stay in their cup 2026-10-24 10:07:58 +02:00
Maurice 0a9075af73 test(studio): the head band 2026-10-24 10:07:58 +02:00
Maurice c3a5383f5e feat(studio): the head band in the dialogs' shape, tools grouped by what they do, book view beside the zoom 2026-10-24 10:07:58 +02:00
Maurice e96813edcf fix(places): search_place says what locationBias does, so the assistant passes the neighbourhood instead of the city centre 2026-10-24 10:07:58 +02:00
Maurice 5e36e38b72 fix(offline): the vector prefetch takes the vector source and each source's own zooms; driving settings sync only with the add-on on 2026-10-24 10:07:58 +02:00
242 changed files with 7099 additions and 912 deletions
+1 -1
View File
@@ -1300,7 +1300,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
target: p => p.locator('.st-back'),
target: p => p.locator('.st-head-close'),
},
],
},
+2 -2
View File
@@ -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,
+7
View File
@@ -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_:-]+)+$/;
+2 -2
View File
@@ -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": 1918
}
@@ -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(() => {});
};
+15 -2
View File
@@ -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')
})
+11 -8
View File
@@ -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')
+30
View File
@@ -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));
@@ -215,13 +215,13 @@ export function StudioAssistantToggle({ assistant }: { assistant: StudioAssistan
return (
<button
type="button"
className={`st-tool st-assistant-toggle ${assistant.open ? 'is-on' : ''}`}
className={`st-tool is-icon st-head-solo st-assistant-toggle ${assistant.open ? 'is-on' : ''}`}
onClick={assistant.toggle}
aria-pressed={assistant.open}
aria-label={t('journey.studio.assistant.toggle')}
title={t('journey.studio.assistant.toggleHint')}
>
<MessageSquare size={14} />
<span className="st-tool-label">{t('journey.studio.assistant.toggle')}</span>
<MessageSquare size={15} />
</button>
);
}
@@ -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.getByRole('img', { name: 'TREK Studio' })).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,382 @@
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, the Studio's mark above the title, the title, a line under it, and
* the 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. Whether it is saved sits under the title, because it
* describes it. The tools on the right are grouped by what they do: who else
* is here, what the book is printed as, editing (undo, redo, paste), auto
* layout, the assistant, help, and getting the book out. Every piece of
* the row is one raised tile of one height, export alone in the accent. 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 }} />
</div>
<h1 className="st-head-title">{s.journey?.title || s.t('journey.title')}</h1>
<div className="st-head-sub">
<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">
<FormatPicker s={s} />
</div>
<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} />
</div>
{/* The book's assistant, a tile of its own like help; only where it can run for this user. */}
<StudioAssistantToggle assistant={assistant} />
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
<HelpButton className="st-tool is-icon st-head-solo" />
<button
type="button"
className="st-tool is-primary st-head-solo"
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 tool of the head row: 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-tool"
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 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: 0.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.
*/}
<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
@@ -35,10 +35,10 @@ export function StudioSavedLayouts({
const scale = width / (wMm * pxPerMm)
return (
<div className="st-saved-layouts">
<div className="st-section st-saved-layouts">
<div className="st-section-label">{t('journey.studio.myLayouts')}</div>
{fitting.length === 0 && <p className="st-hint">{t('journey.studio.myLayoutsEmpty')}</p>}
<div className="st-thumbs">
<div className={`st-thumbs st-tpl-grid ${single ? 'is-pages' : 'is-spreads'}`}>
{fitting.map(layout => (
<div className="st-thumb-row" key={layout.id}>
<button type="button" className="st-thumb" onClick={() => applyLayout(active, layout.id)}>
@@ -79,7 +79,6 @@ export function StudioSavedLayouts({
{t('journey.studio.saveLayout')}
</button>
</Tooltip>
<div className="st-section-label st-builtin-label">{t('journey.studio.builtInLayouts')}</div>
</div>
)
}
+23 -355
View File
@@ -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');
+11 -3
View File
@@ -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'>;
/**
+41 -8
View File
@@ -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,
+5
View File
@@ -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}`)
}
@@ -117,7 +117,7 @@ function BuiltInLayouts({
const scale = width / (page.pageWidth * (single ? 1 : 2) * pxPerMm);
const templates = single ? COVER_TEMPLATES : TEMPLATES;
return (
<div className="st-thumbs">
<div className={`st-thumbs st-tpl-grid ${single ? 'is-pages' : 'is-spreads'}`}>
{templates.map((tpl) => (
<button type="button" key={tpl.id} className="st-thumb" onClick={() => onPick(tpl)}>
{/* A page is laid out from its top left in either reading direction. */}
@@ -183,7 +183,7 @@ export function StudioTemplatesPanel({
if (!doc || !spread || !ctx) return null;
const single = spread.role !== 'inner';
const templates = templatesForSpread(spread, filter);
const { full, card: cardWidth } = previewWidths(list.width, single);
const { card: cardWidth } = previewWidths(list.width, single);
/**
* One undo step: the page laid out, nothing selected, and the photos at hand
@@ -247,8 +247,11 @@ export function StudioTemplatesPanel({
</div>
<p className="st-hint">{t('studio.templates.hint')}</p>
</div>
<StudioSavedLayouts page={page} width={full} pxPerMm={pxPerMm} t={t} />
<BuiltInLayouts spread={spread} page={page} pxPerMm={pxPerMm} width={full} t={t} onPick={pickBuiltIn} />
<StudioSavedLayouts page={page} width={cardWidth} pxPerMm={pxPerMm} t={t} />
<div className="st-section">
<div className="st-section-label">{t('journey.studio.builtInLayouts')}</div>
<BuiltInLayouts spread={spread} page={page} pxPerMm={pxPerMm} width={cardWidth} t={t} onPick={pickBuiltIn} />
</div>
</div>
</>
);
+1 -1
View File
@@ -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;
@@ -67,7 +67,11 @@ function ThemeCard({
);
}
/** One palette entry: its colour, which is the picker, and its name. */
/**
* One palette entry: its colour, which is the picker, and its name. The three
* accents share one name under them, so each shows its colour alone and says
* its own name to a screen reader and on hover.
*/
function PaletteEntry({
name,
hex,
@@ -79,13 +83,14 @@ function PaletteEntry({
onPick: (hex: string) => void;
t: StudioT;
}) {
const accent = name.startsWith('accent');
const label = t(`studio.theme.token.${name}`);
return (
<label className="st-theme-colour">
<label className="st-theme-colour" title={label}>
<span className="st-theme-chip" style={{ background: hex }}>
<input type="color" value={hex} aria-label={label} onChange={(e) => onPick(e.target.value)} />
</span>
<span className="st-theme-colour-name">{label}</span>
{!accent && <span className="st-theme-colour-name">{label}</span>}
</label>
);
}
@@ -181,6 +186,7 @@ export function StudioThemePanel({ t }: { t: StudioT }) {
t={t}
/>
))}
<span className="st-theme-colour-name st-theme-accents-name">{t('studio.theme.accents')}</span>
</div>
<button
type="button"
@@ -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')} />}
+2 -1
View File
@@ -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'
);
});
});
+13
View File
@@ -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>
)
+2 -1
View File
@@ -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',
};
}
+2 -1
View File
@@ -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,
+6 -1
View File
@@ -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) =>
({
+29 -2
View File
@@ -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)))
+4 -19
View File
@@ -2,25 +2,10 @@
Studio in the planner's window (assistantFrame.tsx), so the page and the
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. */
@media (max-width: 1519px) {
.trek-dash .st-assistant-toggle .st-tool-label {
display: none;
}
.trek-dash .st-assistant-toggle {
padding: 0 8px;
}
}
.trek-dash .st-bar .st-back {
min-width: 0;
}
.trek-dash .st-bar .st-back > svg {
flex: none;
/* The toggle is an icon tile of the head row; open, it reads as pressed. */
.trek-dash .st-tool.st-head-solo.st-assistant-toggle.is-on {
background: color-mix(in oklch, var(--accent) 14%, var(--surface));
color: var(--ink);
}
.trek-dash .st-assistant-notice {
+13
View File
@@ -71,6 +71,19 @@
white-space: nowrap;
}
/* In the closed picker the face's name comes first: the kind gives way, cut
short and then gone, before a letter of the name is lost. */
.trek-dash .st-fontpick-current .st-fontpick-face {
flex: 0 1 auto;
}
.trek-dash .st-fontpick-current .st-fontpick-span {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
text-align: end;
}
.trek-dash .st-fontpick-caret {
flex: none;
color: var(--ink-3);
+186
View File
@@ -0,0 +1,186 @@
/* ----------------- head band (StudioHeader.tsx) ----------------- *
* The shape every TREK dialog head has: a tinted band, a raised tile, a small
* line above the title and the 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: what this screen is, above its name, in the title's own ink. */
.trek-dash .st-head-eyebrow {
display: flex;
align-items: center;
gap: 7px;
height: 17px;
color: var(--ink);
}
.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;
}
.trek-dash .st-head-tools {
display: flex;
align-items: center;
gap: 8px;
flex: none;
margin-inline-start: auto;
}
/* Every piece of the row is one raised tile of one height and one corner:
the groups, who is here, help, export and close. */
.trek-dash .st-head {
--st-head-h: 36px;
}
/* Tools that belong together share one raised group, like a segmented control. */
.trek-dash .st-head-group {
display: flex;
align-items: center;
gap: 2px;
height: var(--st-head-h);
padding: 3px;
border-radius: 12px;
background: var(--surface);
box-shadow: var(--sh-xs);
}
/* A tool standing alone is a group of one: help, and export in the accent. */
.trek-dash .st-tool.st-head-solo {
height: var(--st-head-h);
border-radius: 12px;
}
.trek-dash .st-tool.is-icon.st-head-solo {
width: var(--st-head-h);
background: var(--surface);
box-shadow: var(--sh-xs);
}
.trek-dash .st-tool.is-icon.st-head-solo:hover:not(:disabled) {
background: var(--surface);
box-shadow: var(--sh-sm);
}
.trek-dash .st-tool.is-primary.st-head-solo {
padding-inline: 16px;
box-shadow: var(--sh-xs);
}
/* Who is here, as a tile like the tools beside it. */
.trek-dash .st-head .st-peers {
height: var(--st-head-h);
padding-inline: 8px 12px;
margin-inline-start: 0;
border: 0;
border-radius: 12px;
background: var(--surface);
box-shadow: var(--sh-xs);
}
.trek-dash .st-head .st-peer-dot {
width: 24px;
height: 24px;
border-color: var(--surface);
}
.trek-dash .st-head .st-peer-label {
font-size: 12.5px;
}
.trek-dash .st-head-close {
display: grid;
place-items: center;
width: var(--st-head-h);
height: var(--st-head-h);
flex: none;
border-radius: 12px;
background: var(--surface);
box-shadow: var(--sh-xs);
color: var(--ink-3);
transition:
color 0.16s,
box-shadow 0.16s;
}
.trek-dash .st-head-close:hover {
color: var(--ink);
box-shadow: var(--sh-sm);
}
.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 format's shape, drawn in the tool's own ink. */
.trek-dash .st-head-chip-shape {
flex: none;
border: 1.5px solid currentColor;
border-radius: 2px;
opacity: 0.7;
}
/* 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);
}
+4
View File
@@ -28,6 +28,10 @@
.trek-dash .st-tpl-grid.is-spreads {
grid-template-columns: minmax(0, 1fr);
}
/* The book's own layouts and the built-in ones lie in the same grid as the designed pages. */
.trek-dash .st-thumbs.st-tpl-grid {
display: grid;
}
.trek-dash .st-tpl-card {
min-width: 0;
+34 -6
View File
@@ -65,10 +65,13 @@
line-height: 1;
letter-spacing: -0.01em;
}
/* The accents sit in the sheet's top corner, clear of a wide "Aa" below. */
.trek-dash .st-theme-dots {
position: absolute;
top: 7px;
inset-inline-end: 8px;
display: flex;
gap: 3px;
padding-bottom: 2px;
}
.trek-dash .st-theme-dots > span {
width: 11px;
@@ -127,6 +130,28 @@
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
/* The accents' one name spans their three columns, level with the others'
names; every entry is placed, so the name does not push them a row down. */
.trek-dash .st-theme-palette > .st-theme-colour {
grid-row: 1;
}
.trek-dash .st-theme-palette > :nth-child(2) {
grid-column: 2;
}
.trek-dash .st-theme-palette > :nth-child(3) {
grid-column: 3;
}
.trek-dash .st-theme-palette > :nth-child(4) {
grid-column: 4;
}
.trek-dash .st-theme-palette > :nth-child(5) {
grid-column: 5;
}
.trek-dash .st-theme-accents-name {
grid-row: 1;
grid-column: 3 / span 3;
align-self: end;
}
.trek-dash .st-theme-colour {
display: flex;
flex-direction: column;
@@ -154,10 +179,14 @@
opacity: 0;
cursor: pointer;
}
/* On one line: broken inside a word, "Accent" read as "Accen t". A name the
column cannot hold is cut, and the entry's title says it whole. */
.trek-dash .st-theme-colour-name {
max-width: 100%;
overflow-wrap: anywhere;
hyphens: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: -0.01em;
text-align: center;
line-height: 1.2;
font-size: calc(10.5px * var(--fs-scale-caption, 1));
@@ -210,13 +239,12 @@
padding: 0 2px;
}
/* The last section's own rule already sets this apart; a second one doubled it. */
.trek-dash .st-theme-apply {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 6px;
padding-top: 12px;
border-top: 1px solid var(--line-2);
padding-top: 14px;
}
.trek-dash .st-theme-apply .st-hint {
padding: 0 2px;
+4 -95
View File
@@ -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,38 +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 {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 6px;
border-radius: 999px;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--accent);
background: color-mix(in oklch, var(--accent) 13%, transparent);
border: 1px solid color-mix(in oklch, var(--accent) 26%, transparent);
flex: none;
}
/* ----------------- custom trim ----------------- */
.trek-dash .st-menu-sep {
height: 1px;
@@ -1991,9 +1900,9 @@
* The book's own layouts sit above the built-in ones, with the save button
* closing their group, so what a click keeps and where it goes read as one.
*/
.trek-dash .st-saved-layouts { display: flex; flex-direction: column; margin-bottom: 14px; }
.trek-dash .st-saved-layouts { display: flex; flex-direction: column; }
.trek-dash .st-saved-layouts .st-hint { margin: 0 0 4px; }
.trek-dash .st-saved-layouts .st-thumbs:empty { display: none; }
.trek-dash .st-saved-layouts .st-add-page { margin-top: 8px; }
.trek-dash .st-saved-layouts .st-add-page:disabled { opacity: .5; cursor: default; }
.trek-dash .st-saved-layouts .st-add-page:disabled:hover { background: none; border-color: var(--line-2); color: var(--ink-3); }
.trek-dash .st-saved-layouts .st-builtin-label { margin-top: 18px; }
+83 -38
View File
@@ -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++
}
}
+5 -1
View File
@@ -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
+24 -1
View File
@@ -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)
})
})
+21
View File
@@ -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('')
})
})
+1 -1
View File
@@ -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);
});
});
+42 -2
View File
@@ -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();
+3 -1
View File
@@ -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',
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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'),
},
@@ -66,10 +66,6 @@ function metresApart(a: Pick<TourPoi, 'lat' | 'lng'>, b: { lat: number; lng: num
return Math.hypot(dLat, dLng);
}
function withinSamePlace(a: Pick<TourPoi, 'lat' | 'lng'>, b: { lat: number; lng: number }): boolean {
return metresApart(a, b) <= SAME_PLACE_M;
}
/** A name as compared: without case, accents or punctuation. */
function nameKey(name: string): string {
return name
@@ -202,21 +198,82 @@ function tourCategoryOf(mapsCategory: string, asked: readonly TourPoiCategory[])
}
/**
* The places found, each once and within the corridor, with where they lie.
* OpenStreetMap often maps one place twice, as a point and as its outline, and
* the index may hold it beside OpenStreetMap: one name in one spot is one
* place. An unnamed place (a tap, a bench) is never merged with another.
* Within this many metres, a summit of the index and another of a like name
* are one mountain. The index holds a mountain as many pins, placed by whoever
* listed it, at its foot as much as on top, so they lie a mountain's breadth
* apart. Two OpenStreetMap summits are two peaks, as the Watzmann and the
* Kleiner Watzmann are.
*/
const SAME_MOUNTAIN_M = 3000;
/**
* Scripts written without spaces between words: there one name inside another
* is a word of it, as 高尾山 is of 八王子高尾山, where in Latin "Dom" is not
* "Domplatz".
*/
const UNSPACED_SCRIPT = /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}]{2,}$/u;
/** {@link similarNames}, or one word written without spaces inside the other name. */
function likeNames(a: string, b: string): boolean {
if (similarNames(a, b)) return true;
const inside = (word: string, name: string) => UNSPACED_SCRIPT.test(word) && name.replaceAll(' ', '').includes(word);
return a.split(' ').some((word) => inside(word, b)) || b.split(' ').some((word) => inside(word, a));
}
/**
* Two rows that are one place: the same name in one spot, as OpenStreetMap
* maps a place twice, as a point and as its outline, or the index holds it
* beside OpenStreetMap; a like name in one spot; or, for a summit of the
* index, a like name anywhere on the mountain. An unnamed place (a tap, a
* bench) is never one with another.
*/
function samePlace(a: TourPoi, b: TourPoi): boolean {
const name = nameKey(a.name);
const other = nameKey(b.name);
if (!name || !other || a.category !== b.category) return false;
const apart = metresApart(a, b);
const pinned = a.source !== 'openstreetmap' || b.source !== 'openstreetmap';
if (a.category === 'peak' && pinned && apart <= SAME_MOUNTAIN_M) return likeNames(name, other);
return apart <= SAME_PLACE_M && (name === other || likeNames(name, other));
}
/**
* The row a place mapped several times keeps: an OpenStreetMap object over an
* index pin, then the plainest name ("高尾山" rather than "Top Of Mt.Takao"),
* then the first found.
*/
function keptOf(rows: readonly TourPoi[]): TourPoi {
const plainness = (row: TourPoi) => [row.source === 'openstreetmap' ? 0 : 1, nameKey(row.name).length];
return rows.reduce((best, row) => {
const [a, b] = [plainness(row), plainness(best)];
return a[0] < b[0] || (a[0] === b[0] && a[1] < b[1]) ? row : best;
});
}
/**
* The rows that are one place, each once: a row joins every group it is one
* place with, so a bridge like "高尾山 (Mt. Takao)" joins "Mt.Takao" and
* "高尾山" although those two share no word.
*/
function oncePerPlace(rows: readonly TourPoi[]): TourPoi[] {
const groups: TourPoi[][] = [];
for (const row of rows) {
const joined = groups.filter((group) => group.some((other) => samePlace(row, other)));
const merged = [...joined.flat(), row];
groups.splice(0, groups.length, ...groups.filter((group) => !joined.includes(group)), merged);
}
const kept = new Set(groups.map(keptOf));
// In the order found, so the first of equals stays first.
return rows.filter((row) => kept.has(row));
}
/** The places found, each once and within the corridor, with where they lie. */
function placesWithin(found: MapsPoi[], body: TourPoiSearchRequest, line: Array<[number, number]>): TourPoi[] {
const kept = new Map<string, TourPoi>();
const byName = new Map<string, TourPoi[]>();
for (const poi of found) {
const category = tourCategoryOf(poi.category, body.categories);
const near = nearnessToLine({ lat: poi.lat, lng: poi.lng }, line);
if (!category || !near || near.distanceM > body.corridor_m || kept.has(poi.osm_id)) continue;
const name = poi.name.trim().toLocaleLowerCase();
const namesakes = name ? (byName.get(name) ?? []) : [];
if (namesakes.some((other) => withinSamePlace(other, poi))) continue;
const place: TourPoi = {
id: poi.osm_id,
name: poi.name,
@@ -234,9 +291,8 @@ function placesWithin(found: MapsPoi[], body: TourPoiSearchRequest, line: Array<
...bikeChargingOf(poi),
};
kept.set(poi.osm_id, place);
if (name) byName.set(name, [...namesakes, place]);
}
return [...kept.values()];
return oncePerPlace([...kept.values()]);
}
/**
@@ -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']);
});
});
@@ -139,6 +139,28 @@ describe('TourPoiSearchService', () => {
expect(result.pois.map((found) => found.id)).toEqual(['node:9', 'node:10']);
});
it('TOUR-POI-019: the pins of one mountain under several names are one summit, two mapped summits stay two', async () => {
const pin = (id: string, name: string, lat: number) =>
poi(id, lat, 11.1003, 'peak', { name, source: 'trek-places', poi_type: 'mountain' });
poisAlong.mockResolvedValue(
answer([
// Pins at the foot of the mountain and on top, named in two scripts and bridged by one that has both.
pin('gers:1', 'Mt.Takao', 47.401),
pin('gers:2', '東京都 高尾山', 47.4011),
pin('gers:3', '高尾山 (Mt. Takao)', 47.403),
pin('gers:4', '八王子高尾山', 47.405),
pin('gers:5', '高尾山', 47.419),
pin('gers:6', 'Top Of Mt.Takao', 47.4191),
// Another mountain on the way, and two summits of OpenStreetMap a kilometre apart.
pin('gers:7', 'Mt.Kagenobu', 47.41),
poi('node:1', 47.406, 11.1003, 'peak', { name: 'Watzmann' }),
poi('node:2', 47.415, 11.1003, 'peak', { name: 'Kleiner Watzmann' }),
]),
);
const result = await service.search({ tour_type: 'hike', categories: ['peak'], corridor_m: 500, line: LINE });
expect(result.pois.map((found) => found.name)).toEqual(['Watzmann', 'Mt.Kagenobu', 'Kleiner Watzmann', '高尾山']);
});
it('TOUR-POI-002: asks the map search along the line for what each category means, and names a hit by the category asked', async () => {
poisAlong.mockResolvedValue(answer([poi('node:cafe', 47.41, 11.1, 'cafe')]));
const result = await service.search({
+2 -2
View File
@@ -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(),
+7 -2
View File
@@ -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);
}
/**
+20
View File
@@ -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',
+6
View File
@@ -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);
}
});
+17
View File
@@ -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 });
+4 -13
View File
@@ -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(),
+20
View File
@@ -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({
+15 -9
View File
@@ -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: [],
};
});
+26 -17
View File
@@ -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' }>;
+1 -1
View File
@@ -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);
});
+3 -1
View File
@@ -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 };
}
+3 -1
View File
@@ -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();
});
});
+9
View File
@@ -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);
});
});
+29 -9
View File
@@ -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);
}
+2 -1
View File
@@ -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;
}
+9 -1
View File
@@ -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);
});
});
+3 -1
View File
@@ -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(),
+18 -2
View File
@@ -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 };

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