Compare commits

...

41 Commits

Author SHA1 Message Date
Maurice 3c73da4427 fix(assistant): bring the end of a long answer into view once it is written
A new question is held at the top of the view, so its answer reads from its
first line. When the answer carries steps, a strip of places and cards, its
closing words and the changes to apply ended below the view, and the user
saw only the places found. Once the answer is written, the thread now
scrolls to where those closing blocks begin, or to the bottom when they are
shorter than the view, unless the user scrolled it by hand meanwhile.
2026-10-24 16:33:14 +02:00
Maurice cff2e55db7 feat(help): Studio's help as Studio is now, with guides for themes, stickers, travel, text effects, backgrounds, layers and own layouts
The screen texts name the top bar, the rail and the workbench as they are
after the redesign, the travel guide walks through the four tabs, and six
new guides cover what Studio gained since its help was written. Every
Studio picture is taken again, and all texts are translated.
2026-10-24 15:38:36 +02:00
Maurice 429a25faa9 fix(admin): name the addon columns by start and end 2026-10-24 15:10:13 +02:00
Maurice 486a2eeca3 feat(admin): addons tab in the cards of the other tabs, with a search and an on/off filter
The addons sit in a card per type now, the way every other admin tab sets
its cards, instead of three columns nested in one card. A head card shows
how many of each type are on and finds an addon by its name or description,
with a switch between all, the ones on and the ones off.
2026-10-24 15:10:13 +02:00
Maurice 26a6147e8d fix(book): a summary's and a country's look are optional, so a stored book reads and saves as it did
Absent means the classic look. Defaulting it wrote the field into every
element the auto layout and the templates build, which moved their parity
fixtures and grew the page tools past their schema ceiling; the new
descriptions are also shorter for the same reason.
2026-10-24 14:59:00 +02:00
Maurice a7b53988ff feat(studio): rethink the travel tab with many new looks, a saved tile and a newer-book overlay
The travel tab is four tabs now, figures, maps, countries and marks, and
offers only the newer looks, each dressed in the book's theme and linked to
it. The looks books already hold still draw on their pages exactly as
before; they are just no longer offered.

- Figures: a summary as a poster, a ticket, tiles, a road sign, an
  odometer, a handwritten note, a pencil sketch or a band, and each figure
  on its own as a medal, on odometer drums or ringed by hand.
- Maps: the route with its photographs, its stops numbered, its two ends
  marked or on a dark ground, over satellite or relief imagery, a map per
  trip, and a map of each country with its stops alone, cut to its shape.
- Countries: each country as a passport stamp, a visa, an airmail
  postcard, its flag in cloth, a map pin, a luggage label or a sketch.
- Marks: the date, the day, the distance, the coordinates and the flag as
  stamps, tags, pills, ribbons, die-cut stickers, embroidered patches and
  sketches.

The schema gains a summary's and a country's look, seven mark styles and
the numbered and ends stops of a map, all additive, as revision 11.

Also in Studio: the save state is a tile beside who is here, a book a newer
Studio saved is said by an overlay that holds the whole Studio until it
reloads, and the page rail draws the first and the last page beside the
blank inside of their cover. The notifications page and the bell's panel
take the dialogs' look: a head with a tile, cards with an unread dot, and
tools that come forward on hover.
2026-10-24 14:45:04 +02:00
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
Maurice 890473ebd5 style(i18n): format the translated locale files 2026-10-24 09:41:34 +02:00
Maurice 2b86d1fda7 fix(i18n): translate the English left in the other 26 locales
About 2900 strings still carried the `// en-fallback` placeholder and some
3100 more were unmarked English copies, most of them from the Studio, the
assistant, OAuth and the date picker. Every one of them is translated now;
where the English word is the locale's own one (German "Status", "Backup"),
it is kept and marked `// same-as-en`. Placeholders, markup and product names
are unchanged.

"Studio", the photo book editor's name, joins Atlas and Vacay on the
untranslated check's list of names. The baseline drops from 2900 marked and
3100 identical strings to 1 and 158.
2026-10-24 09:41:33 +02:00
843 changed files with 21138 additions and 8871 deletions
+163 -3
View File
@@ -460,6 +460,20 @@ const exportDialog = (page: Page) => page.getByRole('dialog', { name: 'Export' }
const thumbs = (page: Page) => page.locator('.st-thumb-row')
/** The hit targets over the spread, one per element. */
const sheetElements = (page: Page) => page.locator('.st-sheet > div[style*="left:"]')
/**
* The hit target of the first text on the spread on screen. The targets above the page are empty
* boxes, so the one over the drawn words is found by where the words are and marked for the locator.
*/
async function markTextTarget(page: Page): Promise<void> {
const words = page.locator('.st-sheet').getByText(/\p{L}{3,}/u).first()
await expect(words).toBeVisible()
await words.evaluate(el => {
const r = el.getBoundingClientRect()
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
hit?.setAttribute('data-help-text', '')
})
}
const textTarget = (page: Page) => page.locator('.st-sheet [data-help-text]')
async function studioSection(page: Page, name: string): Promise<void> {
await railButton(page, name).click()
@@ -1300,7 +1314,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
target: p => p.locator('.st-back'),
target: p => p.locator('.st-head-close'),
},
],
},
@@ -1458,16 +1472,162 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'studio-my-layouts': {
guide: guide('studio-my-layouts'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
},
target: p => railButton(p, 'Layouts'),
act: async p => { await studioSection(p, 'Layouts') },
},
{
// Pointed at rather than pressed, so the seed book keeps no layout of its own for the guides after this one.
prepare: async p => {
await sidePanel(p).getByRole('button', { name: 'Save this page as a layout' }).scrollIntoViewIfNeeded()
await settle(p)
},
target: p => sidePanel(p).locator('.st-saved-layouts'),
},
],
},
'studio-theme': {
guide: guide('studio-theme'),
start: openStudio,
steps: [
{
target: p => railButton(p, 'Theme'),
act: async p => { await studioSection(p, 'Theme') },
},
{
target: p => sidePanel(p).locator('.st-theme-grid'),
act: async p => {
await sidePanel(p).locator('.st-theme-card').nth(1).click()
await settle(p)
},
},
{
prepare: async p => {
await sidePanel(p).getByRole('button', { name: 'Apply to the book' }).scrollIntoViewIfNeeded()
await settle(p)
},
target: p => sidePanel(p).locator('.st-theme-apply'),
},
],
},
'studio-stickers': {
guide: guide('studio-stickers'),
start: openStudio,
steps: [
{
prepare: async p => { await studioSection(p, 'Elements') },
target: p => sidePanel(p).getByRole('tab', { name: 'Stickers' }),
act: async p => {
await sidePanel(p).getByRole('tab', { name: 'Stickers' }).click()
await expect(sidePanel(p).locator('.st-shape-grid').first()).toBeVisible()
await settle(p)
},
},
{
target: p => sidePanel(p).locator('.st-shape-grid').first(),
},
],
},
'studio-travel': {
guide: guide('studio-travel'),
start: openStudio,
steps: [
{
target: p => railButton(p, 'Travel'),
act: async p => { await studioSection(p, 'Travel') },
act: async p => {
await studioSection(p, 'Travel')
await expect(sidePanel(p).locator('.st-travel-card').first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: sidePanel,
target: p => sidePanel(p).locator('.st-travel-grid').first(),
},
{
target: p => sidePanel(p).locator('.st-travel-card').first(),
},
],
},
'studio-text-effects': {
guide: guide('studio-text-effects'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
await markTextTarget(p)
},
target: p => textTarget(p),
act: async p => {
await textTarget(p).click()
await expect(inspector(p).getByRole('button', { name: 'Effects' })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
const head = inspector(p).getByRole('button', { name: 'Effects' })
if ((await head.getAttribute('aria-expanded')) !== 'true') await head.click()
await head.scrollIntoViewIfNeeded()
await settle(p)
},
target: p => inspector(p).locator('.st-section').filter({ has: p.getByRole('button', { name: 'Effects' }) }),
},
],
},
'studio-background': {
guide: guide('studio-background'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
},
target: p => inspector(p).locator('.st-section').filter({ hasText: 'Page background' }).first(),
},
{
target: p => inspector(p).locator('.st-paint-tabs').first(),
},
],
},
'studio-layers': {
guide: guide('studio-layers'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
},
target: p => railButton(p, 'Layers'),
act: async p => {
await studioSection(p, 'Layers')
await expect(sidePanel(p).locator('.st-layer').first()).toBeVisible()
await settle(p)
},
},
{
target: p => sidePanel(p).locator('.st-layer').first(),
},
],
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 29 KiB

+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_:-]+)+$/;
+1 -1
View File
@@ -38,8 +38,8 @@
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
"src/components/Studio/StudioShell.tsx": 1,
"src/components/Studio/TravelElements.tsx": 1,
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
"src/components/Studio/travel/BadgeIcon.tsx": 1,
"src/components/Todo/useTodoList.ts": 1,
"src/components/shared/ConfirmDialog.tsx": 1,
"src/components/shared/CountryFlag.tsx": 1,
+3 -3
View File
@@ -12,11 +12,11 @@
"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,
"src/components/Studio/TravelElements.tsx": 1738,
"src/components/Studio/TravelElements.tsx": 1559,
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
@@ -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
}
+3 -3
View File
@@ -52,7 +52,7 @@
"components/Journey/moodConfig.ts": 5,
"components/Layout/BottomNav.tsx": 3,
"components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 8,
"components/Layout/InAppNotificationBell.tsx": 3,
"components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 21,
"components/Layout/NewVersionNotice.tsx": 3,
@@ -72,7 +72,7 @@
"components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1,
"components/Notifications/InAppNotificationItem.tsx": 8,
"components/Notifications/InAppNotificationItem.tsx": 1,
"components/PDF/TripPDF.tsx": 53,
"components/Packing/PackingListPanelBagModal.tsx": 2,
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
@@ -295,7 +295,7 @@
"pages/FilesPage.tsx": 11,
"pages/ForgotPasswordPage.tsx": 18,
"pages/HelpPage.tsx": 21,
"pages/InAppNotificationsPage.tsx": 9,
"pages/InAppNotificationsPage.tsx": 2,
"pages/JoinTripPage.tsx": 4,
"pages/JourneyDetailPage.tsx": 53,
"pages/JourneyPage.tsx": 64,
+2 -1
View File
@@ -44,9 +44,10 @@
"components/Studio/SpreadView.tsx": 1,
"components/Studio/StudioElementsPanel.tsx": 2,
"components/Studio/StudioSwatches.tsx": 1,
"components/Studio/TravelElements.tsx": 6,
"components/Studio/TravelElements.tsx": 3,
"components/Studio/peerColour.ts": 1,
"components/Studio/printSheets.ts": 4,
"components/Studio/travel/badgeLook.ts": 2,
"components/Trips/TripMemberAvatar.tsx": 2,
"components/Vacay/VacayPersons.tsx": 1,
"components/shared/DialogShell.tsx": 2,
@@ -712,4 +712,33 @@ describe('AddonManager', () => {
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-045: finds an addon by name or description and shows the ones on or off', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([
buildAddon({ id: 'x-lists', name: 'Todo List', description: 'Track tasks', type: 'trip', enabled: true }),
buildAddon({ id: 'x-costs', name: 'Budget', description: 'Split costs', type: 'trip', enabled: false }),
buildAddon({ id: 'x-holiday', name: 'Vacay', description: 'Holiday planner', type: 'global', enabled: true }),
])
);
render(<AddonManager />);
await screen.findByText('Todo List');
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'costs');
expect(screen.getByText('Budget')).toBeInTheDocument();
expect(screen.queryByText('Todo List')).toBeNull();
expect(screen.queryByText('Vacay')).toBeNull();
await user.clear(screen.getByRole('searchbox', { name: 'Search addons' }));
await user.click(screen.getByRole('button', { name: 'On' }));
expect(screen.getByText('Todo List')).toBeInTheDocument();
expect(screen.getByText('Vacay')).toBeInTheDocument();
expect(screen.queryByText('Budget')).toBeNull();
await user.click(screen.getByRole('button', { name: 'Off' }));
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'nothing like this');
expect(screen.getByText('No addon matches the search or the filter.')).toBeInTheDocument();
});
});
+140 -61
View File
@@ -1,7 +1,7 @@
import type { ComponentType } from 'react'
import { useState, type ComponentType } from 'react'
import { useTranslation } from '../../i18n'
import { useIsDark } from '../../hooks/useIsDark'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront, Search } from 'lucide-react'
import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
@@ -76,6 +76,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
toggleToursWanderer,
mapSwitches,
} = useAddonManager()
const [query, setQuery] = useState('')
const [view, setView] = useState<AddonView>('all')
if (loading) {
return (
@@ -194,10 +196,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
}
/* One column per type, side by side, instead of three stacked sections. Stacked, each
section opened its own set of columns and the tall tiles (Collab, Journey) set a
section's height while its neighbours ran out early. Side by side the column's
head carries the type, so the tiles need no badge of their own. */
/* A card per type, the way every other admin tab sets its cards: Trip in the
left column, Global and Integration in the right one, each card with the
dialogs' head band carrying the type, its count and what it means. Above
them one card says how many are on and finds an addon by name. */
const groups = [
{ key: 'trip', addons: tripAddons },
{ key: 'global', addons: globalAddons },
@@ -214,68 +216,145 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</span>
)
return (
<SettingsCard
icon={Puzzle}
title={t('admin.addons.title')}
hint={subtitle}
badge={totalCount > 0 ? (
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
<span className="inline-flex">
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
</span>
</Tooltip>
) : undefined}
>
{addons.length === 0 ? (
<EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
) : (
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
{groups.map(g => {
const meta = TYPE_META[g.key]
return (
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons} t={t} />
<div className="divide-y divide-edge-faint">
{/* The AI addons share a connection and a settings dialog, so they head the column as one tile. */}
{g.key === 'integration' && aiAddons.length > 0 && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
{g.addons.map(tile)}
</div>
</section>
)
})}
/** Whether an addon is shown under the search and the filter. */
const shown = (addon: Addon) => {
if (view === 'on' && !addon.enabled) return false
if (view === 'off' && addon.enabled) return false
const q = query.trim().toLocaleLowerCase()
if (!q) return true
const label = getAddonLabel(t, addon)
return `${label.name} ${label.description ?? ''}`.toLocaleLowerCase().includes(q)
}
const card = (g: (typeof groups)[number]) => {
const meta = TYPE_META[g.key]
const all = g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons
const visible = g.addons.filter(shown)
const ai = g.key === 'integration' && aiAddons.some(shown)
if (!visible.length && !ai) return null
const on = all.filter(a => a.enabled).length
return (
<SettingsCard
key={g.key}
icon={meta.icon}
title={t(meta.labelKey)}
hint={t(meta.hintKey)}
badge={(
<Tooltip label={t('admin.addons.group.count', { enabled: on, total: all.length })}>
<span className="inline-flex"><StatusPill tone={on > 0 ? 'success' : 'neutral'}>{on}/{all.length}</StatusPill></span>
</Tooltip>
)}
bodyClassName="p-3"
>
<div className="divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
{/* The AI addons share a connection and a settings dialog, so they head the card as one tile. */}
{ai && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
{visible.map(tile)}
</div>
)}
</SettingsCard>
</SettingsCard>
)
}
const [first, ...rest] = groups
const cards = { start: first ? card(first) : null, end: rest.map(card).filter(Boolean) }
return (
<>
<SettingsCard
icon={Puzzle}
title={t('admin.addons.title')}
hint={subtitle}
badge={totalCount > 0 ? (
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
<span className="inline-flex">
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
</span>
</Tooltip>
) : undefined}
>
{addons.length === 0
? <EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
: <AddonToolbar groups={groups.map(g => ({ key: g.key, addons: g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons }))} query={query} onQuery={setQuery} view={view} onView={setView} t={t} />}
</SettingsCard>
{addons.length > 0 && (cards.start || cards.end.length > 0 ? (
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
<div className="min-w-0">{cards.start}</div>
<div className="min-w-0">{cards.end}</div>
</div>
) : (
<p className="m-0 rounded-2xl border border-dashed border-edge-faint px-4 py-8 text-center text-content-muted" style={fs(13, 'body')}>
{t('admin.addons.noMatch')}
</p>
))}
</>
)
}
/** The head band of one type's column: icon tile, the type, its count and what it means. */
function GroupHead({ icon: Icon, label, hint, addons, t }: {
icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>
label: string
hint: string
addons: Addon[]
type AddonView = 'all' | 'on' | 'off'
/**
* The head card's body: a tile per type with how many of it are on, then a
* search over the addons' names and descriptions and a switch between all,
* the ones on and the ones off.
*/
function AddonToolbar({ groups, query, onQuery, view, onView, t }: {
groups: { key: string; addons: Addon[] }[]
query: string
onQuery: (q: string) => void
view: AddonView
onView: (v: AddonView) => void
t: (key: string, params?: Record<string, unknown>) => string
}) {
const enabled = addons.filter(a => a.enabled).length
return (
<div className="flex items-start gap-2.5 border-b border-edge-faint bg-surface-tertiary px-3.5 py-2.5">
<span className="mt-px grid h-7 w-7 flex-none place-items-center rounded-[9px] bg-surface-card text-content-secondary shadow-sm">
<Icon size={13} strokeWidth={2} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{label}</h3>
<Tooltip label={t('admin.addons.group.count', { enabled, total: addons.length })}>
<span className="inline-flex">
<StatusPill>{enabled}/{addons.length}</StatusPill>
</span>
</Tooltip>
</div>
{/* Says what the type means: the only place that still explains it. */}
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11)}>{hint}</p>
<>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{groups.map(g => {
const meta = TYPE_META[g.key]
const Icon = meta.icon
const on = g.addons.filter(a => a.enabled).length
return (
<div key={g.key} className="flex items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-secondary text-content-secondary">
<Icon size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(12.5, 'body')}>{t(meta.labelKey)}</span>
<span className="block text-content-faint" style={fs(11)}>{t('admin.addons.group.count', { enabled: on, total: g.addons.length })}</span>
</span>
<span className={`font-geist font-bold tabular-nums ${on > 0 ? 'text-content' : 'text-content-faint'}`} style={fs(18, 'body')} aria-hidden>{on}</span>
</div>
)
})}
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<label className="relative flex min-w-0 flex-1 items-center">
<Search size={14} className="pointer-events-none absolute start-3 text-content-faint" />
<input
type="search"
value={query}
onChange={e => onQuery(e.target.value)}
placeholder={t('admin.addons.search')}
aria-label={t('admin.addons.search')}
className="h-9 w-full rounded-[10px] border border-edge-faint bg-surface-card ps-8 pe-3 text-content outline-none transition-colors placeholder:text-content-faint focus:border-accent"
style={fs(13, 'body')}
/>
</label>
<div className="inline-flex flex-none gap-1 rounded-[10px] bg-surface-tertiary p-1" role="group" aria-label={t('admin.addons.filter')}>
{(['all', 'on', 'off'] as const).map(v => (
<button
key={v}
type="button"
onClick={() => onView(v)}
aria-pressed={view === v}
className={`h-7 rounded-[8px] px-3 font-medium transition-colors ${view === v ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
style={fs(12.5, 'body')}
>
{t(`admin.addons.filter.${v}`)}
</button>
))}
</div>
</div>
</>
)
}
@@ -1,7 +1,7 @@
import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react';
import type { AssistantMessage as StoredMessage } from '@trek/shared';
import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react';
import { useMemo } from 'react';
import { Fragment, useMemo } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
@@ -16,7 +16,7 @@ import AssistantShareDialog from './AssistantShareDialog';
import AssistantSources from './AssistantSources';
import AssistantToolSteps from './AssistantToolSteps';
import AssistantWeatherStrip from './AssistantWeatherStrip';
import { messageBlocks } from './assistantParts';
import { type MessageBlock, messageBlocks, tailStart } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import AssistantCardBlock from './cards/AssistantCardBlock';
import { useAssistantShare } from './useAssistantShare';
@@ -108,6 +108,28 @@ function Question({ message }: { message: StoredMessage }) {
);
}
/** One block of an answer, in the shape its kind takes. */
function AnswerBlock({
block,
message,
writing,
streaming,
}: {
block: MessageBlock;
message: StoredMessage;
writing: boolean;
streaming: boolean;
}) {
if (block.kind === 'text') return <AssistantMarkdown text={block.text} writing={streaming} />;
if (block.kind === 'steps') return <AssistantToolSteps steps={block.steps} />;
if (block.kind === 'places') return <AssistantPlaceStrip found={block.found} message={message} />;
if (block.kind === 'weather') return <AssistantWeatherStrip days={block.days} />;
if (block.kind === 'sources') return <AssistantSources sources={block.sources} />;
if (block.kind === 'remembered') return <AssistantRemembered note={block.note} live={writing} />;
if (block.kind === 'cards') return <AssistantCardBlock block={block} message={message} />;
return <AssistantChangeCard items={block.items} />;
}
function Answer({ message }: { message: StoredMessage }) {
const { t } = useTranslation();
const { tripId, variant } = useAssistantView();
@@ -119,25 +141,16 @@ function Answer({ message }: { message: StoredMessage }) {
const share = useAssistantShare(tripId, message, writing);
const last = blocks[blocks.length - 1];
const hasText = blocks.some((b) => b.kind === 'text');
const tail = tailStart(blocks);
return (
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
{blocks.map((block) => {
if (block.kind === 'text') {
return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />;
}
if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />;
if (block.kind === 'places') {
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
}
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
if (block.kind === 'sources') return <AssistantSources key={block.key} sources={block.sources} />;
if (block.kind === 'remembered') {
return <AssistantRemembered key={block.key} note={block.note} live={writing} />;
}
if (block.kind === 'cards') return <AssistantCardBlock key={block.key} block={block} message={message} />;
return <AssistantChangeCard key={block.key} items={block.items} />;
})}
{blocks.map((block, i) => (
<Fragment key={block.key}>
{i === tail && i > 0 && <span data-assistant-tail aria-hidden="true" className="-mb-2.5 block h-0" />}
<AnswerBlock block={block} message={message} writing={writing} streaming={writing && block === last} />
</Fragment>
))}
{/* Nothing below says the answer goes on: no words being written, no step running. */}
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
<p role="status" className="m-0 text-caption">
@@ -1,6 +1,6 @@
import { AuiIf, ThreadPrimitive } from '@assistant-ui/react';
import { ArrowDown, FileUp, MapPin } from 'lucide-react';
import { useMemo } from 'react';
import { useMemo, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
@@ -15,13 +15,15 @@ import { useAssistantAttach } from './useAssistantAttach';
import { useFollowSelectedDay } from './useAssistantFocus';
import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop';
import { useAssistantSuggestions } from './useAssistantSuggestions';
import { useRevealAnswerTail } from './useRevealAnswerTail';
/**
* The chat itself: the messages, and under them the line that says what went
* wrong, the privacy note and the composer, held at the bottom while the
* messages scroll. A new question moves up to the top of the view, so its
* answer reads from its first line instead of chasing the newest one. A
* booking file or a place of the trip can be dropped anywhere on it.
* answer reads from its first line instead of chasing the newest one, and once
* the answer is written its end comes into view. A booking file or a place of
* the trip can be dropped anywhere on it.
*/
export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
const { t } = useTranslation();
@@ -31,6 +33,8 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
const { suggestions, canEdit } = useAssistantSuggestions(tripId);
useFollowSelectedDay(tripId);
const viewport = useRef<HTMLDivElement>(null);
useRevealAnswerTail(viewport, isRunning);
const phone = variant === 'fullscreen';
const attach = useAssistantAttach(tripId);
@@ -52,7 +56,11 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
</span>
</div>
)}
<ThreadPrimitive.Viewport turnAnchor="top" className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
<ThreadPrimitive.Viewport
ref={viewport}
turnAnchor="top"
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4"
>
<AuiIf condition={(s) => s.thread.isEmpty}>
<AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} />
{mentionable && (
@@ -61,7 +61,7 @@ export interface ChangeItem {
/** The answer cards of one group, side by side. */
export type CardsBlock = { kind: 'cards'; key: string; group: string; entries: CardEntry[] };
type MessageBlock =
export type MessageBlock =
| { kind: 'text'; key: string; text: string }
| { kind: 'steps'; key: string; steps: ToolStep[] }
| { kind: 'change'; key: string; items: ChangeItem[] }
@@ -464,3 +464,14 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
packing: { labelKey: 'assistant.change.label.packing' },
breaks: { labelKey: 'assistant.change.label.breaks' },
};
/**
* Where an answer's closing words and changes begin: the block after its last
* step, strip or card. That end is what the thread brings into view once the
* answer is written.
*/
export function tailStart(blocks: MessageBlock[]): number {
let i = blocks.length;
while (i > 0 && (blocks[i - 1].kind === 'text' || blocks[i - 1].kind === 'change')) i -= 1;
return i;
}
@@ -0,0 +1,109 @@
/**
* FE-AI-REVEAL-001 to FE-AI-REVEAL-007
*
* Once an answer is written, its closing words and changes come into view,
* unless the user scrolled the thread themselves while it was written; and
* where that end begins in an answer's blocks.
*/
import { useRef } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render } from '../../../tests/helpers/render';
import { type MessageBlock, tailStart } from './assistantParts';
import { tailScrollTop, useRevealAnswerTail } from './useRevealAnswerTail';
/** A viewport of 400px holding 1200px, its tail mark 900px down, scrolled to `scrollTop`. */
function lay(viewport: HTMLElement, { tailAt = 900, scrollTop = 0, height = 1200 } = {}) {
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 400 });
Object.defineProperty(viewport, 'scrollHeight', { configurable: true, value: height });
viewport.scrollTop = scrollTop;
viewport.getBoundingClientRect = () => ({ top: 100 }) as DOMRect;
const tail = viewport.querySelector('[data-assistant-tail]') as HTMLElement | null;
if (tail) tail.getBoundingClientRect = () => ({ top: 100 + tailAt - scrollTop }) as DOMRect;
}
function Thread({ running }: { running: boolean }) {
const ref = useRef<HTMLDivElement>(null);
useRevealAnswerTail(ref, running);
return (
<div ref={ref} data-testid="viewport" tabIndex={0}>
<span data-assistant-tail />
</div>
);
}
const block = (kind: MessageBlock['kind']) => ({ kind, key: kind }) as MessageBlock;
describe('useRevealAnswerTail', () => {
let scrollTo: ReturnType<typeof vi.fn>;
beforeEach(() => {
scrollTo = vi.fn();
HTMLElement.prototype.scrollTo = scrollTo as unknown as HTMLElement['scrollTo'];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0);
return 1;
});
});
afterEach(() => vi.restoreAllMocks());
it('FE-AI-REVEAL-001: brings the end of the answer into view once it is written', () => {
const view = render(<Thread running />);
lay(view.getByTestId('viewport'), { height: 2000 });
view.rerender(<Thread running={false} />);
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 888 }));
});
it('FE-AI-REVEAL-002: leaves the thread where the user scrolled it while the answer was written', () => {
const view = render(<Thread running />);
const viewport = view.getByTestId('viewport');
lay(viewport);
fireEvent.wheel(viewport, { deltaY: 120 });
view.rerender(<Thread running={false} />);
expect(scrollTo).not.toHaveBeenCalled();
});
it('FE-AI-REVEAL-003: does nothing on the first render or while nothing runs', () => {
const view = render(<Thread running={false} />);
lay(view.getByTestId('viewport'));
view.rerender(<Thread running={false} />);
expect(scrollTo).not.toHaveBeenCalled();
});
it('FE-AI-REVEAL-004: a new answer reveals its end again after an earlier one was scrolled by hand', () => {
const view = render(<Thread running />);
const viewport = view.getByTestId('viewport');
lay(viewport);
fireEvent.wheel(viewport, { deltaY: 120 });
view.rerender(<Thread running={false} />);
view.rerender(<Thread running />);
view.rerender(<Thread running={false} />);
expect(scrollTo).toHaveBeenCalledTimes(1);
});
});
describe('tailScrollTop', () => {
it('FE-AI-REVEAL-005: stops at the bottom when the end is shorter than the view, and never scrolls up', () => {
const viewport = document.createElement('div');
viewport.innerHTML = '<span data-assistant-tail></span>';
lay(viewport, { tailAt: 1100 });
expect(tailScrollTop(viewport)).toBe(800);
lay(viewport, { tailAt: 300, scrollTop: 500 });
expect(tailScrollTop(viewport)).toBeNull();
});
it('FE-AI-REVEAL-006: without a mark there is nothing to reveal', () => {
const viewport = document.createElement('div');
lay(viewport);
expect(tailScrollTop(viewport)).toBeNull();
});
});
describe('tailStart', () => {
it('FE-AI-REVEAL-007: the end begins after the last step, strip or card', () => {
expect(tailStart([block('steps'), block('places'), block('text'), block('change')])).toBe(2);
expect(tailStart([block('text'), block('places')])).toBe(2);
expect(tailStart([block('text'), block('change')])).toBe(0);
expect(tailStart([])).toBe(0);
});
});
@@ -0,0 +1,61 @@
import { type RefObject, useEffect, useRef } from 'react';
import { prefersReducedMotion } from '../../utils/reducedMotion';
/** Room left above the tail, so its first line does not sit flush on the edge. */
const TAIL_GAP = 12;
/** What reading the thread by hand looks like while the answer is written. */
const OWN_SCROLL = ['wheel', 'touchmove', 'keydown'] as const;
/**
* Where to scroll so the end of the latest answer is in view: the start of its
* closing text and proposed changes (the `data-assistant-tail` mark), or the
* very bottom when that tail is shorter than the view. Null when it is in view
* already, so the thread never moves up.
*/
export function tailScrollTop(viewport: HTMLElement): number | null {
const marks = viewport.querySelectorAll('[data-assistant-tail]');
const tail = marks[marks.length - 1];
if (!tail) return null;
const offset = tail.getBoundingClientRect().top - viewport.getBoundingClientRect().top + viewport.scrollTop;
const target = Math.min(offset - TAIL_GAP, viewport.scrollHeight - viewport.clientHeight);
return target > viewport.scrollTop + 1 ? target : null;
}
/**
* A new question moves to the top of the view, so its answer reads from the
* first line. A long answer (steps, a strip of places found, cards) then ends
* below the view, and its closing words and the changes to apply were never
* seen. Once the answer is written, this brings that end into view, unless the
* user scrolled the thread themselves while it was written.
*/
export function useRevealAnswerTail(viewport: RefObject<HTMLElement | null>, isRunning: boolean): void {
const scrolledByHand = useRef(false);
const wasRunning = useRef(isRunning);
useEffect(() => {
const el = viewport.current;
if (!el || !isRunning) return;
scrolledByHand.current = false;
const mark = () => {
scrolledByHand.current = true;
};
for (const type of OWN_SCROLL) el.addEventListener(type, mark, { passive: true });
return () => {
for (const type of OWN_SCROLL) el.removeEventListener(type, mark);
};
}, [viewport, isRunning]);
useEffect(() => {
const ended = wasRunning.current && !isRunning;
wasRunning.current = isRunning;
const el = viewport.current;
if (!ended || !el || scrolledByHand.current) return;
// A frame later, so the answer's last blocks have their height.
const frame = requestAnimationFrame(() => {
const top = tailScrollTop(el);
if (top !== null) el.scrollTo({ top, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
});
return () => cancelAnimationFrame(frame);
}, [viewport, isRunning]);
}
@@ -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(() => {});
};
+4
View File
@@ -6,6 +6,7 @@ import {
Book, Palette, Map, Bell, Plug, CloudOff, User, Lock, ShieldCheck, Fingerprint, KeyRound,
Route, Users, UserRound, Crown, PanelLeftClose, Undo2, Ticket, Train, Plane, ListChecks, Wallet, FolderOpen, MessagesSquare,
ArrowUpDown, Backpack, BatteryCharging, BedDouble, BellRing, Bike, Blocks, CableCar, Car, CloudDownload, CopyPlus, FileDown, FilePen, FileSpreadsheet, FolderInput, Footprints, History, Info, Layers, MailCheck, MapPinned, Moon, Mountain, MountainSnow, MoveRight, NotebookPen, PackagePlus, Pin, Receipt, Rotate3d, RouteOff, Shuffle, Spline, TriangleAlert, Waves, Wind,
PaintBucket, Sticker, Type,
type LucideIcon,
} from 'lucide-react'
import type { HelpIcon } from '../../help/types'
@@ -13,6 +14,9 @@ import type { HelpIcon } from '../../help/types'
/** The registry names icons; the components draw them. Unknown names fall back to the compass. */
const ICONS: Record<HelpIcon, LucideIcon> = {
plus: Plus,
sticker: Sticker,
type: Type,
paintBucket: PaintBucket,
pencil: Pencil,
image: Image,
copy: Copy,
@@ -68,12 +68,12 @@ export default function InAppNotificationBell(): React.ReactElement {
{/* Click-away catcher only — the bell button itself closes the panel again from the keyboard. */}
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
<div
className="rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge"
className="rounded-2xl shadow-xl border overflow-hidden bg-surface-card border-edge"
style={{
position: 'fixed',
top: 'var(--nav-h)',
insetInlineEnd: 8,
width: 360,
width: 380,
maxWidth: 'calc(100vw - 16px)',
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
zIndex: 9999,
@@ -81,14 +81,15 @@ export default function InAppNotificationBell(): React.ReactElement {
flexDirection: 'column',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 flex-shrink-0 border-b border-edge-secondary"
>
<span className="text-sm font-semibold text-content">
{/* Header: the dialogs' head, a tile, the name and how many are unread, the tools at the end. */}
<div className="flex items-center gap-3 px-4 pt-4 pb-3 flex-shrink-0">
<span className="grid place-items-center w-9 h-9 rounded-xl bg-accent-subtle text-accent flex-shrink-0">
<Bell className="w-4 h-4" />
</span>
<span className="flex-1 min-w-0 flex items-center gap-2 text-subtitle font-semibold text-content">
{t('notifications.title')}
{unreadCount > 0 && (
<span className="ms-2 px-1.5 py-0.5 rounded-full text-xs font-medium bg-content text-surface">
<span className="px-2 h-5 inline-flex items-center rounded-full text-caption font-semibold tabular-nums bg-accent text-accent-text">
{unreadCount}
</span>
)}
@@ -98,34 +99,38 @@ export default function InAppNotificationBell(): React.ReactElement {
<button type="button"
onClick={markAllRead}
title={t('notifications.markAllRead')}
className="p-1.5 rounded-lg transition-colors text-content-muted hover:bg-surface-hover focus-visible:bg-surface-hover"
aria-label={t('notifications.markAllRead')}
className="grid place-items-center w-8 h-8 rounded-lg transition-colors text-content-muted bg-surface-secondary hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover"
>
<CheckCheck className="w-3.5 h-3.5" />
<CheckCheck className="w-4 h-4" />
</button>
)}
{notifications.length > 0 && (
<button type="button"
onClick={() => setConfirmingDeleteAll(true)}
title={t('notifications.deleteAll')}
className="p-1.5 rounded-lg transition-colors text-content-muted hover:bg-surface-hover focus-visible:bg-surface-hover"
aria-label={t('notifications.deleteAll')}
className="grid place-items-center w-8 h-8 rounded-lg transition-colors text-content-muted bg-surface-secondary hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft"
>
<Trash2 className="w-3.5 h-3.5" />
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Notification list */}
<div className="overflow-y-auto flex-1">
<div className="overflow-y-auto flex-1 py-1 border-t border-edge-secondary">
{isLoading && notifications.length === 0 ? (
<div className="flex items-center justify-center py-10">
<div className="w-5 h-5 border-2 rounded-full animate-spin border-edge border-t-content" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
<Bell className="w-8 h-8 text-content-faint" />
<p className="text-sm font-medium text-content-muted">{t('notifications.empty')}</p>
<p className="text-xs text-content-faint">{t('notifications.emptyDescription')}</p>
<div className="flex flex-col items-center justify-center py-10 px-6 text-center gap-2">
<span className="grid place-items-center w-12 h-12 rounded-2xl bg-surface-secondary text-content-faint">
<Bell className="w-5 h-5" />
</span>
<p className="text-body font-semibold text-content">{t('notifications.empty')}</p>
<p className="text-caption text-content-muted">{t('notifications.emptyDescription')}</p>
</div>
) : (
notifications.slice(0, 10).map(n => (
@@ -135,12 +140,14 @@ export default function InAppNotificationBell(): React.ReactElement {
</div>
{/* Footer */}
<button type="button"
onClick={handleShowAll}
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0 border-t border-edge-secondary text-content hover:bg-surface-hover focus-visible:bg-surface-hover"
>
{t('notifications.showAll')}
</button>
<div className="p-2 flex-shrink-0 border-t border-edge-secondary">
<button type="button"
onClick={handleShowAll}
className="w-full h-9 rounded-xl text-body font-medium transition-colors bg-surface-secondary text-content hover:bg-surface-hover focus-visible:bg-surface-hover"
>
{t('notifications.showAll')}
</button>
</div>
</div>
</>,
document.body
@@ -13,6 +13,18 @@ interface NotificationItemProps {
onClose?: () => void
}
/** The round tool beside a notification, quiet until it is pointed at. */
const TOOL = 'grid place-items-center w-7 h-7 rounded-lg transition-colors text-content-faint hover:text-content hover:bg-surface-card focus-visible:bg-surface-card focus-visible:text-content'
/** An answer or a way on, as a pill under the words. */
const PILL = 'inline-flex items-center gap-1.5 h-7 px-3 rounded-full text-caption font-medium transition-colors'
/**
* One notification, as a card in the list: who sent it, what it says, and what
* can be done with it. Unread ones carry a dot in the accent and a faint
* ground; the tools to mark and delete come forward when the card is pointed
* at or focused, so a list reads as messages rather than as rows of buttons.
*/
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
const { t, locale } = useTranslation()
const { responding, handleRespond, handleNavigate, markRead, markUnread, deleteNotification } =
@@ -22,29 +34,25 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
const bodyText = t(notification.text_key, notification.text_params)
const hasUnknownTitle = titleText === notification.title_key
const hasUnknownBody = bodyText === notification.text_key
const unread = !notification.is_read
const answered = notification.response !== null
return (
<div
className={`relative px-4 py-3 transition-colors border-b border-edge-secondary ${notification.is_read ? '' : 'bg-accent-subtle'}`}
className={`group relative mx-2 my-1 rounded-xl px-3 py-3 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover ${unread ? 'bg-surface-secondary' : ''}`}
>
{unread && <span aria-hidden className="absolute start-1 top-5 w-1.5 h-1.5 rounded-full bg-accent" />}
<div className="flex gap-3 items-start">
{/* Sender avatar */}
<div className="flex-shrink-0 mt-0.5">
<div className="flex-shrink-0">
{notification.sender_avatar ? (
<img
src={notification.sender_avatar}
alt=""
className="w-8 h-8 rounded-full object-cover"
/>
<img src={notification.sender_avatar} alt="" className="w-9 h-9 rounded-xl object-cover" />
) : (
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-content-muted bg-surface-tertiary"
>
<div className="w-9 h-9 rounded-xl flex items-center justify-center text-body font-semibold text-accent bg-accent-subtle">
{notification.sender_username
? notification.sender_username.charAt(0).toUpperCase()
: <User className="w-4 h-4" />
}
: <User className="w-4 h-4" />}
</div>
)}
</div>
@@ -52,72 +60,67 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug text-content">
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>
{hasUnknownTitle ? notification.title_key : titleText}
</p>
<div className="flex items-center gap-0.5 flex-shrink-0">
<span className="text-xs me-1 text-content-faint">
<div className="relative flex-shrink-0 h-7 flex items-center">
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
{relativeTime(notification.created_at, locale)}
</span>
{notification.is_read ? (
<button type="button"
onClick={() => markUnread(notification.id)}
title={t('notifications.markUnread')}
aria-label={t('notifications.markUnread')}
className="p-1 rounded transition-colors text-content-faint hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover focus-visible:text-content"
<div className="absolute end-0 top-0 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
{notification.is_read ? (
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
<Mail className="w-3.5 h-3.5" />
</button>
) : (
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={() => deleteNotification(notification.id)}
title={t('notifications.delete')}
aria-label={t('notifications.delete')}
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
>
<Mail className="w-3.5 h-3.5" />
<Trash2 className="w-3.5 h-3.5" />
</button>
) : (
<button type="button"
onClick={() => markRead(notification.id)}
title={t('notifications.markRead')}
aria-label={t('notifications.markRead')}
className="p-1 rounded transition-colors text-content-faint hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover focus-visible:text-content"
>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
<button type="button"
onClick={() => deleteNotification(notification.id)}
title={t('notifications.delete')}
aria-label={t('notifications.delete')}
className="p-1 rounded transition-colors text-content-faint hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
<p className="text-xs mt-0.5 leading-relaxed text-content-muted">
<p className="text-caption mt-0.5 leading-relaxed text-content-muted">
{hasUnknownBody ? notification.text_key : bodyText}
</p>
{/* Boolean actions */}
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
<div className="flex gap-2 mt-2">
<button type="button"
<div className="flex gap-2 mt-2.5">
<button
type="button"
onClick={() => handleRespond('positive')}
disabled={responding || notification.response !== null}
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
disabled={responding || answered}
className={`${PILL} ${
notification.response === 'positive'
? 'bg-content text-surface'
: `bg-surface-tertiary ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content-secondary'}`
} ${notification.response !== null || responding ? 'cursor-default' : 'cursor-pointer'}`}
? 'bg-accent text-accent-text'
: `bg-surface-card shadow-sm ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
>
<Check className="w-3 h-3" />
<Check className="w-3.5 h-3.5" />
{t(notification.positive_text_key)}
</button>
<button type="button"
<button
type="button"
onClick={() => handleRespond('negative')}
disabled={responding || notification.response !== null}
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
disabled={responding || answered}
className={`${PILL} ${
notification.response === 'negative'
? 'bg-danger text-white'
: `bg-surface-tertiary ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content-secondary'}`
} ${notification.response !== null || responding ? 'cursor-default' : 'cursor-pointer'}`}
: `bg-surface-card shadow-sm ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
>
<X className="w-3 h-3" />
<X className="w-3.5 h-3.5" />
{t(notification.negative_text_key)}
</button>
</div>
@@ -125,12 +128,13 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
{/* Navigate action */}
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
<button type="button"
<button
type="button"
onClick={handleNavigate}
className="flex items-center gap-1 mt-2 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors bg-surface-tertiary text-content-secondary hover:bg-surface-hover focus-visible:bg-surface-hover"
className={`${PILL} mt-2.5 bg-surface-card shadow-sm text-content hover:bg-accent hover:text-accent-text focus-visible:bg-accent focus-visible:text-accent-text`}
>
<ArrowRight className="w-3 h-3" />
{t(notification.navigate_text_key)}
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
</button>
)}
</div>
+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(),
+29 -20
View File
@@ -15,15 +15,13 @@ import type { SaveState } from './useBookStore'
* which is exactly the wrong habit for the one time it says something else.
*/
export function SaveIndicator({
state, t, onAcceptTheirs, onKeepMine, onRetry, onReload = reloadStudio,
state, t, onAcceptTheirs, onKeepMine, onRetry,
}: {
state: SaveState
t: (k: string) => string
onAcceptTheirs: (current: BookRecord) => void
onKeepMine: (current: BookRecord) => void
onRetry: () => void
/** Load this Studio again, for a book a newer one saved. A full reload by default. */
onReload?: () => void
}) {
if (state.status === 'conflict') {
return (
@@ -45,23 +43,8 @@ export function SaveIndicator({
)
}
if (state.status === 'newer') {
/*
* The one state with a way out the user has to take themselves: a newer
* Studio saved this book, this tab cannot write it without losing what
* that one added, and only a reload brings the newer Studio here. Loud
* like a conflict, because until then nothing done here is kept.
*/
return (
<div className="st-save is-conflict" role="alert">
<AlertTriangle size={13} />
<span>{t('journey.studio.saveNewer')}</span>
<button type="button" onClick={onReload}>
{t('journey.studio.saveReload')}
</button>
</div>
)
}
// A book a newer Studio saved is the shell's to say, over everything (NewerBookOverlay).
if (state.status === 'newer') return null
if (state.status === 'readonly') {
/*
@@ -107,6 +90,32 @@ export function SaveIndicator({
return null
}
/**
* A book a newer Studio saved, said over the whole Studio rather than in the
* bar: this tab cannot write the book without losing what the newer one
* added, and only a reload brings the newer Studio here. Until then nothing
* done here would be kept, so nothing can be done: the overlay takes every
* click and offers the one way out there is.
*/
export function NewerBookOverlay({ t, onReload = reloadStudio }: {
t: (k: string) => string
onReload?: () => void
}) {
return (
<div className="st-newer" role="alertdialog" aria-modal="true" aria-labelledby="st-newer-text">
<div className="st-newer-card">
<span className="st-newer-icon"><RefreshCw size={22} strokeWidth={2.2} /></span>
<p id="st-newer-text" className="st-newer-text">{t('journey.studio.saveNewer')}</p>
{/* Focused on arrival, so Enter reloads and nothing behind the overlay takes the keyboard. */}
<button type="button" className="st-newer-reload" onClick={onReload} autoFocus>
<RefreshCw size={15} />
{t('journey.studio.saveReload')}
</button>
</div>
</div>
)
}
/** The default way back to a current Studio: the page again, which loads the newest build. */
function reloadStudio() {
window.location.reload()
+24 -3
View File
@@ -18,6 +18,12 @@ interface SpreadThumbProps {
className?: string;
/** Pixels per millimetre the caller draws at; CSS's own unless the caller zooms. */
pxPerMm?: number;
/**
* Draw the first and the last page beside the blank inside of their cover,
* as the bound book opens on them, so the rail shows why they stand alone.
* The words are the half's name, read out and shown on hover.
*/
coverInside?: string;
}
/**
@@ -37,16 +43,31 @@ export function SpreadThumb({
fold = false,
className,
pxPerMm = PX_PER_MM,
coverInside,
}: SpreadThumbProps) {
const single = spread.role !== 'inner';
const wMm = single ? page.pageWidth : page.pageWidth * 2;
// The first page opens on the right of the cover's inside, the last on the left of the back's.
const opens = coverInside && (spread.role === 'first' || spread.role === 'last') ? spread.role : null;
const wMm = single && !opens ? page.pageWidth : page.pageWidth * 2;
const scale = width / (wMm * pxPerMm);
const pageLeft = opens === 'first' ? page.pageWidth * pxPerMm * scale : 0;
return (
<div dir="ltr" className={className} style={{ width, height: page.pageHeight * pxPerMm * scale }}>
{opens && (
<span
className="st-thumb-inside"
title={coverInside}
aria-label={coverInside}
style={{
insetInlineStart: opens === 'first' ? 0 : page.pageWidth * pxPerMm * scale,
width: page.pageWidth * pxPerMm * scale,
}}
/>
)}
<div
style={{
position: 'absolute',
insetInlineStart: 0,
insetInlineStart: pageLeft,
top: 0,
width: `${wMm}mm`,
height: `${page.pageHeight}mm`,
@@ -56,7 +77,7 @@ export function SpreadThumb({
>
<SpreadView spread={spread} page={page} folios={folios} />
</div>
{fold && !single && <SpreadFold page={page} scaled={pxPerMm * scale} />}
{fold && (!single || opens) && <SpreadFold page={page} scaled={pxPerMm * scale} />}
</div>
);
}
@@ -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,380 @@
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. The tools on the right are grouped by what they do:
* who else is here and whether the book is saved, 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>
<div className="st-head-tools">
<PeerBadges peers={s.peers} t={s.t} />
{/* Whether the book is safe, as a tile beside who else is here: both say how the book stands. */}
<SaveIndicator
state={s.saveState}
t={s.t}
onAcceptTheirs={(current) => s.loadDoc(s.acceptTheirs(current))}
onKeepMine={s.keepMine}
onRetry={() => {
void s.saveNow();
}}
/>
<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>
)
}
+26 -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,17 @@ 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 { NewerBookOverlay } from './SaveIndicator'
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 +86,10 @@ 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 />
{/* A newer Studio saved this book: nothing done here would be kept, so the whole Studio waits for a reload. */}
{s.saveState.status === 'newer' && <NewerBookOverlay t={s.t} />}
{/* The journey gallery's picker, inside Studio's own layer (#2271). */}
{s.providerPick && s.journey && (
<ProviderPicker
@@ -132,7 +122,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 +154,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 +199,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)),
@@ -3,7 +3,9 @@ import type {
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
BookMetric, BookStatsElement, JourneyStats,
} from '@trek/shared'
import { BOOK_METRICS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch } from '@trek/shared'
import {
BOOK_BADGE_STYLES, BOOK_COUNTRY_LOOKS, BOOK_METRICS, BOOK_STATS_LOOKS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch,
} from '@trek/shared'
import { Swatches } from './StudioSwatches'
import { StudioFontPicker } from './StudioFontPicker'
import { WeightLine } from './StudioTypeControls'
@@ -547,6 +549,8 @@ function MapLook({ el, set, t }: Props<BookMapElement>) {
options={[
{ value: 'dot' as const, label: t('journey.studio.pinDot') },
{ value: 'photo' as const, label: t('journey.studio.pinPhoto') },
{ value: 'numbered' as const, label: t('journey.studio.pinNumbered') },
{ value: 'ends' as const, label: t('journey.studio.pinEnds') },
]}
onPick={pinStyle => set({ pinStyle } as Partial<BookElement>)}
/>
@@ -597,16 +601,26 @@ function StatsProps({ el, set, t }: Props<BookStatsElement>) {
/>
</Section>
<Section label={t('journey.studio.layout')}>
<Line label={t('journey.studio.layout')}>
{/* The look first: the arrangement below belongs to the classic one alone. */}
<Line label={t('journey.studio.look')} wide>
<Choice
value={el.layout}
options={(['grid', 'row', 'column'] as const).map(layout => ({
value: layout,
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
}))}
onPick={layout => set({ layout } as Partial<BookElement>)}
value={el.look ?? 'classic'}
options={BOOK_STATS_LOOKS.map(look => ({ value: look, label: t(`journey.studio.statsLook.${look}`) }))}
onPick={look => set({ look } as Partial<BookElement>)}
/>
</Line>
{(el.look ?? 'classic') === 'classic' && (
<Line label={t('journey.studio.layout')}>
<Choice
value={el.layout}
options={(['grid', 'row', 'column'] as const).map(layout => ({
value: layout,
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
}))}
onPick={layout => set({ layout } as Partial<BookElement>)}
/>
</Line>
)}
<Line label={t('journey.studio.units')}>
<Choice
value={el.units}
@@ -634,16 +648,25 @@ function StatsProps({ el, set, t }: Props<BookStatsElement>) {
function CountriesProps({ el, set, t }: Props<BookCountriesElement>) {
return (
<Section label={t('journey.studio.countries')}>
<Line label={t('journey.studio.layout')}>
<Line label={t('journey.studio.look')} wide>
<Choice
value={el.layout}
options={(['list', 'grid', 'column'] as const).map(layout => ({
value: layout,
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
}))}
onPick={layout => set({ layout } as Partial<BookElement>)}
value={el.look ?? 'classic'}
options={BOOK_COUNTRY_LOOKS.map(look => ({ value: look, label: t(`journey.studio.countryLook.${look}`) }))}
onPick={look => set({ look } as Partial<BookElement>)}
/>
</Line>
{(el.look ?? 'classic') === 'classic' && el.codes.length > 1 && (
<Line label={t('journey.studio.layout')}>
<Choice
value={el.layout}
options={(['list', 'grid', 'column'] as const).map(layout => ({
value: layout,
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
}))}
onPick={layout => set({ layout } as Partial<BookElement>)}
/>
</Line>
)}
{/* These three read as raw English out of the code before: `left`,
`center`, `right`, straight from the value. */}
<Line label={t('journey.studio.align')}>
@@ -734,7 +757,7 @@ function BadgeProps({ el, set, t }: Props<BookBadgeElement>) {
<Line label={t('journey.studio.style')} wide>
<Choice
value={el.style}
options={(['plain', 'chip', 'outline', 'stacked'] as const).map(style => ({
options={BOOK_BADGE_STYLES.map(style => ({
value: style, label: t(`journey.studio.markStyle.${style}`),
}))}
onPick={style => set({ style } as Partial<BookElement>)}
+256 -492
View File
@@ -1,18 +1,16 @@
import { useEffect, useRef, useState } from 'react'
import { Check, RouteOff } from 'lucide-react'
import type {
BookBadgeVariant, BookElement, BookMapScope, BookMetric, BookPageSetup, JourneyStats,
} from '@trek/shared'
import {
BOOK_METRICS, bookBadgeValue, bookCountryMark, bookCountryName, bookElementId as uid, bookTravelBadge,
bookTravelCountries, bookTravelMap, bookTravelSize, bookTravelStats,
} from '@trek/shared'
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import { Globe, Hash, Map as MapIcon, Stamp } from 'lucide-react'
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
import { bookElementId as uid } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
import { PanelHead, Shelf } from './StudioPanelHead'
import { studioTravelContext } from './studioTravelContext'
import { Section } from './StudioControls'
import { TravelPreview } from './TravelPreview'
import { StudioTabPanel, StudioTabs } from './StudioTabs'
import { fetchRoads } from './roadRoute'
import { TravelCard } from './travel/TravelCard'
import { TravelStops } from './travel/TravelStops'
import {
COUNTRY_LOOKS, FIGURE_SHELVES, MAP_CARDS, MARK_STYLES, MARK_VARIANTS, SUMMARY_LOOKS, travelBuilders,
} from './travel/travelBuilders'
/**
* The journey's own figures, as things you can put on a page.
@@ -22,90 +20,39 @@ import { fetchRoads } from './roadRoute'
* distance, the countries and the dates are already in TREK; the work is
* offering them as objects rather than as numbers someone has to retype.
*
* ── Why every tile is a real preview ──────────────────────────────────────
* Four tabs, the way the Elements panel sorts what it adds: the figures, the
* maps with the stops they are drawn from, the countries, and the marks. Every
* card is the element its click places, built by the book engine and drawn by
* the page's own renderer with this journey's numbers in it, in the newer
* looks and in the book's theme (travel/travelBuilders.ts). The looks books
* had before these still draw on the pages that hold them; they are no longer
* offered here.
*
* These elements differ from one another by what they *say*, not by their
* shape. Four map styles are four maps; three summary layouts are the same six
* numbers arranged three ways. A named row with the value beside it — which is
* what this panel was first — tells you none of that: you cannot tell the dark
* map from the paper one, or a row of figures from a grid of them, until you
* have placed one and undone it.
*
* So each tile builds the element it would place and renders it through the
* page's own renderer, with this journey's real numbers in it. You are picking
* from the things themselves.
*
* Every button resolves its values *now* and writes them into the element, so
* Every card resolves its values now and writes them into the element, so
* what lands on the page is finished rather than a placeholder that needs the
* server to mean anything. The reasoning is in TravelElements.tsx, and the
* short version is that the print renderer must never depend on a fetch.
* server to mean anything: the print renderer must never depend on a fetch.
*/
/**
* One row of the stops list: a point of the route or a stop switched off.
*
* `entryId` is null for a stop the server took straight from a trip's places,
* which is what a journey has before anyone wrote an entry. Such a stop has no
* switch, because there is no entry to put it on.
*/
type StopRow = {
entryId: number | null
label: string
date: string | null
country: string | null
excluded: boolean
}
type Tab = 'figures' | 'maps' | 'countries' | 'marks'
/*
* How many cards share a row.
*
* A stats panel is the width of a spread; shown two-up its figures scale down
* past reading, so it gets the row to itself. Maps, country lists and marks are
* compact enough that two-up still tells them apart, and two-up is what makes
* the section scannable. The tile measures its own width — see TravelPreview.
*/
/** The words a mark carries, as the book engine resolves them. */
type BadgeValue = NonNullable<ReturnType<typeof bookBadgeValue>>
const TABS: { id: Tab; icon: typeof Hash; labelKey: string }[] = [
{ id: 'figures', icon: Hash, labelKey: 'journey.studio.travelFigures' },
{ id: 'maps', icon: MapIcon, labelKey: 'journey.studio.travelMaps' },
{ id: 'countries', icon: Globe, labelKey: 'journey.studio.countries' },
{ id: 'marks', icon: Stamp, labelKey: 'journey.studio.marks' },
]
/*
* The two imagery maps, satellite first: the recommendation should not be the
* second thing read. Their tiles and credit are the book engine's own
* (BOOK_MAP_SOURCES), so a satellite map placed here is the one the assistant
* places.
* second thing read. Relief stops at about six hundred metres to the pixel, a
* smudge at country size; satellite is the one that prints for the page most
* people make, and the ring says so.
*/
const IMAGERY = [
{ id: 'satellite', labelKey: 'journey.studio.mapSourceSatellite' },
{ id: 'relief', labelKey: 'journey.studio.mapSourceRelief' },
] as const
function Card({ label, onClick, children, recommended = false, title }: {
label: string
onClick: () => void
/**
* Marked as the one to reach for, with a ring rather than with words.
*
* The label sits in a grid cell about eighty pixels wide, where "Satellite,
* recommended" would take both of its lines. A ring says the same thing in
* no space at all, and the tooltip carries the word for anyone who wonders
* why this card looks different.
*/
recommended?: boolean
title?: string
children: React.ReactNode
}) {
return (
<button type="button"
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
onClick={onClick}
title={title ? `${label}: ${title}` : label}
>
{children}
<span className="st-travel-card-label">{label}</span>
</button>
)
}
export function StudioTravelPanel({
page, stats, path, t, locale, canEdit, onToggleStop,
}: {
@@ -123,16 +70,16 @@ export function StudioTravelPanel({
const addElement = useStudioStore(s => s.addElement)
const updateElement = useStudioStore(s => s.updateElement)
const active = useStudioStore(s => s.activeSpread)
const doc = useStudioStore(s => s.doc)
const spread = doc?.spreads[active]
const single = !!spread && spread.role !== 'inner'
const role = useStudioStore(s => s.doc?.spreads[active]?.role)
const theme = useStudioStore(s => s.doc?.theme)
const [tab, setTab] = useState<Tab>('figures')
const tabsId = useId()
/*
* The road lookups still running when the panel goes away.
*
* fetchRoads walks the legs at a deliberate pace, so a twenty-stop map keeps
* asking a public router for minutes after Studio is closed, and the answer
* lands in whatever document the store holds by then.
* The road lookups still running when the panel goes away. fetchRoads walks
* the legs at a deliberate pace, so a twenty-stop map keeps asking a public
* router for minutes, and the answer would land in whatever document the
* store holds by then.
*/
const roadJobs = useRef(new Set<AbortController>())
useEffect(() => () => {
@@ -140,22 +87,6 @@ export function StudioTravelPanel({
roadJobs.current.clear()
}, [])
/*
* The stops whose switch is on its way to the server.
*
* No optimistic flip: which stop is furthest and which countries remain is
* the server's sum, so the row reads its state from the stats the hook
* fetches once the change has landed. Until then the chip is only held,
* so a second click cannot send the opposite of what the first one is
* still delivering.
*/
const [pending, setPending] = useState<Set<number>>(() => new Set())
const centre = (w: number, h: number) => {
const W = single ? page.pageWidth : page.pageWidth * 2
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
}
if (!stats) {
return (
<>
@@ -167,146 +98,17 @@ export function StudioTravelPanel({
)
}
/*
* What the book engine's factories read (studioTravelContext.ts). Every tile
* below is built by the factory the engine's `add` calls. Where it lands is
* the panel's own: centred across the spread, where `add` takes the right page.
*/
const travel = studioTravelContext(stats, path, locale, t)
const at = (size: { w: number; h: number }) => centre(size.w, size.h)
const build = travelBuilders({ page, stats, path, locale, t, theme, single: !!role && role !== 'inner' })
/**
* Country names in the reader's language, not the server's.
*
* The API answers in English, because it has no idea who will read the book.
* `Intl.DisplayNames` is the same CLDR data Atlas already uses for regions, so
* a German book says "Island" and an English one says "Iceland" — from one set
* of codes, with no translation table to keep in step.
*/
const countryName = (code: string): string => bookCountryName(code, locale, stats)
/*
* Each builder returns the finished element. The tile renders it and the
* click places it, so the preview and the result are the same object, and
* the object is the engine's, so there is no second description of what a
* card means.
*/
/** The linked trips that actually put stops on the route. */
const tripsWithRoute = (stats.trips ?? []).filter(t => t.points > 0)
/*
* The route as rows, counting and left-out stops together.
*
* Every point is listed, so the list reads as the route. The left-out
* stops sit among the counting ones in date order rather than in a list of
* their own, because the question the section answers is "which of these
* count?", and that is only answerable with all of them in one sequence.
* Undated stops go last, then by name.
*/
const stops: StopRow[] = [
...stats.points.map(p => ({
entryId: p.entryId, label: p.label, date: p.date, country: p.country, excluded: false,
})),
...(stats.excluded ?? []).map(x => ({
entryId: x.entryId, label: x.label, date: x.date, country: null, excluded: true,
})),
].sort((a, b) => {
if (a.date !== b.date) {
if (a.date == null) return 1
if (b.date == null) return -1
return a.date < b.date ? -1 : 1
}
return a.label.localeCompare(b.label)
})
const excludedCount = stops.filter(s => s.excluded).length
/** Whether the country is worth a badge, or the same word on every row. */
const manyCountries = stats.countries.length > 1
/**
* The day of a stop, without the weekday.
*
* `formatDate` is the journal's format and carries "Tue," because a journal
* entry is about a day somebody lived. A row here is about a leg of a route,
* and it has 236px to hold a place name in as well: the weekday costs a
* third of the badge and answers nothing anybody asks of this list.
*/
const stopDay = (iso: string) =>
new Date(`${iso}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
const toggleStop = async (entryId: number, excluded: boolean) => {
setPending(prev => new Set(prev).add(entryId))
// A refusal is the hook's to report; here it only frees the chip again.
await onToggleStop(entryId, excluded)
setPending(prev => {
const next = new Set(prev)
next.delete(entryId)
return next
})
}
/**
* One country, as the entry a list would have made of it.
*
* The list and the grid are gone: they were compositions somebody else made,
* deciding the order, the spacing and the type size, and the only way to
* change any of that was to not use them. What is left is the same entry —
* name over silhouette, set the same way — as an element you place one at a
* time. Three of them down a page is the list, with the spacing yours; one of
* them at 80mm is a chapter opener, which the list could never be.
*
* The name is editable afterwards, so numbering them "1. Germany",
* "2. Netherlands" is typing rather than a feature.
*/
const countryEl = (code: string): BookElement =>
bookTravelCountries(travel, { id: uid('co'), frame: at(bookTravelSize('countries', page)), codes: [code] })
/**
* A map as the engine's `add` places it, in the style the card offers.
*
* The scope is the journey or one trip. A trip map carries no recorded way:
* the trail is the journey's, and a map fitted to it would frame the whole
* journey around one trip and lose the bow its legs are drawn with.
*/
const mapEl = (
style: 'minimal' | 'outline' | 'dark' | 'paper',
scope: BookMapScope = 'journey',
source: 'vector' | 'relief' | 'satellite' = 'vector',
): BookElement => ({
...bookTravelMap(travel, stats, { id: uid('mp'), frame: at(bookTravelSize('map', page)), scope, source }),
style,
})
/** A mark at the size that suits what it holds, which is the engine's size for its variant. */
const badgeEl = (variant: BookBadgeVariant, value: BadgeValue): BookElement =>
bookTravelBadge({ id: uid('bd'), frame: at(bookTravelSize('badge', page, { variant })), variant, value })
/**
* One figure on its own.
*
* A summary panel is a composition; a single figure is a mark you drop next
* to a photograph. Both are the same element with a different metric list,
* and offering only the composition meant anyone wanting "14 DAYS" beside a
* picture had to place all four and delete three.
*/
const singleEl = (metric: BookMetric): BookElement =>
bookTravelStats(stats, { id: uid('st'), frame: at(bookTravelSize('stats', page)), metrics: [metric] })
/** Place a copy — the previewed element keeps its own id for React. */
/** Place a copy; the previewed element keeps its own id for React. */
const place = (el: BookElement) => {
const placed = { ...el, id: uid(el.kind[0]) } as BookElement
const placed = { ...el, id: uid(el.kind[0] ?? 'e') } as BookElement
addElement(active, placed)
/*
* A map arrives following the roads.
*
* Asked for after the element is on the page rather than before, so the map
* appears at once and the line firms up a moment later: a panel that sat
* there for fifteen seconds after a click would read as broken, and the
* straight line it starts with is the one it would have had anyway.
*
* Legs long enough to have been flights are never sent (see roadRoute.ts),
* the router's own cache answers a second placement of the same journey for
* free, and a failure leaves the leg exactly as it was.
* A map arrives following the roads, asked for after it is on the page so
* it appears at once and the line firms up a moment later. Legs long enough
* to have been flights are never sent (roadRoute.ts), and a failure leaves
* the leg as it was.
*/
if (placed.kind === 'map' && placed.points.length > 1) {
const job = new AbortController()
@@ -314,266 +116,228 @@ export function StudioTravelPanel({
void fetchRoads(placed.points.map(pt => ({ lat: pt.lat, lng: pt.lng })), { signal: job.signal })
.then(roads => {
if (job.signal.aborted) return
if (roads.some(r => r && r.length > 1)) {
updateElement(active, placed.id, { roads } as Partial<BookElement>)
}
if (roads.some(r => r && r.length > 1)) updateElement(active, placed.id, { roads } as Partial<BookElement>)
})
.catch(() => { /* No roads is a state the map already draws. */ })
.finally(() => { roadJobs.current.delete(job) })
}
}
/*
* The marks of the journey, with the words the engine resolves for each. A
* mark the journey cannot fill (no start date, no stop, no country) is not
* offered.
*/
const marks: { el: BookElement; label: string }[] = ([
['date', t('journey.studio.dateMark')],
['day', t('journey.studio.dayMark')],
['coords', t('journey.studio.coordsMark')],
['flag', t('journey.studio.flagMark')],
['distance', t('journey.studio.distanceMark')],
] as const).flatMap(([variant, label]) => {
const value = bookBadgeValue(travel, variant, null)
return value ? [{ el: badgeEl(variant, value), label }] : []
})
return (
<>
<PanelHead label={t('journey.studio.travel')} />
<div className="st-panel-scroll">
<Shelf label={t('journey.studio.singleFigures')}>
<div className="st-travel-grid">
{BOOK_METRICS.map(metric => {
const el = singleEl(metric)
return (
<Card key={metric} label={t(`journey.studio.metric.${metric}`)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={44} maxHeight={62} />
</Card>
)
})}
</div>
</Shelf>
{/*
The switch is here, beside the maps and the figures it changes, and
not only in the entry editor: the moment somebody notices the home
airport on the printed route is the moment they are looking at this
panel. The rows carry no preview because they are not things to
place; they are the input every tile below reads.
*/}
{stops.length > 0 && (
<Section
label={t('journey.studio.stops')}
hint={t('journey.studio.stopsHint')}
/*
* Folded to begin with, and saying how many count while it is.
*
* A fortnight is fourteen rows, which is longer than everything
* else in this panel put together, and most books never need to
* touch it. The figure in the head is what anyone opening it came
* to check anyway, so the closed section still answers the
* question and the maps below stay in reach.
*/
defaultOpen={false}
badge={`${stops.length - excludedCount}/${stops.length}`}
>
<ul className="st-route-stops">
{stops.map((stop, i) => {
const entryId = stop.entryId
const on = !stop.excluded
const state = t(on ? 'journey.studio.stopOn' : 'journey.studio.stopOff')
const chip = `st-chip is-pick is-icon ${on ? 'is-on' : ''}`
/*
* A mark rather than the word.
*
* "Counts" and "Left out" are different lengths, so a column of
* them jitters, and together they cost a third of a row that
* has a place name to fit. The tick and the crossed-out route
* say the same thing at a glance, and the word is still there
* for anyone who hovers or reads with a screen reader.
*/
const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} />
return (
<li key={entryId ?? `place-${i}`} className={`st-route-stop ${on ? '' : 'is-off'}`}>
{/*
One line: the day, then the name, then the switch. The
name and the day were stacked, which doubled the height
of a list whose whole job is being read down.
*/}
{stop.date && <span className="st-badge">{stopDay(stop.date)}</span>}
<span className="st-route-stop-label">{stop.label || t('journey.studio.untitled')}</span>
{/*
The country only where there is more than one to tell
apart. On a journey inside one country it is the same
word fourteen times, in the row that has least room.
*/}
{manyCountries && stop.country && (
<span className="st-badge is-quiet">{countryName(stop.country)}</span>
)}
{canEdit && entryId != null ? (
<button type="button"
className={chip}
title={`${state}: ${t('journey.studio.stopToggle')}`}
aria-label={t('journey.studio.stopToggle')}
aria-pressed={on}
disabled={pending.has(entryId)}
onClick={() => void toggleStop(entryId, !stop.excluded)}
>
{mark}
</button>
) : (
<span className={chip} title={state} aria-label={state}>{mark}</span>
)}
</li>
)
})}
</ul>
</Section>
)}
<Shelf label={t('journey.studio.routeMap')}>
{stats.points.length ? (
<>
<div className="st-travel-grid">
{(['minimal', 'outline', 'paper', 'dark'] as const).map(style => {
const el = mapEl(style)
return (
<Card key={style} label={t(`journey.studio.mapStyle.${style}`)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={62} maxHeight={80} />
</Card>
)
})}
{/*
The two imagery maps, as cards rather than as settings.
They were reachable only by placing an outline map and then
changing its source in the inspector, which is a thing nobody
finds. They are different pictures, not variants of one: relief
is land shaded in its own colours against a dark sea, drawn at
a scale that suits a continent, and satellite is the ground
itself, which is what a page about one country wants.
*/}
{IMAGERY.map(src => (
<Card
key={src.id}
/*
* Satellite is marked rather than merely listed. The three
* imagery options are not equals: relief stops at about six
* hundred metres to the pixel, which is a continent's worth
* of detail and a smudge at country size, and the outlines
* are a diagram. For the page most people are making —
* a route across one or two countries — this is the one
* that prints, and saying so beats letting them find out
* after the book is bound.
*/
label={t(src.labelKey)}
recommended={src.id === 'satellite'}
title={src.id === 'satellite' ? t('journey.studio.recommended') : undefined}
onClick={() => place(mapEl('minimal', 'journey', src.id))}
>
<TravelPreview
el={mapEl('minimal', 'journey', src.id)}
minHeight={62}
maxHeight={80}
/>
</Card>
))}
</div>
{/*
A map per trip, for a journey made of more than one.
Two trips printed as a single route draw a line from the last
stop of the first to the first stop of the second, which is
usually the longest leg on the page and one nobody travelled.
Offered rather than imposed: some journeys really are one route
across several trips, and those still get the map above.
Only trips that have stops on the route are listed — a linked
trip nobody wrote about would place an empty frame.
*/}
{tripsWithRoute.length > 1 && (
<div className="st-shelf-sub">
<h4 className="st-section-label">{t('journey.studio.mapPerTrip')}</h4>
<div className="st-travel-grid">
{tripsWithRoute.map(trip => {
const el = mapEl('minimal', { tripId: trip.id })
return (
<Card
key={trip.id}
label={trip.title || t('journey.studio.untitled')}
onClick={() => place(el)}
>
<TravelPreview el={el} minHeight={62} maxHeight={80} />
</Card>
)
})}
</div>
</div>
)}
</>
) : (
<p className="st-hint">{t('journey.studio.noRoute')}</p>
<StudioTabs<Tab>
idBase={tabsId}
label={t('journey.studio.travel')}
value={tab}
onChange={setTab}
columns={2}
tabs={TABS.map(x => ({ id: x.id, icon: x.icon, label: t(x.labelKey) }))}
/>
<StudioTabPanel idBase={tabsId} value={tab} className="st-tab-body">
<div className="st-panel-scroll">
{tab === 'figures' && <FiguresTab build={build} t={t} place={place} />}
{tab === 'maps' && (
<MapsTab build={build} stats={stats} t={t} place={place}>
<TravelStops
stats={stats} t={t} locale={locale} canEdit={canEdit}
onToggleStop={onToggleStop} countryName={build.countryName}
/>
</MapsTab>
)}
</Shelf>
<Shelf label={t('journey.studio.countries')}>
{stats.countries.length ? (
<>
{/*
One country per element, rather than a block listing them all.
A list of countries is a composition somebody else made: it
decides the order, the spacing and the type size, and the only
way to change any of it is to not use it. Placed one at a time
they are ordinary elements — numbered, moved, set at different
sizes, put on different pages — which is what a book of a trip
through several countries actually wants.
*/}
<div className="st-travel-grid">
{stats.countries.slice(0, 12).map(c => {
const el = countryEl(c.code)
return (
<Card key={c.code} label={countryName(c.code)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={44} maxHeight={64} />
</Card>
)
})}
</div>
{/*
And the same country as a mark: the outline beside the name
rather than under it, at the size of a line of type. It belongs
next to a date or a coordinate, not on a page of its own.
*/}
<div className="st-travel-grid" style={{ marginTop: 6 }}>
{stats.countries.slice(0, 12).map(c => {
const el = badgeEl('country', bookCountryMark('country', c.code, countryName(c.code)))
return (
<Card key={`mark-${c.code}`} label={countryName(c.code)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={30} maxHeight={44} />
</Card>
)
})}
</div>
</>
) : (
<p className="st-hint">{t('journey.studio.noCountries')}</p>
)}
</Shelf>
<Shelf label={t('journey.studio.marks')}>
<div className="st-travel-grid">
{marks.map(({ el, label }) => (
<Card key={el.id} label={label} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={34} maxHeight={52} />
</Card>
))}
</div>
</Shelf>
</div>
{tab === 'countries' && <CountriesTab build={build} stats={stats} t={t} place={place} />}
{tab === 'marks' && <MarksTab build={build} t={t} place={place} />}
</div>
</StudioTabPanel>
</>
)
}
type Build = ReturnType<typeof travelBuilders>
interface TabProps {
build: Build
t: (k: string) => string
place: (el: BookElement) => void
}
/** The summary in its four looks, full width, and every figure on its own as a medal. */
function FiguresTab({ build, t, place }: TabProps) {
return (
<>
<Shelf label={t('journey.studio.summary')}>
<div className="st-travel-grid is-wide">
{SUMMARY_LOOKS.map(look => (
<TravelCard
key={look}
el={build.summaryEl(look)}
label={t(`journey.studio.statsLook.${look}`)}
onPlace={place}
minHeight={look === 'band' ? 30 : 70}
maxHeight={look === 'band' ? 40 : 120}
inset={look === 'note' || look === 'sketch' ? 0.05 : 0}
/>
))}
</div>
</Shelf>
{/* Each figure on its own, in the looks that suit a single figure: a medal, an odometer, a ring drawn by hand. */}
{FIGURE_SHELVES.map(({ look, metrics }) => (
<Shelf key={look} label={`${t('journey.studio.singleFigures')}: ${t(`journey.studio.statsLook.${look}`)}`}>
<div className={`st-travel-grid ${look === 'odometer' ? 'is-wide' : ''}`}>
{metrics.map(metric => (
<TravelCard
key={metric}
el={build.figureEl(metric, look)}
label={t(`journey.studio.metric.${metric}`)}
onPlace={place}
minHeight={look === 'odometer' ? 50 : 80}
maxHeight={look === 'odometer' ? 64 : 104}
inset={look === 'odometer' ? 0 : 0.06}
/>
))}
</div>
</Shelf>
))}
</>
)
}
/**
* The route: drawn with its photographs, its stops numbered, its two ends
* marked or on a dark ground, then over satellite and relief imagery; a map
* per trip for a journey of several; and the stops it is drawn from.
*/
function MapsTab({ build, stats, t, place, children }: TabProps & { stats: JourneyStats; children: ReactNode }) {
if (!stats.points.length) return <p className="st-hint">{t('journey.studio.noRoute')}</p>
// Only the trips that put stops on the route: a linked trip nobody wrote about would place an empty frame.
const trips = (stats.trips ?? []).filter(trip => trip.points > 0)
return (
<>
<Shelf label={t('journey.studio.routeMap')}>
<div className="st-travel-grid">
{MAP_CARDS.map(card => (
<TravelCard key={card.id} el={build.mapEl(card)} label={t(card.labelKey)} onPlace={place} minHeight={70} maxHeight={92} />
))}
{IMAGERY.map(src => (
<TravelCard
key={src.id}
el={build.mapEl({ style: 'minimal' }, 'journey', src.id)}
label={t(src.labelKey)}
onPlace={place}
minHeight={70}
maxHeight={92}
recommended={src.id === 'satellite'}
title={src.id === 'satellite' ? t('journey.studio.recommended') : undefined}
/>
))}
</div>
{/*
A map per trip: two trips printed as one route draw a line from the
last stop of one to the first of the next, a leg nobody travelled.
*/}
{trips.length > 1 && (
<div className="st-shelf-sub">
<h4 className="st-section-label">{t('journey.studio.mapPerTrip')}</h4>
<div className="st-travel-grid">
{trips.map(trip => (
<TravelCard
key={trip.id}
el={build.mapEl({ style: 'minimal', pins: 'numbered' }, { tripId: trip.id })}
label={trip.title || t('journey.studio.untitled')}
onPlace={place}
minHeight={70}
maxHeight={92}
/>
))}
</div>
</div>
)}
</Shelf>
{/* Each country on its own, its stops alone, cut to its shape or in a box. */}
{stats.countries.slice(0, 6).map(c => (
<Shelf key={c.code} label={build.countryName(c.code)}>
<div className="st-travel-grid">
{COUNTRY_MAPS.map(m => (
<TravelCard
key={m.kind}
el={build.countryMapEl(c.code, m.kind)}
label={t(m.labelKey)}
onPlace={place}
minHeight={80}
maxHeight={104}
inset={m.kind === 'box' ? 0 : 0.04}
/>
))}
</div>
</Shelf>
))}
{children}
</>
)
}
/** The maps of one country: cut to its shape on the paper, cut to it over the satellite picture, and in a box. */
const COUNTRY_MAPS = [
{ kind: 'shape', labelKey: 'journey.studio.mapClipCountry' },
{ kind: 'satellite', labelKey: 'journey.studio.mapSourceSatellite' },
{ kind: 'box', labelKey: 'journey.studio.mapClipRect' },
] as const
/** Each country in every look, one shelf per country: a stamp, a visa, a postcard, its flag, a pin, a label, a sketch. */
function CountriesTab({ build, stats, t, place }: TabProps & { stats: JourneyStats }) {
if (!stats.countries.length) return <p className="st-hint">{t('journey.studio.noCountries')}</p>
return (
<>
{stats.countries.slice(0, 12).map(c => (
<Shelf key={c.code} label={build.countryName(c.code)}>
<div className="st-travel-grid">
{COUNTRY_LOOKS.map(look => (
<TravelCard
key={look}
el={build.countryEl(c.code, look)}
label={t(`journey.studio.countryLook.${look}`)}
onPlace={place}
minHeight={84}
maxHeight={100}
inset={look === 'postcard' ? 0.04 : 0.09}
/>
))}
</div>
</Shelf>
))}
</>
)
}
/** The marks the journey fills, as stamps, tags and pills. A mark it cannot fill is not offered. */
function MarksTab({ build, t, place }: TabProps) {
const label = (variant: (typeof MARK_VARIANTS)[number]) => t(`journey.studio.${variant}Mark`)
return (
<>
{MARK_STYLES.map(({ style, variants }) => {
const cards = variants.flatMap(variant => {
const el = build.markEl(variant, style)
return el ? [{ el, variant }] : []
})
if (!cards.length) return null
return (
<Shelf key={style} label={t(`journey.studio.markStyle.${style}`)}>
<div className="st-travel-grid">
{cards.map(({ el, variant }) => (
<TravelCard
key={variant}
el={el}
label={label(variant)}
onPlace={place}
minHeight={style === 'stamp' ? 56 : 38}
maxHeight={style === 'stamp' ? 76 : 54}
inset={style === 'stamp' || style === 'sticker' ? 0.12 : 0.08}
/>
))}
</div>
</Shelf>
)
})}
</>
)
}
@@ -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>
+43 -222
View File
@@ -1,20 +1,21 @@
import type { CSSProperties } from 'react'
import type {
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
BookMetric, BookStatsElement, BookUnits,
BookStatsElement,
} from '@trek/shared'
import {
Camera, CalendarDays, Footprints, Globe, MapPin, Navigation, Route,
type LucideIcon,
} from 'lucide-react'
import { fontStack, lighterWeight, nearestWeight } from './bookFonts'
import { fontStack } from './bookFonts'
import { heavy, inkAt as rgba, light, METRIC_ICONS, metricValue, round2 } from './travel/travelInk'
import { BadgeLookView } from './travel/BadgeLooks'
import { CountryLookView } from './travel/CountryLooks'
import { StatsLookView } from './travel/StatsLooks'
import { badgeIconShare, badgeInk, inkOn, isBadgeLook } from './travel/badgeLook'
import { BadgeIcon } from './travel/BadgeIcon'
import { EndMarker } from './travel/EndMarker'
import { FlagMark } from './travel/FlagMark'
import { haversineKm } from '../../utils/geo'
import { brightness } from './folioColour'
import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes'
import { projectOntoTiles, tileView, usableStaticUrl } from './mapTiles'
import { useCartoApiKey } from '../../hooks/useTileUrl'
import { FLAG_H, FLAG_W, flagBands, flagDisc, flagSpec } from './flags'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { useTranslation } from '../../i18n'
/**
@@ -71,8 +72,6 @@ function typeSize(el: TravelElement, base: number): number {
return Math.max(1.2, el.frame.h * base * el.textScale)
}
/** Two decimals of a millimetre, matching what the document stores. */
const round2 = (n: number) => Math.round(n * 100) / 100
/** Tile geometry, kept finer than the rest: see the note where it is used. */
const round3 = (n: number) => Math.round(n * 1000) / 1000
@@ -100,12 +99,6 @@ const round3 = (n: number) => Math.round(n * 1000) / 1000
*/
const tileOverlapMm = (size: number) => Math.max(0.8, size * 0.04)
/** #rrggbb plus an alpha. */
function rgba(hex: string, alpha: number): string {
const n = Number.parseInt(hex.slice(1), 16)
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
}
export function TravelElementView({ el, frameStyle, big = false }: {
el: BookElement
frameStyle: CSSProperties
@@ -113,9 +106,22 @@ export function TravelElementView({ el, frameStyle, big = false }: {
big?: boolean
}) {
if (el.kind === 'map') return <MapView el={el} frameStyle={frameStyle} big={big} />
if (el.kind === 'stats') return <StatsView el={el} frameStyle={frameStyle} />
if (el.kind === 'countries') return <CountriesView el={el} frameStyle={frameStyle} />
if (el.kind === 'badge') return <BadgeView el={el} frameStyle={frameStyle} />
// The newer looks draw in files of their own; `classic` is what every book had before them.
if (el.kind === 'stats') {
return (el.look ?? 'classic') === 'classic'
? <StatsView el={el} frameStyle={frameStyle} />
: <StatsLookView el={el} frameStyle={frameStyle} />
}
if (el.kind === 'countries') {
return (el.look ?? 'classic') === 'classic'
? <CountriesView el={el} frameStyle={frameStyle} />
: <CountryLookView el={el} frameStyle={frameStyle} />
}
if (el.kind === 'badge') {
return isBadgeLook(el.style)
? <BadgeLookView el={el} frameStyle={frameStyle} />
: <BadgeView el={el} frameStyle={frameStyle} />
}
if (el.kind === 'list') return <ListView el={el} frameStyle={frameStyle} />
return null
}
@@ -132,17 +138,6 @@ export function TravelElementView({ el, frameStyle, big = false }: {
* a slab of ink for no reason. What distinguishes the four styles is how the
* land is drawn, which is enough.
*/
/**
* How heavy the type is set, and one step down for the labels under it.
*
* These elements draw at two levels at once — a figure over its caption, a
* country's name over its outline — and the pair has to move together, the way
* `textScale` moves their size. Hard-coded 700s meant the only way to lighten a
* stats block was to not use one.
*/
const heavy = (el: { font: string; weight: number }) => nearestWeight(el.font, el.weight)
const light = (el: { font: string; weight: number }) => lighterWeight(el.font, el.weight)
const MAP_PALETTES = {
minimal: { land: '#e9e5dc', border: '#d6d0c4', onDark: false },
outline: { land: 'transparent', border: '#b9b3a6', onDark: false },
@@ -160,28 +155,6 @@ const MAP_PALETTES = {
*/
const DEFAULT_ACCENT = '#111111' // theme-lint-disable — the contract's default, matched here
/** Ink for a mark that picks its own, and its opposite. */
const BADGE_DARK_INK = '#1c1b19' // theme-lint-disable — ink on a printed page, not a UI token
const BADGE_LIGHT_INK = '#ffffff' // theme-lint-disable — paper white on a filled chip
/**
* What colour a mark's words are.
*
* Automatic unless the element says otherwise, and what automatic means depends
* on what the mark is: a chip carries its words on its own fill, so they answer
* to the fill's brightness; a day or a date drawn plain takes the accent,
* because that is the figure the page is built around. Anything else is
* ordinary text in the text colour.
*
* The moment somebody picks a colour this steps aside completely. That is the
* point of the flag: automatic is a good default and a bad cage.
*/
function badgeInk(el: BookBadgeElement): string {
if (!el.autoColor) return el.color
if (el.style === 'chip') return brightness(el.accent) > 0.55 ? BADGE_DARK_INK : BADGE_LIGHT_INK
if (el.variant === 'day' || el.variant === 'date') return el.accent
return el.color
}
function routeInk(accent: string, onDark: boolean): string {
if (!onDark) return accent
return accent.toLowerCase() === DEFAULT_ACCENT ? '#f2efe9' : accent // theme-lint-disable — paper on a dark map
@@ -512,6 +485,8 @@ function MapView({ el, frameStyle, big = false }: {
const S = Math.min(W, H)
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
const drawn = el.routeStyle === 'drawn'
// Numbered stops and marked ends are markers of the drawn kind, whatever the line is drawn as.
const drawnPins = drawn || el.pinStyle === 'numbered' || el.pinStyle === 'ends'
const coreW = clamp(S * 0.0055, 0.32, 1.3)
const casingW = coreW * 1.9
const beadR = clamp(coreW * 3.1, 1.7, 4.8)
@@ -528,7 +503,7 @@ function MapView({ el, frameStyle, big = false }: {
* so it inverts with it: dark on a light line, light on a dark one. Measured
* the same way the marks decide their ink.
*/
const numInk = brightness(el.accent) > 0.55 ? '#1c1b19' : '#ffffff' // theme-lint-disable — print ink
const numInk = inkOn(el.accent)
const dashOn = coreW * 2.4
const dashOff = coreW * 1.8
@@ -748,16 +723,16 @@ function MapView({ el, frameStyle, big = false }: {
*/
const beads: { i: number; x: number; y: number; photo: number | null }[] = []
let numbered = false
if (drawn) {
if (drawnPins) {
// Too small to carry photographs at all: a bead wider than a twelfth of the
// frame is a picture with a map around it.
const roomForPhotos = el.pinStyle === 'photo' && big && beadR * 2 <= S * 0.16
/*
* Numbers stand in for the photographs that are not there, so they belong
* to the photo treatment alone. A map asked for dots gets dots, and a
* thumbnail gets dots too: two digits at 130px is a smudge.
* Numbers stand in for the photographs that are not there, and a map asked
* for numbers numbers every stop. A map asked for dots gets dots, and a
* photo map's thumbnail gets dots too: two digits at 130px is a smudge.
*/
numbered = el.pinStyle === 'photo' && big && numR * 2 <= S * 0.12
numbered = (el.pinStyle === 'numbered' || (el.pinStyle === 'photo' && big)) && numR * 2 <= S * 0.12
let last: { x: number; y: number } | null = null
route.forEach((pt, i) => {
const photoId = (el.points[i] as { photoId?: number | null } | undefined)?.photoId ?? null
@@ -979,7 +954,7 @@ function MapView({ el, frameStyle, big = false }: {
</>
)}
{el.showPins && !drawn && route.map((p, i) => (
{el.showPins && !drawnPins && route.map((p, i) => (
<circle
key={i}
cx={p.x}
@@ -1003,8 +978,10 @@ function MapView({ el, frameStyle, big = false }: {
is also what a photograph that fails to load degrades to, so the paper
shows a deliberate marker rather than a hole.
*/}
{el.showPins && drawn && beads.map(m => (
m.photo != null ? (
{el.showPins && drawnPins && beads.map(m => (
el.pinStyle === 'ends' && (m.i === 0 || m.i === route.length - 1) ? (
<EndMarker key={m.i} x={m.x} y={m.y} r={numR} finish={m.i > 0} ink={coreInk} paper={numInk} />
) : m.photo != null ? (
<g key={m.i}>
{/* The shadow, as two translucent discs rather than a filter. */}
<circle cx={m.x} cy={m.y + coreW * 0.3} r={beadR + coreW * 0.55} fill="#000000" fillOpacity={0.1} />
@@ -1127,49 +1104,6 @@ function MapView({ el, frameStyle, big = false }: {
/* ── Stats ────────────────────────────────────────────────────────────── */
/**
* The icon beside each figure.
*
* lucide, like every other icon in TREK — a hand-drawn set was the first pass
* and it was the wrong call twice over: it put a second drawing vocabulary next
* to the one the whole app already uses, and it meant seven paths nobody would
* ever revisit when lucide gained a better glyph.
*
* They size in millimetres despite lucide taking a pixel `size`, because an
* inline width and height override the attributes it writes while the viewBox
* stays 24×24 — so the stroke scales with the drawing instead of thinning out
* on a large panel.
*/
const METRIC_ICONS: Record<BookMetric, LucideIcon> = {
distance: Route,
days: CalendarDays,
steps: Footprints,
photos: Camera,
countries: Globe,
places: MapPin,
furthest: Navigation,
}
function metricValue(
metric: BookMetric,
values: Record<string, number>,
units: BookUnits,
locale: string,
): { value: string; unit: string } {
const raw = values[metric] ?? 0
const n = (v: number, digits = 0) =>
new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(v)
if (metric === 'distance' || metric === 'furthest') {
// Stored in metres and converted here, so switching units never rounds a
// second time on top of a rounded number.
const km = raw / 1000
const shown = units === 'imperial' ? km * 0.621371 : km
return { value: n(shown, shown < 100 ? 1 : 0), unit: units === 'imperial' ? 'mi' : 'km' }
}
return { value: n(raw), unit: '' }
}
/**
* The trip summary — the page Polarsteps' books open on, and the one that makes
* a book feel like a record of a journey rather than a folder of pictures.
@@ -1404,75 +1338,6 @@ function CountriesView({ el, frameStyle }: { el: BookCountriesElement; frameStyl
)
}
/**
* A flag, drawn.
*
* The first version set a regional-indicator pair and let the platform draw it.
* That is the standard trick and it is wrong for most of the people who will
* use this: Chrome on Windows ships no flag glyphs, so it renders the two
* letters, and "DE" printed in a photo book is not a flag.
*
* So flags are constructed from `flags.ts` — exact for the stripe, cross and
* disc constructions listed there, and absent for anything with an emblem
* rather than approximated. A country with no construction falls back to its
* silhouette, which is correct for all 198 of them.
*/
function FlagMark({ code, size }: { code: string; size: number }) {
const spec = flagSpec(code)
const height = size
const width = (height * FLAG_W) / FLAG_H
if (spec) {
const disc = flagDisc(spec)
return (
<svg
width={`${round2(width)}mm`}
height={`${round2(height)}mm`}
viewBox={`0 0 ${FLAG_W} ${FLAG_H}`}
style={{ display: 'block', flex: '0 0 auto', borderRadius: `${round2(height * 0.08)}mm` }}
aria-label={code.toUpperCase()}
>
{flagBands(spec).map((b, i) => (
<rect key={i} x={b.x} y={b.y} width={b.w} height={b.h} fill={b.fill} />
))}
{disc && <circle cx={disc.cx} cy={disc.cy} r={disc.r} fill={disc.fill} />}
{/* A hairline keeps a white-edged flag off a white page. */}
<rect x="0" y="0" width={FLAG_W} height={FLAG_H} fill="none" stroke="rgba(0,0,0,.18)" strokeWidth="0.5" />
</svg>
)
}
const shape = COUNTRY_SHAPES[code.toUpperCase()]
if (!shape) {
// Neither a construction nor an outline: the code, set as a mark rather
// than as two stray capitals.
return (
<span
style={{
fontSize: `${round2(size * 0.62)}mm`,
fontWeight: 700,
letterSpacing: '0.08em',
lineHeight: 1,
display: 'inline-block',
}}
>
{code.toUpperCase()}
</span>
)
}
return (
<svg
width={`${round2((height * shape.w) / shape.h)}mm`}
height={`${round2(height)}mm`}
viewBox={`0 0 ${shape.w} ${shape.h}`}
style={{ display: 'block', flex: '0 0 auto' }}
aria-label={code.toUpperCase()}
>
<path d={shape.d} fill="currentColor" />
</svg>
)
}
/* ── Badges ───────────────────────────────────────────────────────────── */
/**
@@ -1495,7 +1360,6 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
*/
const big = typeSize(el, el.variant === 'date' ? 0.52 : 0.42)
const small = typeSize(el, 0.2)
const shape = el.code ? COUNTRY_SHAPES[el.code.toUpperCase()] : null
const stacked = el.style === 'stacked' || el.variant === 'date'
const chip = el.style === 'chip'
@@ -1511,7 +1375,6 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
*/
const showIcon = el.showIcon !== false
const showLabel = el.showLabel !== false
const iconTint = (automatic: string) => (el.autoIconColor === false ? el.iconColor : automatic)
/*
* A mark with no words is a pictogram, and a pictogram should fill its box.
* Beside a label the icon is a share of the height; on its own it takes four
@@ -1544,54 +1407,12 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
overflow: 'hidden',
}}
>
{el.variant === 'flag' && el.code && showIcon && (
<FlagMark code={el.code} size={el.frame.h * (showLabel ? 0.5 : iconScale)} />
)}
{/*
Mood and weather come from the journal entry, and they are drawn with
the *same* icon and the same palette the journey page uses — imported
rather than re-listed, so a mood added to TREK appears here without
anyone remembering to add it twice.
The picture: a flag, an outline, or the journal's own mood and weather
icons, imported rather than re-listed so a mood added to TREK appears
here without anyone adding it twice.
*/}
{(el.variant === 'mood' || el.variant === 'weather') && el.code && showIcon && (() => {
// Kept apart rather than narrowed out of one union: only a mood has a
// colour of its own, and `'text' in config` types it as unknown.
const mood = el.variant === 'mood' ? MOOD_CONFIG[el.code] : null
const config = mood ?? WEATHER_CONFIG[el.code]
if (!config) return null
const Icon = config.icon
const size = el.frame.h * iconScale
/*
* Automatic is the journal's own palette for a mood and the element's
* accent for the weather. Note the colour has to be passed rather than
* inherited: lucide writes it onto the SVG's stroke, so `currentColor`
* from the wrapper never reaches it — which is why the Colour swatch
* appeared to do nothing to a mood icon.
*/
const tint = iconTint(mood ? mood.text : el.accent)
return (
<Icon
color={tint}
strokeWidth={1.7}
style={{ width: `${round2(size)}mm`, height: `${round2(size)}mm`, display: 'block', flex: '0 0 auto' }}
/>
)
})()}
{el.variant === 'country' && shape && showIcon && (() => {
const h = el.frame.h * (showLabel ? 0.72 : iconScale)
return (
<svg
width={`${round2((h * shape.w) / shape.h)}mm`}
height={`${round2(h)}mm`}
viewBox={`0 0 ${shape.w} ${shape.h}`}
style={{ display: 'block', flex: '0 0 auto' }}
>
<path d={shape.d} fill={iconTint(el.accent)} />
</svg>
)
})()}
{showIcon && <BadgeIcon el={el} height={el.frame.h * (showLabel ? badgeIconShare(el.variant) : iconScale)} />}
{el.text && showLabel && (
<span

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