diff --git a/client/e2e/help/atlas.guide.ts b/client/e2e/help/atlas.guide.ts index cbb283452..36963fbc5 100644 --- a/client/e2e/help/atlas.guide.ts +++ b/client/e2e/help/atlas.guide.ts @@ -42,10 +42,17 @@ const searchBox = (page: Page) => searchInput(page).locator('xpath=ancestor::div const countryHit = (page: Page, name: string) => searchBox(page).getByRole('button', { name: new RegExp(`^[A-Z]{2} ${name}$`) }) /** A geocoded place in the dropdown, under the Places heading. */ const placeHit = (page: Page, text: string) => searchBox(page).getByRole('button', { name: new RegExp(text) }).first() -/** The country / region popup card. */ -const popup = (page: Page) => page.locator('div[role="presentation"] > div[role="presentation"]') +/** + * The country / region popup: the panel of the shared dialog frame, inside + * the backdrop `modal` finds. The Dawarich dialog uses the same frame, but the + * two are never open at once. + */ +const popup = (page: Page) => modal(page).getByRole('dialog') /** The glass panel at the bottom. */ const panel = (page: Page) => page.getByRole('button', { name: 'Stats' }).locator('xpath=ancestor::div[2]') +/** The continent counts in the Stats tab, and the streak with this year's trips right of them. */ +const continentStats = (page: Page) => panel(page).locator('div.flex.items-center.gap-4').filter({ hasText: 'Europe' }).first() +const highlightStats = (page: Page) => panel(page).locator('div.flex.items-center.gap-5').first() /** The detail card the panel grows for a country picked in the search. */ const detailCard = (page: Page, country: string) => panel(page).locator('p.text-sm.font-bold').filter({ hasText: country }).locator('xpath=../..') @@ -84,13 +91,21 @@ async function pickCountry(page: Page, name: string): Promise { await settle(page) } -/** Close the country popup by clicking beside it (there is no Escape). */ +/** + * Close the country popup with the button in its head band. Not with Escape: + * after a key press the browser draws its focus ring on the next popup, which + * takes the focus as it opens, and that ring would be in the picture. + */ async function closePopup(page: Page): Promise { - await page.mouse.click(40, VIEWPORT.height - 40) + await popup(page).getByRole('button', { name: 'Close', exact: true }).click() await expect(popup(page)).toHaveCount(0) } -/** The next unset select in a scope: month and year both read “—” until picked. */ +/** + * The next unset select of the Add place form in the Bucket List tab: month + * and year both read a long dash until picked. The popup's bucket step labels + * its two selects instead, so there they are found by Month and Year. + */ const unsetSelect = (scope: Locator) => scope.getByRole('button', { name: '—', exact: true }).first() /** Pick an option in one of the month / year selects. */ @@ -136,6 +151,63 @@ async function clickMapCentre(page: Page): Promise { await page.mouse.click(c.x, c.y) } +type Box = { x: number; y: number; width: number; height: number } + +/** + * An invisible box laid over several elements, for a step whose text names + * things that have no parent of their own to ring. The runner rings whatever + * box the target has, so this gives it one. It takes no pointer events, so the + * hover and the click the step makes go to the app underneath, and a step + * that uses it needs its own `hover` for the same reason. + */ +async function spanOver(page: Page, boxes: Box[]): Promise { + await page.evaluate(boxes => { + document.getElementById('trek-help-span')?.remove() + const left = Math.min(...boxes.map(b => b.x)) + const top = Math.min(...boxes.map(b => b.y)) + const right = Math.max(...boxes.map(b => b.x + b.width)) + const bottom = Math.max(...boxes.map(b => b.y + b.height)) + const el = document.createElement('div') + el.id = 'trek-help-span' + Object.assign(el.style, { + position: 'fixed', + left: `${left}px`, + top: `${top}px`, + width: `${right - left}px`, + height: `${bottom - top}px`, + pointerEvents: 'none', + }) + document.body.appendChild(el) + }, boxes) +} +const span = (page: Page) => page.locator('#trek-help-span') +async function clearSpan(page: Page): Promise { + await page.evaluate(() => document.getElementById('trek-help-span')?.remove()) +} + +async function boxOf(locator: Locator): Promise { + const box = await locator.boundingBox() + if (!box) throw new Error(`no box for ${locator}`) + return box +} + +/** + * The name the Atlas shows beside the pointer over a region. It is one fixed + * div the map moves after the pointer, with no role or name of its own; the + * country under the region name is how it is told apart. + */ +async function regionTooltipBox(page: Page, country: string): Promise { + const box = await page.evaluate(country => { + const tip = Array.from(document.querySelectorAll('div')).find(d => + d.style.position === 'fixed' && d.style.pointerEvents === 'none' && d.style.display === 'block' && (d.textContent ?? '').includes(country)) + if (!tip) return null + const r = tip.getBoundingClientRect() + return { x: r.x, y: r.y, width: r.width, height: r.height } + }, country) + if (!box) throw new Error(`no region tooltip naming ${country} on screen`) + return box +} + const SCRIPTS: Record = { 'mark-country': { guide: guide('mark-country'), @@ -199,6 +271,13 @@ const SCRIPTS: Record = { act: async p => { await pickCountry(p, 'Japan') await expect(detailCard(p, 'Japan')).toBeVisible() + // The step promises the card's flag, which is a Twemoji image from a + // CDN. Waited for here so the result shows the flag rather than an + // empty square; should the CDN be out of reach, the card falls back to + // two letters and the image never loads, which fails this step. + const flag = detailCard(p, 'Japan').locator('img[alt="JP"]') + await expect(flag).toBeVisible({ timeout: 15_000 }) + await expect.poll(() => flag.evaluate(img => (img as HTMLImageElement).complete && (img as HTMLImageElement).naturalWidth > 0), { timeout: 15_000 }).toBe(true) }, }, ], @@ -243,9 +322,18 @@ const SCRIPTS: Record = { }, }, { - target: centreRegion, + // The text says hovering names the region. That name floats right of + // the pointer and reaches past the region's own box, so a ring round + // the region alone ran straight through it. The ring goes round both. + prepare: async p => { + await hoverMapCentre(p) + await settle(p) + await spanOver(p, [await boxOf(centreRegion(p)), await regionTooltipBox(p, 'Austria')]) + }, + target: span, hover: hoverMapCentre, act: async p => { + await clearSpan(p) await clickMapCentre(p) await expect(popup(p).getByRole('button', { name: /Mark as visited/ })).toBeVisible() }, @@ -314,9 +402,10 @@ const SCRIPTS: Record = { }, { prepare: async p => { - await choose(p, unsetSelect(popup(p)), 'June') - await choose(p, unsetSelect(popup(p)), String(year(1))) + await choose(p, popup(p).getByLabel('Month', { exact: true }), 'June') + await choose(p, popup(p).getByLabel('Year', { exact: true }), String(year(1))) }, + // The whole popup: the step names both selects and the button under them. target: p => popup(p), act: async p => { await popup(p).getByRole('button', { name: 'Add to bucket list', exact: true }).click() @@ -342,7 +431,9 @@ const SCRIPTS: Record = { act: async p => { await p.getByRole('button', { name: 'Add place' }).click() }, }, { - target: p => p.getByPlaceholder('Name (country, city, place...)'), + // The row, not the field alone: the text has the reader press the + // search button beside it as well. + target: p => p.getByPlaceholder('Name (country, city, place...)').locator('xpath=..'), act: async p => { const input = p.getByPlaceholder('Name (country, city, place...)') await typeInto(p, input, 'Petra') @@ -359,7 +450,10 @@ const SCRIPTS: Record = { await choose(p, unsetSelect(panel(p)), 'Sep') await choose(p, unsetSelect(panel(p)), String(year(1))) }, - target: p => p.getByRole('button', { name: 'Add', exact: true }), + // The whole form: framed on the small Add button alone, the picture lost + // the name and the month, and looked like Add on an empty form. + target: p => p.getByRole('button', { name: 'Add', exact: true }).locator('xpath=../..'), + hover: p => p.getByRole('button', { name: 'Add', exact: true }).hover(), act: async p => { await p.getByRole('button', { name: 'Add', exact: true }).click() await expect(panel(p).getByText('Petra').first()).toBeVisible() @@ -374,7 +468,17 @@ const SCRIPTS: Record = { start: openAtlas, steps: [ { target: p => panel(p).locator('.rounded-xl').filter({ hasText: 'Countries' }).first() }, - { target: p => panel(p).locator('.flex.items-center.gap-4').first() }, + { + // The text covers the continents, the streak and this year's trips. + // Those are two groups side by side with no box of their own around + // both, so the ring gets one laid over them. + prepare: async p => { + await spanOver(p, [await boxOf(continentStats(p)), await boxOf(highlightStats(p))]) + }, + target: span, + hover: p => continentStats(p).hover(), + act: clearSpan, + }, ], }, @@ -394,7 +498,11 @@ const SCRIPTS: Record = { }, }, { - target: p => modal(p).getByRole('button', { name: 'Look for countries' }), + // The pitch with its button rather than the button alone: a frame + // centred on the button cut off the dialog's head band, and with it the + // name of the service this dialog asks. + target: p => modal(p).getByRole('button', { name: 'Look for countries' }).locator('xpath=..'), + hover: p => modal(p).getByRole('button', { name: 'Look for countries' }).hover(), act: async p => { await modal(p).getByRole('button', { name: 'Look for countries' }).click() // A year in thirteen 30-day reads, one after another, each of which @@ -408,11 +516,18 @@ const SCRIPTS: Record = { }, }, { - target: p => modal(p).getByRole('button', { name: /^Add \d+ countries$/ }), + // The whole dialog: the list step 2 describes (flag, cities, ticked + // rows) is only ever pictured here, and a frame centred on the button + // cut the rows in half. The button sits in the footer, in the ring too. + target: p => modal(p).getByRole('dialog'), + hover: p => modal(p).getByRole('button', { name: /^Add \d+ countries$/ }).hover(), act: async p => { await modal(p).getByRole('button', { name: /^Add \d+ countries$/ }).click() await expect(p.getByText(/\d+ countries added/)).toBeVisible({ timeout: 20_000 }) - await p.keyboard.press('Escape') + // Closed with the button in its head band, not Escape: the focus goes + // back to the Countries tile, and after a key press the browser draws + // its focus ring there, into the result picture. + await modal(p).getByRole('button', { name: 'Close', exact: true }).click() await expect(modal(p)).toHaveCount(0) // The Atlas re-reads itself after a write; the colours land with the answer. await expect.poll(async () => (await visitedCodes(p)).size, { timeout: 20_000 }).toBeGreaterThan(visitedBefore.size) @@ -445,7 +560,10 @@ const SCRIPTS: Record = { }, }, { - target: p => modal(p).getByRole('button', { name: 'Check wishlist' }), + // As in the countries guide: the pitch with its button, so the head band + // stays in the frame. + target: p => modal(p).getByRole('button', { name: 'Check wishlist' }).locator('xpath=..'), + hover: p => modal(p).getByRole('button', { name: 'Check wishlist' }).hover(), act: async p => { await modal(p).getByRole('button', { name: 'Check wishlist' }).click() // One read per wish with coordinates (Fushimi Inari, Wadi Rum, Petra), @@ -456,7 +574,10 @@ const SCRIPTS: Record = { }, }, { - target: p => modal(p).getByRole('button', { name: /^Tick off \d+$/ }), + // The whole dialog, as in the countries guide: the rows with distance, + // stay and day and the note with the 250 m rule are what step 2 quotes. + target: p => modal(p).getByRole('dialog'), + hover: p => modal(p).getByRole('button', { name: /^Tick off \d+$/ }).hover(), act: async p => { await modal(p).getByRole('button', { name: /^Tick off \d+$/ }).click() await expect(p.getByText(/\d+ wishes ticked off/)).toBeVisible({ timeout: 20_000 }) diff --git a/client/e2e/help/collections.guide.ts b/client/e2e/help/collections.guide.ts index b83351a14..ca4ff3bfc 100644 --- a/client/e2e/help/collections.guide.ts +++ b/client/e2e/help/collections.guide.ts @@ -1,7 +1,7 @@ -import { test, expect, type Page, type Locator } from '@playwright/test' +import { test, expect, type Page, type Locator, type Request } from '@playwright/test' import path from 'node:path' import { clearNotices } from '../screenshots/shot' -import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, OUT_DIR, type GuideScript } from './guide' +import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, OUT_DIR, PICTURE_DAY, type GuideScript } from './guide' import { ensureExtraTrips } from './fixtures' import { collectionsGuides, collectionsContext } from '../../src/help/contexts/collections' import type { HelpGuide } from '../../src/help/types' @@ -9,13 +9,34 @@ import type { HelpGuide } from '../../src/help/types' /** * Actions for the Collections guides, keyed by the ids in * `src/help/contexts/collections.ts`. The seed leaves one list, "Kyoto - * shortlist", with four of the Japan trip's places on it; the guides add a - * second list, more places, a label, a member and a file round trip, in the - * order the context lists them, and leave it all in place. + * shortlist", with the Japan trip's three Kyoto places on it; the guides add a + * second list, a Kyoto place found by search, three more Kyoto temples taken + * from the trip, a label, a member and a file round trip, in the order the + * context lists them, and leave the list side of it in place. Whatever they put + * into a trip comes out again in that guide's cleanup. */ const KYOTO = 'Kyoto shortlist' const LISBON = 'Lisbon coffee' +const JAPAN = 'Autumn in Japan' + +/** + * Kyoto places the import guide puts into the Japan trip first. The seed's trip + * holds Tokyo and the three Kyoto places the list already has, so an import from + * it could only add Tokyo to a list called "Kyoto shortlist", and every later + * picture would carry that. None of these is on a day, which is also what the + * import's third step says starts out selected. + */ +const KYOTO_EXTRAS = [ + { name: 'Kinkaku-ji Temple', lat: 35.0394, lng: 135.7292, address: '1 Kinkakujicho, Kita Ward, Kyoto', + description: 'The Golden Pavilion, mirrored in its pond.' }, + { name: 'Tenryu-ji Temple', lat: 35.0158, lng: 135.6737, address: '68 Sagatenryuji Susukinobabacho, Ukyo Ward, Kyoto', + description: 'Zen garden next to the bamboo grove.' }, + { name: 'Kiyomizu-dera Temple', lat: 34.9949, lng: 135.785, address: '1-294 Kiyomizu, Higashiyama Ward, Kyoto', + description: 'The wooden stage over the maples.' }, +] +/** The two places in Arashiyama, which the labels guide groups under that name. */ +const ARASHIYAMA_PLACES = ['Arashiyama Bamboo Grove', 'Tenryu-ji Temple'] const guide = (id: string): HelpGuide => { const g = collectionsGuides.find(x => x.id === id) @@ -28,28 +49,215 @@ const listRow = (page: Page, name: string) => rail(page).locator('button.col-row const hero = (page: Page) => page.locator('.col-hero') const filterBar = (page: Page) => page.locator('.col-filterbar') const modal = (page: Page) => page.locator('.trek-modal-backdrop').last() -/** The card inside the backdrop, for framing a whole dialog. */ -const modalCard = (page: Page) => modal(page).locator('> [role="presentation"]').first() +/** The rounded panel inside the backdrop, for framing a whole dialog. */ +const modalCard = (page: Page) => modal(page).locator('> [role="dialog"]').first() +/** A trip's tile on the first step of the import from a trip. */ +const importTrip = (page: Page, title: string) => modal(page).getByRole('button').filter({ hasText: title }) +/** + * The trip tiles on the first step of that import, and the rows of a trip's + * places on its second step: each is the dialog's only staggered block while it + * is on screen. + */ +const importStagger = (page: Page) => modal(page).locator('.trek-stagger') const placeRow = (page: Page, name: string) => page.locator('.col-lrow').filter({ hasText: name }).first() const placeRows = (page: Page) => page.locator('.col-lrow') const selectionBar = (page: Page) => page.locator('.col-selbar') /** The detail sheet beside the list. */ const detail = (page: Page) => page.locator('.col-detail').first() +/** The map beside the list, with its floating controls. */ +const mapShell = (page: Page) => page.locator('.col-map-shell') +/** The map's search pill: the magnifier and the field together. */ +const mapSearch = (page: Page) => mapShell(page).locator('.col-map-search').first() + +// ── Trip data over the API ──────────────────────────────────────────────────── + +async function tripIdOf(page: Page, title: string): Promise { + const res = await page.request.get('/api/trips') + const body = (await res.json()) as { trips?: { id: number; title: string }[] } | { id: number; title: string }[] + const trips = Array.isArray(body) ? body : (body.trips ?? []) + const trip = trips.find(t => t.title === title) + if (!trip) throw new Error(`trip "${title}" is missing`) + return trip.id +} + +async function tripPlaces(page: Page, tripId: number): Promise<{ id: number; name: string }[]> { + const res = await page.request.get(`/api/trips/${tripId}/places`) + if (!res.ok()) throw new Error(`GET places of trip ${tripId}: ${res.status()}`) + const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[] + return Array.isArray(body) ? body : (body.places ?? []) +} + +async function deleteTripPlaces(page: Page, tripId: number, ids: number[]): Promise { + for (const id of ids) { + const res = await page.request.delete(`/api/trips/${tripId}/places/${id}`) + if (!res.ok() && res.status() !== 404) throw new Error(`DELETE place ${id} of trip ${tripId}: ${res.status()}`) + } +} + +/** The places the import guide put into the Japan trip, removed again in its cleanup. */ +let addedToTrip: number[] = [] +/** The Japan trip's places before the copy guide ran; everything else is the copy's. */ +let placesBeforeCopy: Set | null = null + +// ── Waiting for the page to look finished ───────────────────────────────────── + +/** + * Take focus off whatever holds it. A focused field or a button that got focus + * back from a closed dialog draws an outline in the accent, which in a picture + * reads as a second step ring. + */ +async function blurFocus(page: Page): Promise { + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur?.()) +} + +/** + * Let a Leaflet camera move that is under way arrive. + * + * Leaflet times a pan from `+new Date()`, and the browser's clock is pinned to + * the picture day (guide.ts), so a pan started for a picture never gets past its + * first frame: the map stays where it was and the place the text says it pans + * to is not in view. Moving the fixed time on for a moment lets the running + * animation see that its time is up and finish, exactly where it would have + * ended; the clock goes back to the picture day straight after. + */ +async function finishMapMotion(page: Page): Promise { + await page.clock.setFixedTime(new Date(PICTURE_DAY.getTime() + 10_000)) + await page.evaluate(() => new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res)))) + await page.clock.setFixedTime(PICTURE_DAY) +} + +/** + * Wait until the map has stopped moving and shows its pins in the part of it + * that is on screen: every pin, or with `every: false` at least one, for a map + * that has just panned to one place and may have left others behind its edge. + * Nothing to wait for where the list has no map. + */ +async function mapReady(page: Page, { every = true } = {}): Promise { + if (!(await mapShell(page).count())) return + await finishMapMotion(page) + await expect + .poll( + () => + mapShell(page).evaluate((el, every) => { + if (el.querySelector('.leaflet-zoom-anim, .leaflet-pan-anim, .leaflet-cluster-anim')) return 'moving' + const map = el.getBoundingClientRect() + const box = { + left: Math.max(map.left, 0), + top: Math.max(map.top, 0), + right: Math.min(map.right, window.innerWidth), + bottom: Math.min(map.bottom, window.innerHeight), + } + const icons = Array.from(el.querySelectorAll('.leaflet-marker-icon')).map(i => i.getBoundingClientRect()) + const inside = icons.filter(r => r.left >= box.left && r.right <= box.right && r.top >= box.top && r.bottom <= box.bottom).length + if (!inside) return 'no pins in view' + return every && inside < icons.length ? `${icons.length - inside} of ${icons.length} pins out of view` : 'ready' + }, every, { timeout: 2_000 }), // short: a map on its way off the page is asked again, not waited for + { message: 'the map shows the pins of the list', timeout: 15_000 }, + ) + .toBe('ready') +} + +/** + * Frame the map on the list that is open. + * + * The map opens on a camera worked out for the whole window, not for the pane it + * sits in (MapView's opening frame), so on this page it starts about one zoom + * level too close and every pin is off its edges. It frames the pane properly + * whenever the number of places on it changes, so the search pill is used to + * empty it and fill it again, which is what a reader who searches and clears the + * search sees. The pill ends empty and the page as it was. + */ +async function frameMap(page: Page): Promise { + if (!(await mapShell(page).count())) return + const field = mapSearch(page).locator('input') + const rows = await placeRows(page).count() + await field.fill('zzz') + await expect(placeRows(page)).toHaveCount(0) + await field.fill('') + await expect(placeRows(page)).toHaveCount(rows) + await blurFocus(page) + await mapReady(page) +} + +/** Photo requests of each page still in flight: the lookups and the images they point at. */ +const photoRequests = new WeakMap>() + +function trackPhotos(page: Page): void { + if (photoRequests.has(page)) return + const pending = new Set() + photoRequests.set(page, pending) + const isPhoto = (r: Request) => r.url().includes('/api/maps/place-photo/') + page.on('request', r => { if (isPhoto(r)) pending.add(r) }) + page.on('requestfinished', r => pending.delete(r)) + page.on('requestfailed', r => pending.delete(r)) + // A cached photo whose file has gone answers with an empty 204, and the avatar + // falls back to its pin. Say so in the log, because that is not the app. + page.on('response', r => { + if (isPhoto(r) && r.url().endsWith('/bytes') && r.status() === 204) console.warn(`[collections] empty place photo: ${r.url()}`) + }) +} + +/** + * Wait for the place photos. Each avatar looks its photo up first and loads the + * image after that, so the lookups can be quiet for a moment while an image is + * still to come; quiet has to hold for a while before it counts. Bounded, so a + * lookup that hangs costs time, not the run. + */ +async function photosReady(page: Page): Promise { + const pending = photoRequests.get(page) + if (pending) { + const until = Date.now() + 30_000 + let quietSince = 0 + while (Date.now() < until) { + if (pending.size) quietSince = 0 + else if (!quietSince) quietSince = Date.now() + else if (Date.now() - quietSince > 800) break + await page.waitForTimeout(100) + } + if (pending.size) console.warn(`[collections] place photos still loading: ${[...pending].map(r => r.url()).join(', ')}`) + } + await page + .waitForFunction(() => Array.from(document.querySelectorAll('.col-lrow img')).every(img => img.complete), undefined, { timeout: 10_000 }) + .catch(() => {}) +} + +/** Map framed, pins drawn, photos in, nothing focused: a page ready for its picture. */ +async function pageReady(page: Page, { reframe = false, every = true } = {}): Promise { + if (reframe) await frameMap(page) + else await mapReady(page, { every }) + await photosReady(page) + await blurFocus(page) + await settle(page) +} async function openCollections(page: Page): Promise { + trackPhotos(page) await page.goto('/collections') await clearNotices(page) await dismissReleaseNotice(page) await expect(rail(page)).toBeVisible({ timeout: 20_000 }) await expect(listRow(page, KYOTO)).toBeVisible({ timeout: 20_000 }) - await settle(page) + // All saved, which this lands on, always has places, and the map comes with them. + await expect(placeRows(page).first()).toBeVisible({ timeout: 20_000 }) + await pageReady(page, { reframe: true }) } async function openList(page: Page, name: string): Promise { await openCollections(page) await listRow(page, name).click() await expect(hero(page)).toContainText(name) + // Until its places are in, the page can still show the map of the list before. await settle(page) + await expect(page.locator('.col-loading')).toHaveCount(0) + await pageReady(page, { reframe: true }) +} + +/** Turn on select mode and tick the named places. */ +async function selectPlaces(page: Page, names: string[]): Promise { + await filterBar(page).getByRole('button', { name: 'Select', exact: true }).click() + await expect(selectionBar(page)).toBeVisible() + for (const name of names) await placeRow(page, name).click() + await expect(selectionBar(page)).toContainText(`${names.length} selected`) } /** Pick an option in a CustomSelect that has a search box. */ @@ -61,6 +269,10 @@ async function pickSearchable(page: Page, trigger: Locator, text: string): Promi await search.locator('xpath=../..').getByRole('button', { name: text, exact: true }).first().click() } +/** The place the add guide finds by search: not on the list yet, with a real street address. */ +const SEARCH_FOR = 'Nijo Castle' +const FOUND = /Nij[oō]/ + const SCRIPTS: Record = { 'create-list': { guide: guide('create-list'), @@ -81,6 +293,9 @@ const SCRIPTS: Record = { await modal(p).getByRole('button', { name: '#f97316' }).click() await typeInto(p, modal(p).getByPlaceholder('Add a description…'), 'Pastéis, miradouros and the best flat white in Alfama.') await beat(p, 400) + // The description keeps focus after typing, and its focus border sits + // right next to the ring on Create in the next picture. + await blurFocus(p) }, }, { @@ -89,7 +304,10 @@ const SCRIPTS: Record = { await modal(p).getByRole('button', { name: 'Create' }).click() await expect(modal(p)).toHaveCount(0) await expect(hero(p)).toContainText(LISBON) - await settle(p) + // The new list is empty: its two ways to fill it replace the list and the map. + await expect(p.locator('.col-cta').first()).toBeVisible() + await expect(mapShell(p)).toHaveCount(0) + await pageReady(p) }, }, ], @@ -108,15 +326,19 @@ const SCRIPTS: Record = { }, }, { - target: p => modal(p).getByPlaceholder('Search for a place…'), + // The whole pill, magnifier included, not the bare input inside it. + target: p => modal(p).getByPlaceholder('Search for a place…').locator('xpath=..'), act: async p => { const search = modal(p).getByPlaceholder('Search for a place…') - await typeInto(p, search, 'Fushimi Inari') + await typeInto(p, search, SEARCH_FOR) await modal(p).getByRole('button', { name: 'Search', exact: true }).click() - const hit = modal(p).locator('button').filter({ hasText: /Fushimi Inari/ }).first() + // The OpenStreetMap hit, whose address runs from the street to the + // country; the index can answer the same name with only a ward. + const hit = modal(p).locator('button').filter({ hasText: FOUND }).filter({ hasText: /Kyoto.*Japan/ }).first() await expect(hit).toBeVisible({ timeout: 20_000 }) await hit.click() - await expect(modal(p).getByPlaceholder('Name', { exact: true })).toHaveValue(/Fushimi/) + await expect(modal(p).getByPlaceholder('Name', { exact: true })).toHaveValue(FOUND) + await blurFocus(p) await settle(p) }, }, @@ -124,12 +346,12 @@ const SCRIPTS: Record = { target: p => modal(p).getByRole('button', { name: 'Add', exact: true }), act: async p => { await modal(p).getByRole('button', { name: 'Add', exact: true }).click() - await expect(placeRow(p, 'Fushimi Inari Shrine')).toBeVisible({ timeout: 15_000 }) + await expect(placeRow(p, 'Nij')).toBeVisible({ timeout: 15_000 }) // The dialog stays open for the next place; the guide is done with it. await beat(p, 600) await modal(p).getByRole('button', { name: 'Cancel' }).click() await expect(modal(p)).toHaveCount(0) - await settle(p) + await pageReady(p) }, }, ], @@ -137,33 +359,52 @@ const SCRIPTS: Record = { 'import-from-trip': { guide: guide('import-from-trip'), - start: p => openList(p, KYOTO), + start: async p => { + const tripId = await tripIdOf(p, JAPAN) + const have = new Set((await tripPlaces(p, tripId)).map(x => x.name)) + addedToTrip = [] + for (const place of KYOTO_EXTRAS) { + if (have.has(place.name)) continue + const res = await p.request.post(`/api/trips/${tripId}/places`, { data: place }) + if (!res.ok()) throw new Error(`could not add "${place.name}" to ${JAPAN}: ${res.status()} ${await res.text()}`) + const body = (await res.json()) as { place?: { id: number }; id?: number } + const id = body.place?.id ?? body.id + if (id != null) addedToTrip.push(id) + } + await openList(p, KYOTO) + }, steps: [ { target: p => filterBar(p).getByRole('button', { name: 'Import from a trip' }), act: async p => { await filterBar(p).getByRole('button', { name: 'Import from a trip' }).click() await expect(modal(p)).toBeVisible() - await expect(modal(p).locator('button.col-imp-trip').first()).toBeVisible({ timeout: 15_000 }) + await expect(importTrip(p, JAPAN)).toBeVisible({ timeout: 15_000 }) await settle(p) }, }, { - target: p => modal(p).locator('button.col-imp-trip').filter({ hasText: 'Autumn in Japan' }), + // All the trip tiles, not the one: a tile in the left column puts the + // frame's centre there, and the dialog's right half falls off the picture. + target: importStagger, act: async p => { - await modal(p).locator('button.col-imp-trip').filter({ hasText: 'Autumn in Japan' }).click() - await expect(modal(p).locator('button.col-imp-row').first()).toBeVisible({ timeout: 15_000 }) + await importTrip(p, JAPAN).click() + await expect(modal(p).getByPlaceholder('Search places…')).toBeVisible() + await expect(importStagger(p).getByRole('button').first()).toBeVisible({ timeout: 15_000 }) await settle(p) }, }, { - target: p => modal(p).locator('.col-imp-list'), + // The whole dialog: the rows with their toolbar are taller than the + // smallest frame, so a frame centred on them cuts the head band off. + target: modalCard, act: async p => { - // Make sure something is ticked: the first two rows that are not already on the list. - const rows = modal(p).locator('button.col-imp-row:not(.dup)') - for (let i = 0; i < Math.min(2, await rows.count()); i++) { - if ((await rows.nth(i).getAttribute('aria-pressed')) !== 'true') await rows.nth(i).click() + // Nothing to tick: the three temples no day holds are what the dialog + // selects by itself, which is what the step's text says. + for (const place of KYOTO_EXTRAS) { + await expect(importStagger(p).getByRole('button', { name: new RegExp(place.name) })).toHaveAttribute('aria-pressed', 'true') } + await expect(modal(p).getByRole('button', { name: `Import ${KYOTO_EXTRAS.length}` })).toBeVisible() await beat(p, 300) }, }, @@ -172,12 +413,19 @@ const SCRIPTS: Record = { act: async p => { await modal(p).getByRole('button', { name: /^Import \d+$/ }).click() await expect(modal(p).getByText('Import finished')).toBeVisible({ timeout: 15_000 }) - await modal(p).getByRole('button', { name: 'Close' }).click() + // The head band has a Close of its own; the one meant here ends the dialog in its footer. + await modal(p).locator('footer').getByRole('button', { name: 'Close' }).click() await expect(modal(p)).toHaveCount(0) - await settle(p) + for (const place of KYOTO_EXTRAS) await expect(placeRow(p, place.name)).toBeVisible() + await pageReady(p) }, }, ], + cleanup: async p => { + // The list keeps its copies; the trip goes back to what the seed made of it. + await deleteTripPlaces(p, await tripIdOf(p, JAPAN), addedToTrip) + addedToTrip = [] + }, }, 'place-status': { @@ -197,7 +445,7 @@ const SCRIPTS: Record = { act: async p => { await placeRows(p).first().getByRole('button', { name: 'Want to go' }).click() await expect(placeRows(p).first().getByRole('button', { name: 'Visited' })).toBeVisible() - await settle(p) + await pageReady(p) }, }, ], @@ -212,7 +460,9 @@ const SCRIPTS: Record = { act: async p => { await placeRows(p).nth(1).click() await expect(detail(p)).toBeVisible() - await settle(p) + // The map pans to the place; let that pan arrive before the next picture. + // Places at the far side of the list can end up past the map's edge. + await pageReady(p, { every: false }) }, }, { @@ -242,16 +492,14 @@ const SCRIPTS: Record = { await beat(p, 400) await p.keyboard.press('Escape') await expect(modal(p)).toHaveCount(0) + // Escape hands focus back to the label control, outlined. + await blurFocus(p) await settle(p) }, }, { - prepare: async p => { - await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click() - await expect(selectionBar(p)).toBeVisible() - await placeRows(p).nth(0).click() - await placeRows(p).nth(1).click() - }, + // The two places that are in Arashiyama, so the label means what it says. + prepare: p => selectPlaces(p, ARASHIYAMA_PLACES), target: p => selectionBar(p).getByRole('button', { name: 'Assign label' }), act: async p => { await selectionBar(p).getByRole('button', { name: 'Assign label' }).click() @@ -261,6 +509,8 @@ const SCRIPTS: Record = { await expect(modal(p)).toHaveCount(0) await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click() await expect(selectionBar(p)).toHaveCount(0) + for (const name of ARASHIYAMA_PLACES) await expect(placeRow(p, name).locator('.col-lrow-label')).toContainText('Arashiyama') + await blurFocus(p) await settle(p) }, }, @@ -268,7 +518,8 @@ const SCRIPTS: Record = { target: p => p.locator('.col-labelfilter').first(), act: async p => { await p.locator('.col-labelfilter').first().getByRole('button', { name: /Arashiyama/ }).click() - await settle(p) + await expect(placeRows(p)).toHaveCount(ARASHIYAMA_PLACES.length) + await pageReady(p) }, }, ], @@ -303,8 +554,12 @@ const SCRIPTS: Record = { }, }, { - prepare: async p => { await selectionBar(p).getByRole('button', { name: 'Select all' }).click() }, + prepare: async p => { + await selectionBar(p).getByRole('button', { name: 'Select all' }).click() + await blurFocus(p) + }, target: selectionBar, + act: pageReady, }, ], cleanup: async p => { @@ -315,15 +570,17 @@ const SCRIPTS: Record = { 'copy-to-trip': { guide: guide('copy-to-trip'), - start: p => openList(p, KYOTO), + start: async p => { + placesBeforeCopy = new Set((await tripPlaces(p, await tripIdOf(p, JAPAN))).map(x => x.id)) + await openList(p, KYOTO) + }, steps: [ { target: p => filterBar(p).getByRole('button', { name: 'Select', exact: true }), act: async p => { - await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click() - await expect(selectionBar(p)).toBeVisible() - await placeRows(p).nth(0).click() - await placeRows(p).nth(2).click() + // Two places the Japan trip does not have yet: the one found by search + // and a temple whose trip copy the import guide took out again. + await selectPlaces(p, ['Nij', 'Kinkaku-ji Temple']) await beat(p, 300) }, }, @@ -336,16 +593,23 @@ const SCRIPTS: Record = { }, }, { - target: p => modal(p).locator('button').filter({ hasText: 'Norway Road Trip' }), + target: p => modal(p).locator('button').filter({ hasText: JAPAN }), act: async p => { - await modal(p).locator('button').filter({ hasText: 'Norway Road Trip' }).click() + await modal(p).locator('button').filter({ hasText: JAPAN }).click() await expect(modal(p)).toHaveCount(0, { timeout: 15_000 }) - await settle(p) + await pageReady(p) }, }, ], cleanup: async p => { if (await selectionBar(p).count()) await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click() + // The copies leave the trip again, so the trip screens keep the seed's trip. + if (placesBeforeCopy) { + const tripId = await tripIdOf(p, JAPAN) + const before = placesBeforeCopy + await deleteTripPlaces(p, tripId, (await tripPlaces(p, tripId)).filter(x => !before.has(x.id)).map(x => x.id)) + placesBeforeCopy = null + } }, }, @@ -362,7 +626,9 @@ const SCRIPTS: Record = { }, }, { - target: p => modal(p).getByRole('button', { name: 'Select a user' }).locator('xpath=../..'), + // The invite row: the user picker, the role picker and Send invite. Each + // picker sits in a wrapper that sizes it, so the row is three levels up. + target: p => modal(p).getByRole('button', { name: 'Select a user' }).locator('xpath=../../..'), act: async p => { await pickSearchable(p, modal(p).getByRole('button', { name: 'Select a user' }), 'mira') await beat(p, 300) @@ -376,7 +642,8 @@ const SCRIPTS: Record = { await beat(p, 500) await p.keyboard.press('Escape') await expect(modal(p)).toHaveCount(0) - await settle(p) + // Escape gives focus back to Share, and its outline would read as a ring. + await pageReady(p) }, }, ], @@ -438,7 +705,8 @@ const SCRIPTS: Record = { await modal(p).getByRole('button', { name: /^Import$/ }).click() await expect(modal(p)).toHaveCount(0, { timeout: 15_000 }) await expect(hero(p)).toContainText('Kyoto shortlist (from file)') - await settle(p) + // A new list mounts a new map, which opens on the window's camera. + await pageReady(p, { reframe: true }) }, }, ], @@ -461,10 +729,10 @@ const SCRIPTS: Record = { act: async p => { const desc = modal(p).getByPlaceholder('Add a description…') await desc.fill('') - await typeInto(p, desc, 'Coffee first, then the miradouros. Weekend of 16 October.') + await typeInto(p, desc, 'Coffee first, then the miradouros. For the long weekend in Lisbon.') await modal(p).getByRole('button', { name: 'Save' }).click() await expect(modal(p)).toHaveCount(0) - await settle(p) + await pageReady(p) }, }, ], @@ -479,13 +747,14 @@ const SCRIPTS: Record = { act: async p => { await rail(p).locator('button.col-row-btn').filter({ hasText: 'All saved' }).click() await expect(hero(p)).toContainText('All saved') - await settle(p) + await pageReady(p) }, }, { - target: p => p.getByPlaceholder('Search places').first(), + // The whole pill, magnifier included, not the bare input inside it. + target: mapSearch, act: async p => { - await typeInto(p, p.getByPlaceholder('Search places').first(), 'temple') + await typeInto(p, mapSearch(p).locator('input'), 'temple') await settle(p) }, }, diff --git a/client/e2e/help/journey.guide.ts b/client/e2e/help/journey.guide.ts index 85e44b43b..76e2e1070 100644 --- a/client/e2e/help/journey.guide.ts +++ b/client/e2e/help/journey.guide.ts @@ -1,7 +1,8 @@ -import { test, expect, type Page } from '@playwright/test' +import { test, expect, type APIResponse, type Locator, type Page } from '@playwright/test' +import { readFileSync } from 'node:fs' import { clearNotices } from '../screenshots/shot' import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide' -import { ensureExtraTrips, ensureJourneyFixtures, coverFixture, JOURNEY_TITLE, SECOND_JOURNEY, SPARE_TRIP } from './fixtures' +import { ensureExtraTrips, ensureJourneyFixtures, filesImageFixture, JOURNEY_TITLE, SECOND_JOURNEY, SPARE_TRIP } from './fixtures' import { journeyGuides, journeyContext, journalGuides, journalContext, studioGuides, studioContext } from '../../src/help/contexts/journey' import type { HelpGuide } from '../../src/help/types' @@ -10,6 +11,12 @@ import type { HelpGuide } from '../../src/help/types' * `src/help/contexts/journey.ts`: the list at `/journey` and the open journal * at `/journey/:id`. The fixtures write three entries into the seeded * "Autumn in Japan" journey and leave the rest of its timeline as suggestions. + * + * A guide that writes something (an entry, photos, a verdict, a share link, a + * contributor, a linked trip) takes it away again in its cleanup, so every + * guide starts from the journal the overview picture shows. Each one also + * clears the same things at its start, which makes a retry after a failed run + * begin from the seed as well. */ const ALL = [...journeyGuides, ...journalGuides, ...studioGuides] @@ -24,7 +31,16 @@ let journeyId = 0 async function openJourneys(page: Page): Promise { await page.goto('/journey') + // clearNotices pages through a notice by clicking any button whose name holds + // "next", and the second journey's card is such a button ("Norway, next + // summer"): it opened that journey instead. While it runs the cards are + // hidden from role lookups with aria-hidden. Not inert: Playwright still + // finds an inert button and then waits forever for it to take the click. + await expect(journeyCard(page, SECOND_JOURNEY.title)).toBeVisible({ timeout: 20_000 }) + const cards = page.locator('.vg-card') + await cards.evaluateAll(els => els.forEach(el => el.setAttribute('aria-hidden', 'true'))) await clearNotices(page) + await cards.evaluateAll(els => els.forEach(el => el.removeAttribute('aria-hidden'))) await dismissReleaseNotice(page) await expect(createCard(page)).toBeVisible({ timeout: 20_000 }) await expect(journeyCard(page, SECOND_JOURNEY.title)).toBeVisible({ timeout: 20_000 }) @@ -44,34 +60,55 @@ async function openJournal(page: Page): Promise { const journeyCard = (page: Page, title: string) => page.locator('.vg-card').filter({ hasText: title }).first() /** The + card at the end of the grid. */ const createCard = (page: Page) => page.getByRole('button', { name: /Create a new Journey/ }) -/** Full-screen dialogs of this screen (create, settings, add trip, invite) all sit at z-200. */ -const anyDialog = (page: Page) => page.locator('div.fixed.inset-0.z-\\[200\\]') +/** + * The dialogs of both screens (create, settings, link a trip, invite, the entry + * editor) are the shared DialogShell: a dimmed backdrop holding the panel. They + * portal onto the body in the order they open, so the last backdrop is the one + * on top. ConfirmDialog draws its own backdrop without this class, so a + * question asked over a dialog is not counted as one. + */ +const anyDialog = (page: Page) => page.locator('div.trek-modal-backdrop') const dialog = (page: Page) => anyDialog(page).last() -const dialogCard = (page: Page) => dialog(page).locator('> div').first() -/** The entry editor. */ -const editor = (page: Page) => page.locator('div.fixed.inset-0.z-\\[9999\\]') +/** The rounded panel of the top dialog: head band, body and footer. */ +const dialogCard = (page: Page) => dialog(page).getByRole('dialog') +/** The entry editor, named by the eyebrow over the title it has you type. */ +const editor = (page: Page) => page.getByRole('dialog', { name: /^(new|edit) entry$/i }) +/** The editor's photo tiles, saved ones first and then the files waiting for Save. */ +const photoTiles = (page: Page) => editor(page).locator('.group.h-20.w-20') -/** A written entry's card in the timeline, and the ⋯ menu button on its cover. */ +/** A written entry's card in the timeline, and the More options button that opens its menu. */ const entryCard = (page: Page, title: string) => page.locator('div[class*="rounded-[20px]"]').filter({ hasText: title }).first() -// Two builds of the button: on a photo cover (rounded-[10px]) and on a plain card (w-7 h-7 rounded-md). -const entryMenu = (page: Page, title: string) => entryCard(page, title).locator('button[class*="rounded-[10px]"], button.w-7.rounded-md').first() +// The shared MoreButton, in both builds of the card (over a photo cover and on a plain one). +const entryMenu = (page: Page, title: string) => entryCard(page, title).getByRole('button', { name: 'More options' }) +/** + * An item of the menu the More options button opens. The shared ContextMenu + * portals onto the end of the body, so the last button of that name is its own. + */ +const menuItem = (page: Page, name: string) => page.getByRole('button', { name, exact: true }).last() /** A suggestion card: the one kind of card that carries a dismiss button. */ const suggestion = (page: Page) => page.locator('[role="button"]').filter({ has: page.getByRole('button', { name: 'Dismiss this suggestion' }) }).first() +/** The suggestion a trip place became, by the place's name. */ +const namedSuggestion = (page: Page, name: string) => + page.locator('[role="button"]').filter({ has: page.getByRole('button', { name: 'Dismiss this suggestion' }) }).filter({ hasText: name }).first() const settingsButton = (page: Page) => page.getByRole('button', { name: 'Journey Settings' }).first() const mapPanel = (page: Page) => page.locator('aside').filter({ has: page.locator('.leaflet-container, .maplibregl-map, canvas') }).first() async function openSettings(page: Page): Promise { await settingsButton(page).click() - await expect(dialog(page).getByText('Journey Settings')).toBeVisible() + await expect(page.getByRole('dialog', { name: 'Journey Settings' })).toBeVisible() await settle(page) } -/** Close whatever dialogs are stacked: Escape first, then the backdrop, until none is left. */ +/** + * Close whatever dialogs are stacked, the top one first, with the X in its + * head band. The mouse rather than Escape: a dialog hands the focus back to + * the button that opened it, and after a key press that focus counts as + * keyboard focus and brings up the button's tooltip in the next picture. + */ async function closeDialog(page: Page): Promise { - await page.keyboard.press('Escape') for (let i = 0; i < 3 && (await anyDialog(page).count()); i++) { - await page.mouse.click(20, VIEWPORT.height / 2) + await dialogCard(page).getByRole('button', { name: 'Close', exact: true }).first().click() await page.waitForTimeout(300) } await expect(anyDialog(page)).toHaveCount(0) @@ -84,6 +121,229 @@ async function openEditor(page: Page): Promise { await settle(page) } +/** Open a written entry in the editor through the menu on its card. */ +async function editEntry(page: Page, title: string): Promise { + await entryMenu(page, title).click() + await menuItem(page, 'Edit').click() + await expect(editor(page)).toBeVisible() + await settle(page) +} + +/** + * Leave the editor without saving. One that holds an unsaved change asks + * first, in a ConfirmDialog, and the guide means to throw the change away. + */ +async function cancelEditor(page: Page): Promise { + await editor(page).getByRole('button', { name: 'Cancel', exact: true }).click() + const discard = page.getByRole('button', { name: 'Discard', exact: true }) + await expect.poll(async () => (await editor(page).count()) === 0 || (await discard.isVisible())).toBe(true) + if (await discard.isVisible()) await discard.click() + await expect(editor(page)).toHaveCount(0) + await settle(page) +} + +/** + * Take the focus off whatever holds it before an after-picture. A closed dialog + * hands the focus back to the button that opened it, and after typing that + * counts as keyboard focus: the button then shows its focus ring or tooltip in + * a picture that is about something else. + */ +async function blurFocus(page: Page): Promise { + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) +} + +/** + * Some steps are about a few controls that have no element of their own around + * them: the three choices of the export dialog sit in two sections, Delete and + * Archive Journey share the footer with Cancel and Save. The ring is drawn + * round one element's box, so a transparent box is laid over exactly the + * union of those controls for the picture and removed again after it. It + * takes no pointer events, so the measuring and the hovering go through it. + */ +const GROUP_MARK = 'trek-help-group' +const groupMark = (page: Page) => page.locator(`#${GROUP_MARK}`) + +async function markGroup(page: Page, items: Locator): Promise { + const boxes = await items.evaluateAll(els => els.map(el => { + const r = el.getBoundingClientRect() + return { left: r.left, top: r.top, right: r.right, bottom: r.bottom } + })) + if (!boxes.length) throw new Error('markGroup: nothing to mark') + const box = { + left: Math.min(...boxes.map(b => b.left)), + top: Math.min(...boxes.map(b => b.top)), + right: Math.max(...boxes.map(b => b.right)), + bottom: Math.max(...boxes.map(b => b.bottom)), + } + await page.evaluate(({ id, box }) => { + document.getElementById(id)?.remove() + const el = document.createElement('div') + el.id = id + Object.assign(el.style, { + position: 'fixed', + left: `${box.left}px`, + top: `${box.top}px`, + width: `${box.right - box.left}px`, + height: `${box.bottom - box.top}px`, + pointerEvents: 'none', + }) + document.body.appendChild(el) + }, { id: GROUP_MARK, box }) +} + +async function unmarkGroup(page: Page): Promise { + await page.evaluate(id => document.getElementById(id)?.remove(), GROUP_MARK) +} + +// ── Data a guide brings along ───────────────────────────────────────────────── + +/** + * The seed has one trip with places, and a journey already holds it. The + * guides that show a trip's places arriving as suggestions therefore put a few + * real stops on a trip of their own first, each on a day, because only a place + * on a day becomes a suggestion. `day` is the index of that day in the trip. + */ +interface TripStop { name: string; lat: number; lng: number; address: string; day: number } + +/** Five stops for the Lisbon weekend (three days), ticked when a journey is created. */ +const LISBON_STOPS: TripStop[] = [ + { name: 'Castelo de São Jorge', lat: 38.7139, lng: -9.1334, address: 'R. de Santa Cruz do Castelo, 1100-129 Lisboa, Portugal', day: 0 }, + { name: 'Miradouro da Senhora do Monte', lat: 38.7192, lng: -9.1326, address: 'Largo Monte, 1170-107 Lisboa, Portugal', day: 0 }, + { name: 'Mosteiro dos Jerónimos', lat: 38.6979, lng: -9.2068, address: 'Praça do Império, 1400-206 Lisboa, Portugal', day: 1 }, + { name: 'Pastéis de Belém', lat: 38.6975, lng: -9.2032, address: 'R. de Belém 84-92, 1300-085 Lisboa, Portugal', day: 1 }, + { name: 'Palácio Nacional da Pena', lat: 38.7876, lng: -9.3906, address: 'Estrada da Pena, 2710-609 Sintra, Portugal', day: 2 }, +] +const LISBON_TRIP = 'Weekend in Lisbon' +const NEW_JOURNEY = 'Lisbon weekend' + +/** Four stations of the Alps by rail trip (eight days), for the guide that links it. */ +const ALPS_STOPS: TripStop[] = [ + { name: 'Zürich HB', lat: 47.378, lng: 8.5402, address: 'Bahnhofplatz, 8001 Zürich, Switzerland', day: 0 }, + { name: 'Alp Grüm', lat: 46.3736, lng: 10.0467, address: '7710 Alp Grüm, Switzerland', day: 2 }, + { name: 'Tirano', lat: 46.2163, lng: 10.1686, address: 'Piazza Stazione, 23037 Tirano SO, Italy', day: 3 }, + { name: 'Venezia Santa Lucia', lat: 45.441, lng: 12.3208, address: 'Fondamenta Santa Lucia, 30121 Venezia VE, Italy', day: 6 }, +] + +const NEW_ENTRY = 'Night market on the river' +const ASAKUSA = 'First morning in Asakusa' + +/** The body of an API answer, or a loud failure naming what was asked. */ +async function answer(res: APIResponse, what: string): Promise { + if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`) + return (await res.json()) as T +} + +/** The list inside an answer that comes either bare or wrapped in `{ key: [...] }`. */ +function listIn(body: unknown, key: string): T[] { + if (Array.isArray(body)) return body as T[] + return ((body as Record)[key] ?? []) as T[] +} + +async function tripId(page: Page, title: string): Promise { + const body = await answer(await page.request.get('/api/trips'), 'list trips') + // Exact title: a copy another screen's guide made is "Weekend in Lisbon" plus something. + const trip = listIn<{ id: number; title: string }>(body, 'trips').find(t => t.title === title) + if (!trip) throw new Error(`fixture trip "${title}" is missing`) + return trip.id +} + +async function tripPlaces(page: Page, trip: number): Promise<{ id: number; name: string }[]> { + return listIn<{ id: number; name: string }>(await answer(await page.request.get(`/api/trips/${trip}/places`), 'list places'), 'places') +} + +/** Put the stops on the trip, each on its day; those already there are left alone. */ +async function addTripStops(page: Page, title: string, stops: TripStop[]): Promise { + const trip = await tripId(page, title) + const have = await tripPlaces(page, trip) + const days = listIn<{ id: number; day_number?: number; date?: string }>( + await answer(await page.request.get(`/api/trips/${trip}/days`), 'list days'), 'days', + ).sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0) || String(a.date).localeCompare(String(b.date))) + for (const { day, ...place } of stops) { + if (have.some(p => p.name === place.name)) continue + const { place: created } = await answer<{ place: { id: number } }>( + await page.request.post(`/api/trips/${trip}/places`, { data: place }), `add "${place.name}"`, + ) + const dayId = days[day]?.id + if (!dayId) throw new Error(`trip "${title}" has no day ${day + 1}`) + await answer(await page.request.post(`/api/trips/${trip}/days/${dayId}/assignments`, { data: { place_id: created.id } }), `put "${place.name}" on day ${day + 1}`) + } +} + +/** Take the stops off the trip again, by name, so only what the guide added goes. */ +async function removeTripStops(page: Page, title: string, stops: TripStop[]): Promise { + const trip = await tripId(page, title) + for (const place of await tripPlaces(page, trip)) { + if (!stops.some(s => s.name === place.name)) continue + await answer(await page.request.delete(`/api/trips/${trip}/places/${place.id}`), `remove "${place.name}"`) + } +} + +async function deleteJourneysNamed(page: Page, title: string): Promise { + const { journeys } = await answer<{ journeys: { id: number; title: string }[] }>(await page.request.get('/api/journeys'), 'list journeys') + for (const j of journeys.filter(x => x.title === title)) { + await answer(await page.request.delete(`/api/journeys/${j.id}`), `delete journey "${title}"`) + } +} + +interface JournalEntry { id: number; title: string | null; photos?: { id: number }[] } + +async function journalEntries(page: Page): Promise { + const { entries } = await answer<{ entries: JournalEntry[] }>(await page.request.get(`/api/journeys/${journeyId}/entries`), 'list entries') + return entries +} + +async function journalEntry(page: Page, title: string): Promise { + const entry = (await journalEntries(page)).find(e => e.title === title) + if (!entry) throw new Error(`entry "${title}" is missing`) + return entry +} + +async function deleteEntriesNamed(page: Page, title: string): Promise { + for (const entry of (await journalEntries(page)).filter(e => e.title === title)) { + await answer(await page.request.delete(`/api/journeys/entries/${entry.id}`), `delete entry "${title}"`) + } +} + +/** + * Leave an entry with the photo the fixtures gave it and nothing else. That + * photo was uploaded first, so it has the lowest id; it goes back to the front + * as well, in case a guide made another one the cover. + */ +async function resetEntryPhotos(page: Page, title: string): Promise { + const photos = [...((await journalEntry(page, title)).photos ?? [])].sort((a, b) => a.id - b.id) + for (const photo of photos.slice(1)) { + await answer(await page.request.delete(`/api/journeys/photos/${photo.id}`), `delete a photo of "${title}"`) + } + if (photos[0]) await answer(await page.request.patch(`/api/journeys/photos/${photos[0].id}`, { data: { sort_order: 0 } }), `put the first photo of "${title}" back in front`) +} + +/** The fixtures give this entry no verdict; null is what an entry without one holds. */ +async function clearVerdict(page: Page, title: string): Promise { + const entry = await journalEntry(page, title) + await answer(await page.request.patch(`/api/journeys/entries/${entry.id}`, { data: { pros_cons: null } }), `clear the verdict of "${title}"`) +} + +async function removeContributor(page: Page, username: string): Promise { + const { contributors } = await answer<{ contributors: { user_id: number; username: string; role: string }[] }>( + await page.request.get(`/api/journeys/${journeyId}`), 'read the journey', + ) + for (const c of contributors.filter(x => x.username === username && x.role !== 'owner')) { + await answer(await page.request.delete(`/api/journeys/${journeyId}/contributors/${c.user_id}`), `remove ${username}`) + } +} + +async function deleteShareLink(page: Page): Promise { + // Answered with success whether or not a link exists. + await answer(await page.request.delete(`/api/journeys/${journeyId}/share-link`), 'delete the share link') +} + +async function unlinkTrip(page: Page, title: string): Promise { + const trip = await tripId(page, title) + const res = await page.request.delete(`/api/journeys/${journeyId}/trips/${trip}`) + // The owner is answered with success whether or not the trip was linked. + await answer(res, `unlink "${title}"`) +} + // ── Studio ──────────────────────────────────────────────────────────────────── const rail = (page: Page) => page.locator('.st-rail') @@ -91,7 +351,8 @@ const railButton = (page: Page, name: string) => rail(page).getByRole('button', const sidePanel = (page: Page) => page.locator('.st-panel.st-side') const inspector = (page: Page) => page.locator('.st-inspector') const studioMenu = (page: Page) => page.locator('.st-menu').last() -const exportDialog = (page: Page) => page.locator('.st-ex') +/** The export dialog: the shared DialogShell over Studio, titled Export. */ +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:"]') @@ -132,7 +393,17 @@ const SCRIPTS: Record = { // ── /journey ──────────────────────────────────────────────────────────── 'create-journey': { guide: guide('create-journey'), - start: openJourneys, + // The ticked trip needs places on its days, or the counter reads 0 and the + // new journal opens empty, which is the opposite of what the result says. + start: async p => { + await deleteJourneysNamed(p, NEW_JOURNEY) + await addTripStops(p, LISBON_TRIP, LISBON_STOPS) + await openJourneys(p) + }, + cleanup: async p => { + await deleteJourneysNamed(p, NEW_JOURNEY) + await removeTripStops(p, LISBON_TRIP, LISBON_STOPS) + }, steps: [ { target: createCard, @@ -145,7 +416,7 @@ const SCRIPTS: Record = { { target: dialogCard, act: async p => { - await typeInto(p, dialog(p).locator('input').first(), 'Lisbon weekend') + await typeInto(p, dialog(p).locator('input').first(), NEW_JOURNEY) await typeInto(p, dialog(p).locator('input').nth(1), 'Two days of tiles and custard tarts') // By accessible name, not by a substring: a copy of the trip made by // another screen's guide is also "Weekend in Lisbon something". @@ -159,6 +430,9 @@ const SCRIPTS: Record = { await dialog(p).getByRole('button', { name: 'Create Journey' }).click() await expect(p).toHaveURL(/\/journey\/\d+/, { timeout: 15_000 }) await expect(p.getByRole('button', { name: 'Add Entry' }).first()).toBeVisible({ timeout: 20_000 }) + // The trip's places, now suggestions on their days, are what the result is about. + await expect(namedSuggestion(p, LISBON_STOPS[0].name)).toBeVisible({ timeout: 20_000 }) + await blurFocus(p) await settle(p) await p.waitForTimeout(600) }, @@ -189,16 +463,23 @@ const SCRIPTS: Record = { // ── /journey/:id ──────────────────────────────────────────────────────── 'add-entry': { guide: guide('add-entry'), - start: openJournal, + start: async p => { + await deleteEntriesNamed(p, NEW_ENTRY) + await openJournal(p) + }, + cleanup: p => deleteEntriesNamed(p, NEW_ENTRY), steps: [ { target: p => p.getByRole('button', { name: 'Add Entry' }).first(), act: openEditor, }, { - target: p => editor(p).getByPlaceholder('Write your story...'), + // The name is typed into the head band. Ringing the band keeps the story box and + // its Markdown toolbar in the frame below it; ringing the story box cuts the band + // off, and a ring round the name field alone runs through the eyebrow above it. + target: p => editor(p).locator('header'), act: async p => { - await typeInto(p, editor(p).getByPlaceholder('Give this moment a name...'), 'Night market on the river') + await typeInto(p, editor(p).getByPlaceholder('Give this moment a name...'), NEW_ENTRY) await typeInto( p, editor(p).getByPlaceholder('Write your story...'), @@ -208,7 +489,9 @@ const SCRIPTS: Record = { }, }, { - target: p => editor(p).getByRole('button', { name: 'Amazing' }).locator('xpath=../..'), + // The editor's right column: date, location, mood and weather, which is + // everything the step names (the verdict sits at its top). + target: p => editor(p).getByRole('group', { name: 'Mood' }).locator('xpath=../..'), act: async p => { await editor(p).getByRole('button', { name: 'Amazing' }).click() await editor(p).getByRole('button', { name: 'Partly cloudy' }).click() @@ -220,42 +503,57 @@ const SCRIPTS: Record = { act: async p => { await editor(p).getByRole('button', { name: 'Save', exact: true }).click() await expect(editor(p)).toHaveCount(0, { timeout: 15_000 }) - await expect(entryCard(p, 'Night market on the river')).toBeVisible() + await expect(entryCard(p, NEW_ENTRY)).toBeVisible() + // It lands on today, the last day, far below the top of the timeline + // where the journal stays after the editor closes. + await entryCard(p, NEW_ENTRY).evaluate(el => el.scrollIntoView({ block: 'center' })) + await blurFocus(p) await settle(p) }, }, ], }, + // Make 1st is offered on a saved photo that is not first yet; files just + // picked wait for Save and have none. So the entry is one that already holds + // two photos (the fixtures' one and a second put there at the start), the + // upload adds a third, and step 3 shows the second with Make 1st on it. 'entry-photos': { guide: guide('entry-photos'), - start: openJournal, + start: async p => { + await resetEntryPhotos(p, ASAKUSA) + const entry = await journalEntry(p, ASAKUSA) + const grove = await filesImageFixture('arashiyama') + await answer(await p.request.post(`/api/journeys/entries/${entry.id}/photos`, { + multipart: { photos: { name: 'morning-light.jpg', mimeType: 'image/jpeg', buffer: readFileSync(grove) } }, + }), `add a second photo to "${ASAKUSA}"`) + await openJournal(p) + }, + cleanup: p => resetEntryPhotos(p, ASAKUSA), steps: [ { - target: p => entryMenu(p, 'Barefoot through the light'), - act: async p => { - await entryMenu(p, 'Barefoot through the light').click() - await p.getByRole('button', { name: 'Edit', exact: true }).first().click() - await expect(editor(p)).toBeVisible() - await settle(p) - }, + target: p => entryMenu(p, ASAKUSA), + act: p => editEntry(p, ASAKUSA), }, { target: p => editor(p).getByRole('button', { name: 'Upload photos' }), act: async p => { - const file = await coverFixture() - await editor(p).locator('input[type="file"][multiple]').setInputFiles(file) - await expect(editor(p).locator('img').first()).toBeVisible({ timeout: 15_000 }) + const before = await photoTiles(p).count() + await editor(p).locator('input[type="file"][multiple]').setInputFiles(await filesImageFixture('jr-pass-map')) + await expect(photoTiles(p)).toHaveCount(before + 1, { timeout: 15_000 }) await settle(p) }, }, { - prepare: async p => { await editor(p).locator('img').first().hover() }, - target: p => editor(p).getByRole('button', { name: 'Save', exact: true }), + // The second saved photo; the target is hovered for the picture, which shows its Make 1st. + target: p => photoTiles(p).nth(1), act: async p => { + await photoTiles(p).nth(1).getByRole('button', { name: 'Make 1st' }).click() + await beat(p, 400) await editor(p).getByRole('button', { name: 'Save', exact: true }).click() await expect(editor(p)).toHaveCount(0, { timeout: 30_000 }) - await expect(entryCard(p, 'Barefoot through the light').locator('img').first()).toBeVisible({ timeout: 15_000 }) + await expect(entryCard(p, ASAKUSA).locator('img').first()).toBeVisible({ timeout: 15_000 }) + await blurFocus(p) await settle(p) }, }, @@ -289,6 +587,7 @@ const SCRIPTS: Record = { await dialog(p).getByRole('button', { name: 'Bring back dismissed suggestions' }).click() await beat(p, 500) await closeDialog(p) + await blurFocus(p) }, }, ], @@ -308,29 +607,31 @@ const SCRIPTS: Record = { }, { target: p => editor(p).getByText('Date', { exact: true }).locator('xpath=..'), - act: async p => { - await editor(p).getByRole('button', { name: 'Cancel' }).click() - await expect(editor(p)).toHaveCount(0) - }, + act: cancelEditor, }, ], }, 'pros-cons': { guide: guide('pros-cons'), - start: openJournal, + start: async p => { + await clearVerdict(p, ASAKUSA) + await openJournal(p) + }, + cleanup: p => clearVerdict(p, ASAKUSA), steps: [ { prepare: async p => { - await entryMenu(p, 'First morning in Asakusa').click() - await p.getByRole('button', { name: 'Edit', exact: true }).first().click() - await expect(editor(p)).toBeVisible() + await editEntry(p, ASAKUSA) await editor(p).getByText('Pros & Cons').scrollIntoViewIfNeeded() await settle(p) }, target: p => editor(p).getByText('Pros & Cons').locator('xpath=../..'), act: async p => { + // Two pros through Add another, as the step says, and one con: two short lists. await typeInto(p, editor(p).getByPlaceholder('Something great...').last(), 'Empty courtyard at seven') + await editor(p).getByRole('button', { name: 'Add another' }).first().click() + await typeInto(p, editor(p).getByPlaceholder('Something great...').last(), 'Soba as the counter opened') await typeInto(p, editor(p).getByPlaceholder('Not so great...').last(), 'Coffee only after nine') await beat(p, 400) }, @@ -340,12 +641,17 @@ const SCRIPTS: Record = { act: async p => { await editor(p).getByRole('button', { name: 'Save', exact: true }).click() await expect(editor(p)).toHaveCount(0, { timeout: 15_000 }) - // The card folds a long entry, so the saved verdict is checked at the source. await expect.poll(async () => { const res = await p.request.get(`/api/journeys/${journeyId}/entries`) const { entries } = (await res.json()) as { entries: { title: string | null; pros_cons?: { pros?: string[] } | null }[] } - return entries.find(e => e.title === 'First morning in Asakusa')?.pros_cons?.pros ?? [] + return entries.find(e => e.title === ASAKUSA)?.pros_cons?.pros ?? [] }).toContain('Empty courtyard at seven') + // The card folds the verdict behind Show more; the result is about the verdict. + await entryCard(p, ASAKUSA).getByRole('button', { name: 'Show more' }).click() + const verdict = entryCard(p, ASAKUSA).getByText('Empty courtyard at seven') + await expect(verdict).toBeVisible() + await verdict.evaluate(el => el.scrollIntoView({ block: 'center' })) + await blurFocus(p) await settle(p) }, }, @@ -357,7 +663,8 @@ const SCRIPTS: Record = { start: openJournal, steps: [ { - target: p => p.getByPlaceholder('Search this journey'), + // The pill round the field, which carries the magnifier and the border. + target: p => p.getByPlaceholder('Search this journey').locator('xpath=..'), act: async p => { await typeInto(p, p.getByPlaceholder('Search this journey'), 'crossing') await settle(p) @@ -403,16 +710,27 @@ const SCRIPTS: Record = { act: openSettings, }, { - prepare: async p => { await dialog(p).getByText('Entry fields').scrollIntoViewIfNeeded() }, - target: p => dialog(p).getByText('Entry fields').locator('xpath=..'), + prepare: async p => { await dialog(p).getByText('Entry fields', { exact: true }).scrollIntoViewIfNeeded() }, + // The label sits in a row of its own, so the section with its switches is two levels up. + target: p => dialog(p).getByText('Entry fields', { exact: true }).locator('xpath=../..'), act: closeDialog, }, ], }, + // The spare trip gets stations on its days first: a trip without places adds + // nothing to the journal, and the result would show no change at all. 'link-trip': { guide: guide('link-trip'), - start: openJournal, + start: async p => { + await unlinkTrip(p, SPARE_TRIP.title) + await addTripStops(p, SPARE_TRIP.title, ALPS_STOPS) + await openJournal(p) + }, + cleanup: async p => { + await unlinkTrip(p, SPARE_TRIP.title) + await removeTripStops(p, SPARE_TRIP.title, ALPS_STOPS) + }, steps: [ { target: settingsButton, @@ -432,16 +750,32 @@ const SCRIPTS: Record = { act: async p => { await dialog(p).getByText(SPARE_TRIP.title).locator('xpath=../..').getByRole('button', { name: 'Link' }).click() await beat(p, 600) + // Linking closes the picker and the settings under it once the server has + // answered; waiting for that keeps the X from racing the request. + await expect(p.getByPlaceholder('Trip name or destination...')).toHaveCount(0, { timeout: 15_000 }) if (await anyDialog(p).count()) await closeDialog(p) + // Its days come months after the running trip, at the foot of the + // timeline, so the result is taken there, on the first new suggestion. + const first = namedSuggestion(p, ALPS_STOPS[0].name) + await expect(first).toBeVisible({ timeout: 15_000 }) + await first.evaluate(el => el.scrollIntoView({ block: 'center' })) + await blurFocus(p) await settle(p) + await p.waitForTimeout(600) // the map follows the scroll }, }, ], }, + // The link is deleted again afterwards: left in place, it shows up in every + // later picture of the settings, archive-journey's among them. 'share-public': { guide: guide('share-public'), - start: openJournal, + start: async p => { + await deleteShareLink(p) + await openJournal(p) + }, + cleanup: deleteShareLink, steps: [ { prepare: async p => { @@ -467,13 +801,18 @@ const SCRIPTS: Record = { contributors: { guide: guide('contributors'), - start: openJournal, + start: async p => { + await removeContributor(p, 'jonas') + await openJournal(p) + }, + cleanup: p => removeContributor(p, 'jonas'), steps: [ { prepare: async p => { await openSettings(p) await dialog(p).getByRole('button', { name: 'Invite Contributor' }).scrollIntoViewIfNeeded() }, + // The button is the last thing in the Contributors section, so its parent is the whole section. target: p => dialog(p).getByRole('button', { name: 'Invite Contributor' }).locator('xpath=..'), }, { @@ -488,9 +827,16 @@ const SCRIPTS: Record = { }, }, { - target: p => dialog(p).getByRole('button', { name: 'Invite', exact: true }), + // The role switch at the left of the footer, with Invite beside it in the + // frame. Not the footer itself: it spans the panel edge to edge, and a ring + // round it runs past the panel's rounded corners. + target: p => dialog(p).getByRole('group', { name: 'Role' }), + hover: p => dialog(p).getByRole('group', { name: 'Role' }).getByRole('button', { name: 'Editor' }).hover(), act: async p => { + await dialog(p).getByRole('group', { name: 'Role' }).getByRole('button', { name: 'Editor' }).click() await dialog(p).getByRole('button', { name: 'Invite', exact: true }).click() + // The invite lists jonas as well, so the settings are checked only once it has closed. + await expect(p.getByPlaceholder('Username or email...')).toHaveCount(0, { timeout: 15_000 }) await expect(dialog(p).getByText('jonas')).toBeVisible({ timeout: 15_000 }) await beat(p, 500) await closeDialog(p) @@ -528,9 +874,21 @@ const SCRIPTS: Record = { act: openSettings, }, { - prepare: async p => { await dialog(p).getByRole('button', { name: 'Archive Journey' }).scrollIntoViewIfNeeded() }, - target: p => dialog(p).getByRole('button', { name: 'Archive Journey' }).locator('xpath=..'), - act: closeDialog, + // Delete and Archive Journey, at the left of the footer. The footer as a + // whole spans the panel edge to edge and its ring would run past the + // panel's corners, so the two are marked as a group. + prepare: p => markGroup(p, dialogCard(p).locator('footer').getByRole('button', { name: /^(Delete|Archive Journey|Restore Journey)$/ })), + target: groupMark, + // The pointer waits in the empty middle of the footer: on either button it + // brings up a tooltip right where the step's number goes. + hover: async p => { + const footer = await dialogCard(p).locator('footer').boundingBox() + if (footer) await p.mouse.move(footer.x + footer.width / 2, footer.y + footer.height / 2) + }, + act: async p => { + await unmarkGroup(p) + await closeDialog(p) + }, }, ], }, @@ -736,12 +1094,21 @@ const SCRIPTS: Record = { }, }, { - target: exportDialog, + // Single pages, Spreads and Crop marks: three pressable rows in two + // sections, marked as one group with the sections' labels, so the + // step's number sits above LAYOUT instead of on it. The pointer rests + // on the chosen row, which has no hover look, so no row looks picked + // that is not. + prepare: p => markGroup(p, exportDialog(p).locator('section')), + target: groupMark, + hover: p => exportDialog(p).getByRole('button', { name: /^Single pages/ }).hover(), + act: unmarkGroup, }, { target: p => exportDialog(p).getByRole('button', { name: 'Print view' }), act: async p => { - await exportDialog(p).getByRole('button', { name: 'Close' }).click() + // Print view would open the browser's print window; the X closes the dialog instead. + await exportDialog(p).getByRole('button', { name: 'Close', exact: true }).click() await expect(exportDialog(p)).toHaveCount(0) }, }, diff --git a/client/e2e/help/vacay.guide.ts b/client/e2e/help/vacay.guide.ts index d9ace441b..f91f902bc 100644 --- a/client/e2e/help/vacay.guide.ts +++ b/client/e2e/help/vacay.guide.ts @@ -1,6 +1,6 @@ -import { test, expect, type Page, type Locator } from '@playwright/test' -import { clearNotices } from '../screenshots/shot' -import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide' +import { test, expect, request as apiRequest, type APIRequestContext, type Page, type Locator } from '@playwright/test' +import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript, type StepAction } from './guide' +import { E2E_BASE_URL } from '../../playwright.config' import { monthName, year } from '../dates' import { vacayGuides, vacayContext } from '../../src/help/contexts/vacay' import type { HelpGuide } from '../../src/help/types' @@ -16,6 +16,8 @@ import type { HelpGuide } from '../../src/help/types' const YEAR = year() /** The month after the picture day's: the first one the planner draws after the current. */ const NEXT_MONTH = monthName(1) +/** What the entitlement guide sets: not the seed's 30, so the change shows. */ +const ENTITLEMENT = '28' const guide = (id: string): HelpGuide => { const g = vacayGuides.find(x => x.id === id) @@ -23,10 +25,33 @@ const guide = (id: string): HelpGuide => { return g } +/** + * What clearNotices in `screenshots/shot.ts` clears, less its loop over every + * button whose name contains "next". On this page that loop found the year + * card's "Add next year" and clicked it six times on every visit, so the plan + * grew by six years per guide and the Year card filled up with chips past + * 2100. A tour's own button is called just "Next", so that one is still stepped + * through. + */ +async function clearVacayNotices(page: Page): Promise { + const next = page.getByRole('button', { name: 'Next', exact: true }) + for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) { + if (!(await next.isEnabled().catch(() => false))) break + await next.click().catch(() => {}) + } + for (const label of ['Dismiss', 'OK']) { + const btn = page.getByRole('button', { name: label, exact: true }) + for (let i = 0; i < 4 && (await btn.isVisible().catch(() => false)); i++) { + await btn.click().catch(() => {}) + await page.waitForTimeout(300) + } + } +} + async function openVacay(page: Page): Promise { await page.goto('/vacay') - await clearNotices(page) await dismissReleaseNotice(page) + await clearVacayNotices(page) await expect(toolbar(page)).toBeVisible() await expect(monthCard(page, NEXT_MONTH)).toBeVisible() } @@ -43,9 +68,52 @@ const sideCard = (page: Page, label: string) => page.locator('.vg-card').filter( const settings = (page: Page) => page.locator('.trek-modal-backdrop') /** The invite and share forms: small dialogs portalled to the body. */ const dialog = (page: Page) => page.locator('.trek-backdrop-enter').last() +/** A settings row by its label: icon, label, hint and, for a toggle, its switch. */ +const settingRow = (page: Page, label: string): Locator => + settings(page).getByText(label, { exact: true }).locator('xpath=../../..') /** The switch of a settings row, found through the row's label. */ -const toggleRow = (page: Page, label: string): Locator => - settings(page).getByText(label, { exact: true }).locator('xpath=../../..').getByRole('button').first() +const toggleRow = (page: Page, label: string): Locator => settingRow(page, label).getByRole('button').first() +/** The form Add calendar opens: colour, label, country, region and its Add button. */ +const addCalendarForm = (page: Page): Locator => + settings(page).getByPlaceholder('Label (optional)').last().locator('xpath=../..') +/** The Days tile of the Entitlement card while it is being edited. */ +const daysTile = (page: Page): Locator => sideCard(page, 'Entitlement').locator('input').locator('xpath=..') +/** A year chip in the Year card. */ +const yearChip = (page: Page, y: number): Locator => + sideCard(page, 'Year').locator('[role="button"]').filter({ hasText: String(y) }).first() +/** A row of a sidebar card (a person, an invite, a share) by the name in it. */ +const cardRow = (page: Page, card: string, name: string): Locator => + sideCard(page, card).locator('.group').filter({ hasText: name }).first() + +/** Nothing hovered: for a picture where the pointer would reveal a button the step is not about. */ +const parkPointer = async (page: Page): Promise => { await page.mouse.move(VIEWPORT.width - 4, VIEWPORT.height - 4) } + +/** + * Back to the top of the page. A step target further down scrolls the page a + * little, and a result picture taken there hides the top row of months under + * the header, unlike the hero and the other results. + */ +async function toTop(page: Page): Promise { + // On a desktop the body is what scrolls (index.css hides the overflow of + // html), so the window's own scroll position is already 0. + await page.evaluate(() => { + for (const el of [document.body, document.documentElement]) el.scrollTo({ top: 0, left: 0, behavior: 'instant' }) + window.scrollTo({ top: 0, left: 0, behavior: 'instant' }) + }) + await settle(page) +} + +/** + * Turn a settings switch on, and leave it alone when it is on already. Block + * Weekends and Carry Over start out on, and a plain click switched them off, + * so the next picture showed the opposite of what the step had just said. The + * switch has no aria state; its filled track is the only sign it is on. + */ +async function switchOn(page: Page, label: string): Promise { + const sw = toggleRow(page, label) + if (!(await sw.evaluate(el => el.classList.contains('bg-content')))) await sw.click() + await settle(page) +} async function openSettings(page: Page): Promise { await page.getByRole('button', { name: 'Settings' }).click() @@ -56,9 +124,107 @@ async function openSettings(page: Page): Promise { async function closeSettings(page: Page): Promise { await page.keyboard.press('Escape') await expect(settings(page)).toBeHidden() + // The dialog hands the focus back to the Settings button, and after a key + // press that draws a thick focus outline, which in the next picture reads + // like a step ring. + await page.evaluate(() => { (document.activeElement as HTMLElement | null)?.blur() }) await settle(page) } +/** + * Click Add calendar and fill the form the way the step describes, short of + * adding it, so the picture shows the fields filled in. + */ +async function fillCalendarForm(page: Page, addButton: Locator, label: string): Promise { + await addButton.click() + await typeInto(page, settings(page).getByPlaceholder('Label (optional)').last(), label) + await pick(page, settings(page).getByRole('button', { name: 'Select country' }), 'Germany') + // The region list loads after the country is picked; give it a moment to show up. + const region = settings(page).getByRole('button', { name: 'Select region (required)' }) + await region.waitFor({ state: 'visible', timeout: 8_000 }).catch(() => {}) + if (await region.isVisible().catch(() => false)) await pick(page, region, 'Berlin') + await expect(addCalendarForm(page).getByRole('button', { name: 'Add', exact: true })).toBeEnabled({ timeout: 10_000 }) +} + +async function submitCalendarForm(page: Page): Promise { + await addCalendarForm(page).getByRole('button', { name: 'Add', exact: true }).click() + // The form closes once the calendar is saved and turns into its row. + await expect(settings(page).getByRole('button', { name: 'Add', exact: true })).toBeHidden({ timeout: 10_000 }) + await settle(page) +} + +/** + * "Close Settings", the last step of the holiday guides. The ring goes round + * the head band rather than the close button alone: the 960 px dialog is as + * wide as the smallest frame, so a frame centred on the button leaves out the + * band's title and the calendar the guide just added, and the band is still + * what the step names. The pointer rests on the button, so its Close tooltip + * marks where to click inside the ring. + */ +const settingsCloseStep: StepAction = { + target: p => settings(p).locator('header').first(), + hover: async p => { await settings(p).getByRole('button', { name: 'Close' }).hover() }, + act: closeSettings, +} + +// ── A calendar shared with the admin ────────────────────────────────────────── + +/** + * The seeded member whose calendar the share-calendar guide shows as shared + * with the admin. Not jonas: the admin shares with jonas in the same guide, + * and two rows named jonas would hide which one is incoming. + */ +const SHARER = { username: 'mira', email: 'mira@example.com', password: 'DemoSeed12345!' } + +/** + * A request context acting as the member. Its own context with a bearer + * token: a login on the page's context would put the member's session cookie + * on the admin's requests. + */ +async function asMember(member: typeof SHARER): Promise { + const anon = await apiRequest.newContext({ baseURL: E2E_BASE_URL, storageState: undefined }) + const login = await anon.post('/api/auth/login', { data: { email: member.email, password: member.password } }) + const token = login.ok() ? ((await login.json()) as { token?: string }).token : undefined + await anon.dispose() + if (!token) throw new Error(`could not log in as ${member.username}: ${login.status()}`) + return apiRequest.newContext({ + baseURL: E2E_BASE_URL, + storageState: undefined, + extraHTTPHeaders: { Authorization: `Bearer ${token}` }, + }) +} + +/** The member shares her calendar with the admin, as she would from her own Vacay. */ +async function shareWithAdmin(page: Page): Promise { + const me = await page.request.get('/api/auth/me') + const adminId = ((await me.json()) as { user: { id: number } }).user.id + const member = await asMember(SHARER) + try { + const res = await member.post('/api/addons/vacay/shares', { data: { user_id: adminId } }) + // A retried guide finds the share from its first attempt still there. + if (!res.ok() && !(await res.text()).includes('Already shared')) { + throw new Error(`could not share ${SHARER.username}'s calendar with the admin: ${res.status()}`) + } + } finally { + await member.dispose() + } +} + +/** Take back what shareWithAdmin did: the share and the notification it sent the admin. */ +async function unshareWithAdmin(page: Page): Promise { + const shares = (await (await page.request.get('/api/addons/vacay/shares')).json()) as { incoming: { id: number; username: string }[] } + for (const s of shares.incoming.filter(x => x.username === SHARER.username)) { + const res = await page.request.delete(`/api/addons/vacay/shares/${s.id}`) + if (!res.ok()) throw new Error(`could not remove ${SHARER.username}'s share: ${res.status()}`) + } + const inbox = (await (await page.request.get('/api/notifications/in-app?limit=50')).json()) as { + notifications: { id: number; title_key: string; sender_username: string | null }[] + } + for (const n of inbox.notifications.filter(x => x.title_key === 'notif.vacay_share.title' && x.sender_username === SHARER.username)) { + await page.request.delete(`/api/notifications/in-app/${n.id}`) + } +} + /** * Pick an option in a CustomSelect: open it, filter, click the match inside * the open menu. Scoped to the menu on purpose: a calendar row already set to @@ -91,7 +257,7 @@ const SCRIPTS: Record = { await beat(p, 600) // Leave the day logged for the after-picture and the guides that follow. await day(p, NEXT_MONTH, 14).click() - await settle(p) + await toTop(p) }, }, ], @@ -111,7 +277,7 @@ const SCRIPTS: Record = { }, { target: p => p.getByRole('button', { name: 'Half day' }), - act: async p => { await p.getByRole('button', { name: 'Half day' }).click() }, + act: async p => { await p.getByRole('button', { name: 'Half day' }).click(); await toTop(p) }, }, ], }, @@ -130,6 +296,7 @@ const SCRIPTS: Record = { await day(p, NEXT_MONTH, 16).click() await settle(p) await p.getByRole('button', { name: 'Comp / Flex' }).click() + await toTop(p) }, }, ], @@ -144,13 +311,21 @@ const SCRIPTS: Record = { act: async p => { await sideCard(p, 'Entitlement').locator('[role="button"]').filter({ hasText: 'Days' }).first().click() }, }, { - target: p => sideCard(p, 'Entitlement').locator('input'), - act: async p => { + // The seed's entitlement is 30, so typing 30 changed nothing and the + // result showed the Left of before. A different number is typed first, + // so the picture shows it in the field and the result recalculates Left. + prepare: async p => { const input = sideCard(p, 'Entitlement').locator('input') await input.fill('') - await typeInto(p, input, '30') - await input.press('Enter') - await settle(p) + await typeInto(p, input, ENTITLEMENT) + }, + // The whole tile, so the ring and its number sit round the Days label + // and the field rather than on the label. + target: daysTile, + act: async p => { + await sideCard(p, 'Entitlement').locator('input').press('Enter') + await expect(sideCard(p, 'Entitlement').locator('input')).toHaveCount(0) + await toTop(p) }, }, ], @@ -171,13 +346,23 @@ const SCRIPTS: Record = { { target: p => sideCard(p, 'Year').locator('.grid'), act: async p => { + // Over to the new year and back. Staying put would leave the Entitlement + // card on the numbers of the year just added, which Add next year loads + // under the heading of the year still selected; switching reloads them. + await sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR + 1) }).first().click() + await settle(p) await sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR) }).first().click() await settle(p) }, }, { - prepare: async p => { await sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR + 1) }).first().hover() }, - target: p => sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR + 1) }).first().getByRole('button', { name: 'Remove year' }), + // The chip with its minus showing, rather than the minus alone: the step + // says to hover the chip, and a ring round the tiny minus put its number + // on top of the year it belongs to. + prepare: async p => { await yearChip(p, YEAR + 1).hover() }, + target: p => yearChip(p, YEAR + 1), + // With the ring on the chip, this is what still fails the guide when the minus is gone. + act: async p => { await expect(yearChip(p, YEAR + 1).getByRole('button', { name: 'Remove year' })).toBeVisible() }, }, ], cleanup: async p => { @@ -192,9 +377,12 @@ const SCRIPTS: Record = { start: openVacay, steps: [ { - // On by default, so the step only shows where the switch lives. + // On by default, so the step only shows where the switch lives. The ring + // takes the whole row, name and switch, which is what "check that it is + // on" reads off, and it pulls the frame over the dialog instead of the + // page beside it. prepare: openSettings, - target: p => toggleRow(p, 'Company Holidays'), + target: p => settingRow(p, 'Company Holidays'), act: closeSettings, }, { @@ -210,6 +398,7 @@ const SCRIPTS: Record = { await settle(p) // Back to logging vacation, so the next guide starts where a reader would. await toolbar(p).getByRole('button').first().click() + await toTop(p) }, }, ], @@ -220,29 +409,19 @@ const SCRIPTS: Record = { start: openVacay, steps: [ { + // The whole row, as in company-holidays step 1: name and switch together. prepare: openSettings, - target: p => toggleRow(p, 'Public Holidays'), + target: p => settingRow(p, 'Public Holidays'), act: async p => { await toggleRow(p, 'Public Holidays').click(); await settle(p) }, }, { - target: p => settings(p).getByRole('button', { name: 'Add calendar' }).first(), - act: async p => { - await settings(p).getByRole('button', { name: 'Add calendar' }).first().click() - await typeInto(p, settings(p).getByPlaceholder('Label (optional)').last(), 'Berlin') - await pick(p, settings(p).getByRole('button', { name: 'Select country' }), 'Germany') - // The region list loads after the country is picked; give it a moment to show up. - const region = settings(p).getByRole('button', { name: 'Select region (required)' }) - await region.waitFor({ state: 'visible', timeout: 8_000 }).catch(() => {}) - if (await region.isVisible().catch(() => false)) await pick(p, region, 'Berlin') - await expect(settings(p).getByRole('button', { name: 'Add', exact: true })).toBeEnabled({ timeout: 10_000 }) - await settings(p).getByRole('button', { name: 'Add', exact: true }).click() - await settle(p) - }, - }, - { - target: p => settings(p).getByRole('button', { name: 'Close' }), - act: async p => { await closeSettings(p); await settle(p) }, + // The step is about the country and the region, so the picture shows + // the form filled in rather than the button that opens it empty. + prepare: p => fillCalendarForm(p, settings(p).getByRole('button', { name: 'Add calendar' }).first(), 'Berlin'), + target: addCalendarForm, + act: submitCalendarForm, }, + settingsCloseStep, ], }, @@ -252,28 +431,16 @@ const SCRIPTS: Record = { steps: [ { prepare: openSettings, - target: p => toggleRow(p, 'School Holidays'), + target: p => settingRow(p, 'School Holidays'), act: async p => { await toggleRow(p, 'School Holidays').click(); await settle(p) }, }, { - target: p => settings(p).getByRole('button', { name: 'Add calendar' }).last(), - act: async p => { - await settings(p).getByRole('button', { name: 'Add calendar' }).last().click() - await typeInto(p, settings(p).getByPlaceholder('Label (optional)').last(), 'Schools Berlin') - await pick(p, settings(p).getByRole('button', { name: 'Select country' }), 'Germany') - // The region list loads after the country is picked; give it a moment to show up. - const region = settings(p).getByRole('button', { name: 'Select region (required)' }) - await region.waitFor({ state: 'visible', timeout: 8_000 }).catch(() => {}) - if (await region.isVisible().catch(() => false)) await pick(p, region, 'Berlin') - await expect(settings(p).getByRole('button', { name: 'Add', exact: true })).toBeEnabled({ timeout: 10_000 }) - await settings(p).getByRole('button', { name: 'Add', exact: true }).click() - await settle(p) - }, - }, - { - target: p => settings(p).getByRole('button', { name: 'Close' }), - act: async p => { await closeSettings(p); await settle(p) }, + // The last Add calendar: the public-holiday one above it is the first. + prepare: p => fillCalendarForm(p, settings(p).getByRole('button', { name: 'Add calendar' }).last(), 'Schools Berlin'), + target: addCalendarForm, + act: submitCalendarForm, }, + settingsCloseStep, ], }, @@ -287,7 +454,7 @@ const SCRIPTS: Record = { }, { target: p => toggleRow(p, 'Block Weekends'), - act: async p => { await toggleRow(p, 'Block Weekends').click(); await settle(p) }, + act: async p => { await switchOn(p, 'Block Weekends') }, }, { target: p => settings(p).getByText('Week starts on', { exact: true }).locator('xpath=../../..'), @@ -305,7 +472,8 @@ const SCRIPTS: Record = { target: p => settings(p).getByText('Vacation year', { exact: true }).locator('xpath=../../..'), }, { - target: p => settings(p).getByText('Vacation year', { exact: true }).locator('xpath=../../..').getByRole('button').first(), + // The step names all three choices, so the ring goes round the row that holds them. + target: p => settings(p).getByText('Vacation year', { exact: true }).locator('xpath=../../..').getByRole('button').first().locator('xpath=..'), act: closeSettings, }, ], @@ -321,7 +489,7 @@ const SCRIPTS: Record = { }, { target: p => toggleRow(p, 'Carry Over'), - act: async p => { await toggleRow(p, 'Carry Over').click(); await settle(p); await closeSettings(p) }, + act: async p => { await switchOn(p, 'Carry Over'); await closeSettings(p) }, }, ], }, @@ -335,16 +503,25 @@ const SCRIPTS: Record = { act: async p => { await sideCard(p, 'Persons').getByRole('button').first().click(); await settle(p) }, }, { - target: p => dialog(p).getByRole('button', { name: 'Select user' }), - act: async p => { + // Picked before the picture, so it shows mira chosen and Send Invite + // ready, which is the button the step ends on; the dialog closes on send, + // so no later picture shows the choice. + prepare: async p => { await pick(p, dialog(p).getByRole('button', { name: 'Select user' }), 'mira (mira@example.com)') + await expect(dialog(p).getByRole('button', { name: 'Send Invite' })).toBeEnabled() + }, + target: p => dialog(p).getByRole('button', { name: 'Send Invite' }), + act: async p => { await dialog(p).getByRole('button', { name: 'Send Invite' }).click() await expect(dialog(p)).toBeHidden({ timeout: 15_000 }) await settle(p) }, }, { - target: p => sideCard(p, 'Persons').getByText('mira', { exact: false }).first(), + // The whole row, with the pending chip the step is about. The pointer + // stays off it, or the row's Cancel button shows as well. + target: p => cardRow(p, 'Persons', 'mira'), + hover: parkPointer, }, ], }, @@ -358,18 +535,34 @@ const SCRIPTS: Record = { act: async p => { await sideCard(p, 'Shared Calendars').getByTitle('Share calendar').click(); await settle(p) }, }, { - target: p => dialog(p).getByRole('button', { name: 'Select user' }), - act: async p => { + // As in the invite guide: jonas picked and Share ready in the picture. + prepare: async p => { await pick(p, dialog(p).getByRole('button', { name: 'Select user' }), 'jonas') + await expect(dialog(p).getByRole('button', { name: 'Share', exact: true })).toBeEnabled() + }, + target: p => dialog(p).getByRole('button', { name: 'Share', exact: true }), + act: async p => { await dialog(p).getByRole('button', { name: 'Share', exact: true }).click() await expect(dialog(p)).toBeHidden({ timeout: 15_000 }) await settle(p) }, }, { + // The step also names calendars shared with you, and the seed has none, + // so mira shares hers with the admin first. The page learns of it over + // the socket; a reload is the fallback should that message not come. + prepare: async p => { + await shareWithAdmin(p) + const incoming = cardRow(p, 'Shared Calendars', SHARER.username) + if (!(await incoming.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true, () => false))) await openVacay(p) + await expect(incoming).toBeVisible() + }, target: p => sideCard(p, 'Shared Calendars'), + // On the row the admin shares, so its Stop sharing button shows. + hover: async p => { await cardRow(p, 'Shared Calendars', 'jonas').hover() }, }, ], + cleanup: unshareWithAdmin, }, } diff --git a/client/public/help-media/add-entry/result.webp b/client/public/help-media/add-entry/result.webp index 061511979..3dbc103c2 100644 Binary files a/client/public/help-media/add-entry/result.webp and b/client/public/help-media/add-entry/result.webp differ diff --git a/client/public/help-media/add-entry/step-1.webp b/client/public/help-media/add-entry/step-1.webp index 5ec6eee2e..769baf518 100644 Binary files a/client/public/help-media/add-entry/step-1.webp and b/client/public/help-media/add-entry/step-1.webp differ diff --git a/client/public/help-media/add-entry/step-2.webp b/client/public/help-media/add-entry/step-2.webp index 11edf267e..8a002aa36 100644 Binary files a/client/public/help-media/add-entry/step-2.webp and b/client/public/help-media/add-entry/step-2.webp differ diff --git a/client/public/help-media/add-entry/step-3.webp b/client/public/help-media/add-entry/step-3.webp index fe141dda7..a3e292944 100644 Binary files a/client/public/help-media/add-entry/step-3.webp and b/client/public/help-media/add-entry/step-3.webp differ diff --git a/client/public/help-media/add-entry/step-4.webp b/client/public/help-media/add-entry/step-4.webp index 647e8dbb2..a9e02d4f1 100644 Binary files a/client/public/help-media/add-entry/step-4.webp and b/client/public/help-media/add-entry/step-4.webp differ diff --git a/client/public/help-media/add-on-day/step-1.webp b/client/public/help-media/add-on-day/step-1.webp index 285307587..ee9091469 100644 Binary files a/client/public/help-media/add-on-day/step-1.webp and b/client/public/help-media/add-on-day/step-1.webp differ diff --git a/client/public/help-media/add-on-day/step-2.webp b/client/public/help-media/add-on-day/step-2.webp index ebe9fd143..c6cb06554 100644 Binary files a/client/public/help-media/add-on-day/step-2.webp and b/client/public/help-media/add-on-day/step-2.webp differ diff --git a/client/public/help-media/add-place/result.webp b/client/public/help-media/add-place/result.webp index 63bcfc6f3..7b742b5c9 100644 Binary files a/client/public/help-media/add-place/result.webp and b/client/public/help-media/add-place/result.webp differ diff --git a/client/public/help-media/add-place/step-2.webp b/client/public/help-media/add-place/step-2.webp index 5786f830c..f88839aa6 100644 Binary files a/client/public/help-media/add-place/step-2.webp and b/client/public/help-media/add-place/step-2.webp differ diff --git a/client/public/help-media/add-place/step-3.webp b/client/public/help-media/add-place/step-3.webp index 2a8159881..0854e2f76 100644 Binary files a/client/public/help-media/add-place/step-3.webp and b/client/public/help-media/add-place/step-3.webp differ diff --git a/client/public/help-media/all-saved/step-1.webp b/client/public/help-media/all-saved/step-1.webp index c371f829c..dc0507686 100644 Binary files a/client/public/help-media/all-saved/step-1.webp and b/client/public/help-media/all-saved/step-1.webp differ diff --git a/client/public/help-media/all-saved/step-2.webp b/client/public/help-media/all-saved/step-2.webp index aa3f59568..5bde01a8d 100644 Binary files a/client/public/help-media/all-saved/step-2.webp and b/client/public/help-media/all-saved/step-2.webp differ diff --git a/client/public/help-media/archive-journey/step-1.webp b/client/public/help-media/archive-journey/step-1.webp index 28054e99b..f4988e094 100644 Binary files a/client/public/help-media/archive-journey/step-1.webp and b/client/public/help-media/archive-journey/step-1.webp differ diff --git a/client/public/help-media/archive-journey/step-2.webp b/client/public/help-media/archive-journey/step-2.webp index e5183e976..d8ae4dd72 100644 Binary files a/client/public/help-media/archive-journey/step-2.webp and b/client/public/help-media/archive-journey/step-2.webp differ diff --git a/client/public/help-media/bucket-country/step-2.webp b/client/public/help-media/bucket-country/step-2.webp index 3baf2b073..cdf73e421 100644 Binary files a/client/public/help-media/bucket-country/step-2.webp and b/client/public/help-media/bucket-country/step-2.webp differ diff --git a/client/public/help-media/bucket-country/step-3.webp b/client/public/help-media/bucket-country/step-3.webp index 56037a184..3b08d48ec 100644 Binary files a/client/public/help-media/bucket-country/step-3.webp and b/client/public/help-media/bucket-country/step-3.webp differ diff --git a/client/public/help-media/bucket-place/step-3.webp b/client/public/help-media/bucket-place/step-3.webp index 3cfa249ef..f6dc777a5 100644 Binary files a/client/public/help-media/bucket-place/step-3.webp and b/client/public/help-media/bucket-place/step-3.webp differ diff --git a/client/public/help-media/bucket-place/step-4.webp b/client/public/help-media/bucket-place/step-4.webp index c8dddf386..50550dfce 100644 Binary files a/client/public/help-media/bucket-place/step-4.webp and b/client/public/help-media/bucket-place/step-4.webp differ diff --git a/client/public/help-media/carry-over/step-1.webp b/client/public/help-media/carry-over/step-1.webp index 1fc89198d..ee186e59a 100644 Binary files a/client/public/help-media/carry-over/step-1.webp and b/client/public/help-media/carry-over/step-1.webp differ diff --git a/client/public/help-media/carry-over/step-2.webp b/client/public/help-media/carry-over/step-2.webp index 3dc9faca2..2e1f26e50 100644 Binary files a/client/public/help-media/carry-over/step-2.webp and b/client/public/help-media/carry-over/step-2.webp differ diff --git a/client/public/help-media/comp-day/result.webp b/client/public/help-media/comp-day/result.webp index 2c99d157b..55de5ad05 100644 Binary files a/client/public/help-media/comp-day/result.webp and b/client/public/help-media/comp-day/result.webp differ diff --git a/client/public/help-media/comp-day/step-1.webp b/client/public/help-media/comp-day/step-1.webp index 144c6e41b..aaea19020 100644 Binary files a/client/public/help-media/comp-day/step-1.webp and b/client/public/help-media/comp-day/step-1.webp differ diff --git a/client/public/help-media/comp-day/step-2.webp b/client/public/help-media/comp-day/step-2.webp index 6a3ffd65f..1e2f0b70f 100644 Binary files a/client/public/help-media/comp-day/step-2.webp and b/client/public/help-media/comp-day/step-2.webp differ diff --git a/client/public/help-media/company-holidays/result.webp b/client/public/help-media/company-holidays/result.webp index ac0d268a4..44fbb1005 100644 Binary files a/client/public/help-media/company-holidays/result.webp and b/client/public/help-media/company-holidays/result.webp differ diff --git a/client/public/help-media/company-holidays/step-1.webp b/client/public/help-media/company-holidays/step-1.webp index 552bcbf83..f39462f65 100644 Binary files a/client/public/help-media/company-holidays/step-1.webp and b/client/public/help-media/company-holidays/step-1.webp differ diff --git a/client/public/help-media/company-holidays/step-2.webp b/client/public/help-media/company-holidays/step-2.webp index b3f084779..8df29dc5a 100644 Binary files a/client/public/help-media/company-holidays/step-2.webp and b/client/public/help-media/company-holidays/step-2.webp differ diff --git a/client/public/help-media/company-holidays/step-3.webp b/client/public/help-media/company-holidays/step-3.webp index bfc669939..b1ef37c39 100644 Binary files a/client/public/help-media/company-holidays/step-3.webp and b/client/public/help-media/company-holidays/step-3.webp differ diff --git a/client/public/help-media/continue-writing/step-1.webp b/client/public/help-media/continue-writing/step-1.webp index 17af59df4..7de20a055 100644 Binary files a/client/public/help-media/continue-writing/step-1.webp and b/client/public/help-media/continue-writing/step-1.webp differ diff --git a/client/public/help-media/contributors/step-1.webp b/client/public/help-media/contributors/step-1.webp index 26b3e4ab1..5f0e97e77 100644 Binary files a/client/public/help-media/contributors/step-1.webp and b/client/public/help-media/contributors/step-1.webp differ diff --git a/client/public/help-media/contributors/step-2.webp b/client/public/help-media/contributors/step-2.webp index 66619dba1..2c5078f16 100644 Binary files a/client/public/help-media/contributors/step-2.webp and b/client/public/help-media/contributors/step-2.webp differ diff --git a/client/public/help-media/contributors/step-3.webp b/client/public/help-media/contributors/step-3.webp index 78e29ac2f..01b7b9c7f 100644 Binary files a/client/public/help-media/contributors/step-3.webp and b/client/public/help-media/contributors/step-3.webp differ diff --git a/client/public/help-media/copy-to-trip/result.webp b/client/public/help-media/copy-to-trip/result.webp index 6c11b3d90..be32b6c21 100644 Binary files a/client/public/help-media/copy-to-trip/result.webp and b/client/public/help-media/copy-to-trip/result.webp differ diff --git a/client/public/help-media/copy-to-trip/step-1.webp b/client/public/help-media/copy-to-trip/step-1.webp index 152ed73ab..fbdb34faa 100644 Binary files a/client/public/help-media/copy-to-trip/step-1.webp and b/client/public/help-media/copy-to-trip/step-1.webp differ diff --git a/client/public/help-media/copy-to-trip/step-2.webp b/client/public/help-media/copy-to-trip/step-2.webp index 48f808364..4e8d64380 100644 Binary files a/client/public/help-media/copy-to-trip/step-2.webp and b/client/public/help-media/copy-to-trip/step-2.webp differ diff --git a/client/public/help-media/copy-to-trip/step-3.webp b/client/public/help-media/copy-to-trip/step-3.webp index 78649cffb..bcccf3cc5 100644 Binary files a/client/public/help-media/copy-to-trip/step-3.webp and b/client/public/help-media/copy-to-trip/step-3.webp differ diff --git a/client/public/help-media/country-details/result.webp b/client/public/help-media/country-details/result.webp index 43a10a260..f373e868b 100644 Binary files a/client/public/help-media/country-details/result.webp and b/client/public/help-media/country-details/result.webp differ diff --git a/client/public/help-media/create-journey/result.webp b/client/public/help-media/create-journey/result.webp index c365a01a4..9c82867f8 100644 Binary files a/client/public/help-media/create-journey/result.webp and b/client/public/help-media/create-journey/result.webp differ diff --git a/client/public/help-media/create-journey/step-2.webp b/client/public/help-media/create-journey/step-2.webp index e6eba88db..c8b463dab 100644 Binary files a/client/public/help-media/create-journey/step-2.webp and b/client/public/help-media/create-journey/step-2.webp differ diff --git a/client/public/help-media/create-journey/step-3.webp b/client/public/help-media/create-journey/step-3.webp index 456f96a93..b5fc3da28 100644 Binary files a/client/public/help-media/create-journey/step-3.webp and b/client/public/help-media/create-journey/step-3.webp differ diff --git a/client/public/help-media/create-list/result.webp b/client/public/help-media/create-list/result.webp index bb4cc8a6c..75a74801f 100644 Binary files a/client/public/help-media/create-list/result.webp and b/client/public/help-media/create-list/result.webp differ diff --git a/client/public/help-media/create-list/step-2.webp b/client/public/help-media/create-list/step-2.webp index e17a01798..1f1ed1378 100644 Binary files a/client/public/help-media/create-list/step-2.webp and b/client/public/help-media/create-list/step-2.webp differ diff --git a/client/public/help-media/create-list/step-3.webp b/client/public/help-media/create-list/step-3.webp index 079066e29..3b2a32297 100644 Binary files a/client/public/help-media/create-list/step-3.webp and b/client/public/help-media/create-list/step-3.webp differ diff --git a/client/public/help-media/ctx/collections.webp b/client/public/help-media/ctx/collections.webp index 53a6812d1..ba3f16af9 100644 Binary files a/client/public/help-media/ctx/collections.webp and b/client/public/help-media/ctx/collections.webp differ diff --git a/client/public/help-media/ctx/journey-detail.webp b/client/public/help-media/ctx/journey-detail.webp index b039fce62..9b1b2f0c8 100644 Binary files a/client/public/help-media/ctx/journey-detail.webp and b/client/public/help-media/ctx/journey-detail.webp differ diff --git a/client/public/help-media/ctx/journey-studio.webp b/client/public/help-media/ctx/journey-studio.webp index e13b2d70f..ecb1567eb 100644 Binary files a/client/public/help-media/ctx/journey-studio.webp and b/client/public/help-media/ctx/journey-studio.webp differ diff --git a/client/public/help-media/ctx/vacay.webp b/client/public/help-media/ctx/vacay.webp index d2d2e27e4..53eadcf39 100644 Binary files a/client/public/help-media/ctx/vacay.webp and b/client/public/help-media/ctx/vacay.webp differ diff --git a/client/public/help-media/dawarich-countries/step-2.webp b/client/public/help-media/dawarich-countries/step-2.webp index 464870a2b..179071c2f 100644 Binary files a/client/public/help-media/dawarich-countries/step-2.webp and b/client/public/help-media/dawarich-countries/step-2.webp differ diff --git a/client/public/help-media/dawarich-countries/step-3.webp b/client/public/help-media/dawarich-countries/step-3.webp index 3b6c2c6e8..4d94c64e8 100644 Binary files a/client/public/help-media/dawarich-countries/step-3.webp and b/client/public/help-media/dawarich-countries/step-3.webp differ diff --git a/client/public/help-media/dawarich-wishes/result.webp b/client/public/help-media/dawarich-wishes/result.webp index 732bb28d8..ae7605671 100644 Binary files a/client/public/help-media/dawarich-wishes/result.webp and b/client/public/help-media/dawarich-wishes/result.webp differ diff --git a/client/public/help-media/dawarich-wishes/step-2.webp b/client/public/help-media/dawarich-wishes/step-2.webp index 239486b76..93e2237db 100644 Binary files a/client/public/help-media/dawarich-wishes/step-2.webp and b/client/public/help-media/dawarich-wishes/step-2.webp differ diff --git a/client/public/help-media/dawarich-wishes/step-3.webp b/client/public/help-media/dawarich-wishes/step-3.webp index 860eacaf4..82244ac7f 100644 Binary files a/client/public/help-media/dawarich-wishes/step-3.webp and b/client/public/help-media/dawarich-wishes/step-3.webp differ diff --git a/client/public/help-media/edit-list/result.webp b/client/public/help-media/edit-list/result.webp index 66ae821cf..1ee68304a 100644 Binary files a/client/public/help-media/edit-list/result.webp and b/client/public/help-media/edit-list/result.webp differ diff --git a/client/public/help-media/edit-list/step-1.webp b/client/public/help-media/edit-list/step-1.webp index a46778d68..d89b81fed 100644 Binary files a/client/public/help-media/edit-list/step-1.webp and b/client/public/help-media/edit-list/step-1.webp differ diff --git a/client/public/help-media/edit-list/step-2.webp b/client/public/help-media/edit-list/step-2.webp index 7145c1b42..4dd06b42a 100644 Binary files a/client/public/help-media/edit-list/step-2.webp and b/client/public/help-media/edit-list/step-2.webp differ diff --git a/client/public/help-media/entitlement/result.webp b/client/public/help-media/entitlement/result.webp index 2c99d157b..132e956f2 100644 Binary files a/client/public/help-media/entitlement/result.webp and b/client/public/help-media/entitlement/result.webp differ diff --git a/client/public/help-media/entitlement/step-1.webp b/client/public/help-media/entitlement/step-1.webp index ce19ee7f3..90c7f2bfe 100644 Binary files a/client/public/help-media/entitlement/step-1.webp and b/client/public/help-media/entitlement/step-1.webp differ diff --git a/client/public/help-media/entitlement/step-2.webp b/client/public/help-media/entitlement/step-2.webp index a83dcb5e4..29c784b81 100644 Binary files a/client/public/help-media/entitlement/step-2.webp and b/client/public/help-media/entitlement/step-2.webp differ diff --git a/client/public/help-media/entry-fields/step-1.webp b/client/public/help-media/entry-fields/step-1.webp index 28054e99b..f4988e094 100644 Binary files a/client/public/help-media/entry-fields/step-1.webp and b/client/public/help-media/entry-fields/step-1.webp differ diff --git a/client/public/help-media/entry-fields/step-2.webp b/client/public/help-media/entry-fields/step-2.webp index bc0b9241a..ffda95f6a 100644 Binary files a/client/public/help-media/entry-fields/step-2.webp and b/client/public/help-media/entry-fields/step-2.webp differ diff --git a/client/public/help-media/entry-photos/result.webp b/client/public/help-media/entry-photos/result.webp index 70ceefe28..a500bc11b 100644 Binary files a/client/public/help-media/entry-photos/result.webp and b/client/public/help-media/entry-photos/result.webp differ diff --git a/client/public/help-media/entry-photos/step-1.webp b/client/public/help-media/entry-photos/step-1.webp index 3d947ac6b..e75fd58e2 100644 Binary files a/client/public/help-media/entry-photos/step-1.webp and b/client/public/help-media/entry-photos/step-1.webp differ diff --git a/client/public/help-media/entry-photos/step-2.webp b/client/public/help-media/entry-photos/step-2.webp index 351e24af2..d850ec704 100644 Binary files a/client/public/help-media/entry-photos/step-2.webp and b/client/public/help-media/entry-photos/step-2.webp differ diff --git a/client/public/help-media/entry-photos/step-3.webp b/client/public/help-media/entry-photos/step-3.webp index bec78d7d7..43e887c42 100644 Binary files a/client/public/help-media/entry-photos/step-3.webp and b/client/public/help-media/entry-photos/step-3.webp differ diff --git a/client/public/help-media/export-list/step-1.webp b/client/public/help-media/export-list/step-1.webp index 2d6103a8b..1f9ecba84 100644 Binary files a/client/public/help-media/export-list/step-1.webp and b/client/public/help-media/export-list/step-1.webp differ diff --git a/client/public/help-media/export-list/step-2.webp b/client/public/help-media/export-list/step-2.webp index 3359237b4..7583ad9f5 100644 Binary files a/client/public/help-media/export-list/step-2.webp and b/client/public/help-media/export-list/step-2.webp differ diff --git a/client/public/help-media/filter-select/result.webp b/client/public/help-media/filter-select/result.webp index d60a04839..5835d85ae 100644 Binary files a/client/public/help-media/filter-select/result.webp and b/client/public/help-media/filter-select/result.webp differ diff --git a/client/public/help-media/filter-select/step-1.webp b/client/public/help-media/filter-select/step-1.webp index 413aa4305..c81a3374a 100644 Binary files a/client/public/help-media/filter-select/step-1.webp and b/client/public/help-media/filter-select/step-1.webp differ diff --git a/client/public/help-media/filter-select/step-2.webp b/client/public/help-media/filter-select/step-2.webp index 4963ed364..83fa9a68e 100644 Binary files a/client/public/help-media/filter-select/step-2.webp and b/client/public/help-media/filter-select/step-2.webp differ diff --git a/client/public/help-media/filter-select/step-3.webp b/client/public/help-media/filter-select/step-3.webp index ac8b7119e..30f078fbf 100644 Binary files a/client/public/help-media/filter-select/step-3.webp and b/client/public/help-media/filter-select/step-3.webp differ diff --git a/client/public/help-media/gallery-map/step-1.webp b/client/public/help-media/gallery-map/step-1.webp index 82cb619b5..314fadd11 100644 Binary files a/client/public/help-media/gallery-map/step-1.webp and b/client/public/help-media/gallery-map/step-1.webp differ diff --git a/client/public/help-media/gallery-map/step-2.webp b/client/public/help-media/gallery-map/step-2.webp index 3aae3bda2..572729cc1 100644 Binary files a/client/public/help-media/gallery-map/step-2.webp and b/client/public/help-media/gallery-map/step-2.webp differ diff --git a/client/public/help-media/half-day/result.webp b/client/public/help-media/half-day/result.webp index d0351fd8b..4d46aea0e 100644 Binary files a/client/public/help-media/half-day/result.webp and b/client/public/help-media/half-day/result.webp differ diff --git a/client/public/help-media/half-day/step-1.webp b/client/public/help-media/half-day/step-1.webp index f1012d4d3..5d068d2c3 100644 Binary files a/client/public/help-media/half-day/step-1.webp and b/client/public/help-media/half-day/step-1.webp differ diff --git a/client/public/help-media/half-day/step-2.webp b/client/public/help-media/half-day/step-2.webp index 37818861f..4239250e3 100644 Binary files a/client/public/help-media/half-day/step-2.webp and b/client/public/help-media/half-day/step-2.webp differ diff --git a/client/public/help-media/half-day/step-3.webp b/client/public/help-media/half-day/step-3.webp index 8d044eac5..3b791aea4 100644 Binary files a/client/public/help-media/half-day/step-3.webp and b/client/public/help-media/half-day/step-3.webp differ diff --git a/client/public/help-media/import-file/result.webp b/client/public/help-media/import-file/result.webp index 3997260c2..80f6a8071 100644 Binary files a/client/public/help-media/import-file/result.webp and b/client/public/help-media/import-file/result.webp differ diff --git a/client/public/help-media/import-file/step-1.webp b/client/public/help-media/import-file/step-1.webp index 1ae21a39e..f7dfa7fa1 100644 Binary files a/client/public/help-media/import-file/step-1.webp and b/client/public/help-media/import-file/step-1.webp differ diff --git a/client/public/help-media/import-file/step-2.webp b/client/public/help-media/import-file/step-2.webp index dbf9637ed..b47cf1302 100644 Binary files a/client/public/help-media/import-file/step-2.webp and b/client/public/help-media/import-file/step-2.webp differ diff --git a/client/public/help-media/import-file/step-3.webp b/client/public/help-media/import-file/step-3.webp index 54cd3802b..7817e923a 100644 Binary files a/client/public/help-media/import-file/step-3.webp and b/client/public/help-media/import-file/step-3.webp differ diff --git a/client/public/help-media/import-from-trip/result.webp b/client/public/help-media/import-from-trip/result.webp index 96aa46712..d5bbf46e3 100644 Binary files a/client/public/help-media/import-from-trip/result.webp and b/client/public/help-media/import-from-trip/result.webp differ diff --git a/client/public/help-media/import-from-trip/step-1.webp b/client/public/help-media/import-from-trip/step-1.webp index f4fb0f4b7..aa705b508 100644 Binary files a/client/public/help-media/import-from-trip/step-1.webp and b/client/public/help-media/import-from-trip/step-1.webp differ diff --git a/client/public/help-media/import-from-trip/step-2.webp b/client/public/help-media/import-from-trip/step-2.webp index f4eaaf8b4..ccdfff2c5 100644 Binary files a/client/public/help-media/import-from-trip/step-2.webp and b/client/public/help-media/import-from-trip/step-2.webp differ diff --git a/client/public/help-media/import-from-trip/step-3.webp b/client/public/help-media/import-from-trip/step-3.webp index eee8f932b..7c2351721 100644 Binary files a/client/public/help-media/import-from-trip/step-3.webp and b/client/public/help-media/import-from-trip/step-3.webp differ diff --git a/client/public/help-media/import-from-trip/step-4.webp b/client/public/help-media/import-from-trip/step-4.webp index e88e01c47..989ac0c63 100644 Binary files a/client/public/help-media/import-from-trip/step-4.webp and b/client/public/help-media/import-from-trip/step-4.webp differ diff --git a/client/public/help-media/invite/result.webp b/client/public/help-media/invite/result.webp index 968c12816..e3f4acebe 100644 Binary files a/client/public/help-media/invite/result.webp and b/client/public/help-media/invite/result.webp differ diff --git a/client/public/help-media/invite/step-1.webp b/client/public/help-media/invite/step-1.webp index 2eb5723a0..bb749a60a 100644 Binary files a/client/public/help-media/invite/step-1.webp and b/client/public/help-media/invite/step-1.webp differ diff --git a/client/public/help-media/invite/step-2.webp b/client/public/help-media/invite/step-2.webp index b238f246a..154940358 100644 Binary files a/client/public/help-media/invite/step-2.webp and b/client/public/help-media/invite/step-2.webp differ diff --git a/client/public/help-media/invite/step-3.webp b/client/public/help-media/invite/step-3.webp index cef70e405..31434630c 100644 Binary files a/client/public/help-media/invite/step-3.webp and b/client/public/help-media/invite/step-3.webp differ diff --git a/client/public/help-media/labels/result.webp b/client/public/help-media/labels/result.webp index bf705eb17..39ec45530 100644 Binary files a/client/public/help-media/labels/result.webp and b/client/public/help-media/labels/result.webp differ diff --git a/client/public/help-media/labels/step-1.webp b/client/public/help-media/labels/step-1.webp index bdfe85eee..16eddfa69 100644 Binary files a/client/public/help-media/labels/step-1.webp and b/client/public/help-media/labels/step-1.webp differ diff --git a/client/public/help-media/labels/step-2.webp b/client/public/help-media/labels/step-2.webp index 2165dc961..e39ebf666 100644 Binary files a/client/public/help-media/labels/step-2.webp and b/client/public/help-media/labels/step-2.webp differ diff --git a/client/public/help-media/labels/step-3.webp b/client/public/help-media/labels/step-3.webp index 78b1889cb..e59ee1725 100644 Binary files a/client/public/help-media/labels/step-3.webp and b/client/public/help-media/labels/step-3.webp differ diff --git a/client/public/help-media/labels/step-4.webp b/client/public/help-media/labels/step-4.webp index 027580654..a9c622f92 100644 Binary files a/client/public/help-media/labels/step-4.webp and b/client/public/help-media/labels/step-4.webp differ diff --git a/client/public/help-media/leave-year/step-1.webp b/client/public/help-media/leave-year/step-1.webp index c797d5045..cab262ab7 100644 Binary files a/client/public/help-media/leave-year/step-1.webp and b/client/public/help-media/leave-year/step-1.webp differ diff --git a/client/public/help-media/leave-year/step-2.webp b/client/public/help-media/leave-year/step-2.webp index 77ccc9397..1078bdc57 100644 Binary files a/client/public/help-media/leave-year/step-2.webp and b/client/public/help-media/leave-year/step-2.webp differ diff --git a/client/public/help-media/link-trip/result.webp b/client/public/help-media/link-trip/result.webp index b6ecb0725..7fdb7617a 100644 Binary files a/client/public/help-media/link-trip/result.webp and b/client/public/help-media/link-trip/result.webp differ diff --git a/client/public/help-media/link-trip/step-1.webp b/client/public/help-media/link-trip/step-1.webp index 28054e99b..f4988e094 100644 Binary files a/client/public/help-media/link-trip/step-1.webp and b/client/public/help-media/link-trip/step-1.webp differ diff --git a/client/public/help-media/link-trip/step-2.webp b/client/public/help-media/link-trip/step-2.webp index 362c05805..573ef578f 100644 Binary files a/client/public/help-media/link-trip/step-2.webp and b/client/public/help-media/link-trip/step-2.webp differ diff --git a/client/public/help-media/link-trip/step-3.webp b/client/public/help-media/link-trip/step-3.webp index fa7909b84..289d708f4 100644 Binary files a/client/public/help-media/link-trip/step-3.webp and b/client/public/help-media/link-trip/step-3.webp differ diff --git a/client/public/help-media/log-day/result.webp b/client/public/help-media/log-day/result.webp index e961d27f3..0f8a8b084 100644 Binary files a/client/public/help-media/log-day/result.webp and b/client/public/help-media/log-day/result.webp differ diff --git a/client/public/help-media/log-day/step-1.webp b/client/public/help-media/log-day/step-1.webp index 923c99917..34d2bf69a 100644 Binary files a/client/public/help-media/log-day/step-1.webp and b/client/public/help-media/log-day/step-1.webp differ diff --git a/client/public/help-media/log-day/step-2.webp b/client/public/help-media/log-day/step-2.webp index 75e111caa..cba5efc6e 100644 Binary files a/client/public/help-media/log-day/step-2.webp and b/client/public/help-media/log-day/step-2.webp differ diff --git a/client/public/help-media/log-day/step-3.webp b/client/public/help-media/log-day/step-3.webp index a3d684a3d..6b731c15f 100644 Binary files a/client/public/help-media/log-day/step-3.webp and b/client/public/help-media/log-day/step-3.webp differ diff --git a/client/public/help-media/mark-country/result.webp b/client/public/help-media/mark-country/result.webp index c17b353d9..2ff525a82 100644 Binary files a/client/public/help-media/mark-country/result.webp and b/client/public/help-media/mark-country/result.webp differ diff --git a/client/public/help-media/mark-country/step-2.webp b/client/public/help-media/mark-country/step-2.webp index b2e2a23a3..5fced89bf 100644 Binary files a/client/public/help-media/mark-country/step-2.webp and b/client/public/help-media/mark-country/step-2.webp differ diff --git a/client/public/help-media/mark-country/step-3.webp b/client/public/help-media/mark-country/step-3.webp index 990fee8c5..182397a07 100644 Binary files a/client/public/help-media/mark-country/step-3.webp and b/client/public/help-media/mark-country/step-3.webp differ diff --git a/client/public/help-media/open-journey/step-1.webp b/client/public/help-media/open-journey/step-1.webp index 2101ee6ce..ef7ddafa6 100644 Binary files a/client/public/help-media/open-journey/step-1.webp and b/client/public/help-media/open-journey/step-1.webp differ diff --git a/client/public/help-media/place-detail/step-1.webp b/client/public/help-media/place-detail/step-1.webp index c71b955b6..a05b67040 100644 Binary files a/client/public/help-media/place-detail/step-1.webp and b/client/public/help-media/place-detail/step-1.webp differ diff --git a/client/public/help-media/place-detail/step-2.webp b/client/public/help-media/place-detail/step-2.webp index 201a711ed..c2debb976 100644 Binary files a/client/public/help-media/place-detail/step-2.webp and b/client/public/help-media/place-detail/step-2.webp differ diff --git a/client/public/help-media/place-status/result.webp b/client/public/help-media/place-status/result.webp index 69cd6ecb4..827625e4c 100644 Binary files a/client/public/help-media/place-status/result.webp and b/client/public/help-media/place-status/result.webp differ diff --git a/client/public/help-media/place-status/step-1.webp b/client/public/help-media/place-status/step-1.webp index 67045c4c4..d5e38338a 100644 Binary files a/client/public/help-media/place-status/step-1.webp and b/client/public/help-media/place-status/step-1.webp differ diff --git a/client/public/help-media/place-status/step-2.webp b/client/public/help-media/place-status/step-2.webp index 125c7e539..29a557446 100644 Binary files a/client/public/help-media/place-status/step-2.webp and b/client/public/help-media/place-status/step-2.webp differ diff --git a/client/public/help-media/planned-countries/result.webp b/client/public/help-media/planned-countries/result.webp index a5d871fb3..e857ed030 100644 Binary files a/client/public/help-media/planned-countries/result.webp and b/client/public/help-media/planned-countries/result.webp differ diff --git a/client/public/help-media/planned-countries/step-2.webp b/client/public/help-media/planned-countries/step-2.webp index 8b265f9ac..80bb5167c 100644 Binary files a/client/public/help-media/planned-countries/step-2.webp and b/client/public/help-media/planned-countries/step-2.webp differ diff --git a/client/public/help-media/pros-cons/result.webp b/client/public/help-media/pros-cons/result.webp index b6ecb0725..ee3ad25d2 100644 Binary files a/client/public/help-media/pros-cons/result.webp and b/client/public/help-media/pros-cons/result.webp differ diff --git a/client/public/help-media/pros-cons/step-1.webp b/client/public/help-media/pros-cons/step-1.webp index 366f24bd1..f0978fe25 100644 Binary files a/client/public/help-media/pros-cons/step-1.webp and b/client/public/help-media/pros-cons/step-1.webp differ diff --git a/client/public/help-media/pros-cons/step-2.webp b/client/public/help-media/pros-cons/step-2.webp index 0b9ba86ed..9c568393b 100644 Binary files a/client/public/help-media/pros-cons/step-2.webp and b/client/public/help-media/pros-cons/step-2.webp differ diff --git a/client/public/help-media/public-holidays/result.webp b/client/public/help-media/public-holidays/result.webp index 0179eeb3d..40ecb0801 100644 Binary files a/client/public/help-media/public-holidays/result.webp and b/client/public/help-media/public-holidays/result.webp differ diff --git a/client/public/help-media/public-holidays/step-1.webp b/client/public/help-media/public-holidays/step-1.webp index 0c51a3413..681183f65 100644 Binary files a/client/public/help-media/public-holidays/step-1.webp and b/client/public/help-media/public-holidays/step-1.webp differ diff --git a/client/public/help-media/public-holidays/step-2.webp b/client/public/help-media/public-holidays/step-2.webp index 0d3154837..e34e2a6fd 100644 Binary files a/client/public/help-media/public-holidays/step-2.webp and b/client/public/help-media/public-holidays/step-2.webp differ diff --git a/client/public/help-media/public-holidays/step-3.webp b/client/public/help-media/public-holidays/step-3.webp index 46b6f5833..72a8e18f5 100644 Binary files a/client/public/help-media/public-holidays/step-3.webp and b/client/public/help-media/public-holidays/step-3.webp differ diff --git a/client/public/help-media/regions/step-2.webp b/client/public/help-media/regions/step-2.webp index 139e9d65c..510ac06bc 100644 Binary files a/client/public/help-media/regions/step-2.webp and b/client/public/help-media/regions/step-2.webp differ diff --git a/client/public/help-media/regions/step-3.webp b/client/public/help-media/regions/step-3.webp index a72154431..89a84dfa0 100644 Binary files a/client/public/help-media/regions/step-3.webp and b/client/public/help-media/regions/step-3.webp differ diff --git a/client/public/help-media/school-holidays/result.webp b/client/public/help-media/school-holidays/result.webp index 23cde2b46..3649808c2 100644 Binary files a/client/public/help-media/school-holidays/result.webp and b/client/public/help-media/school-holidays/result.webp differ diff --git a/client/public/help-media/school-holidays/step-1.webp b/client/public/help-media/school-holidays/step-1.webp index dfeba0424..570b19f3b 100644 Binary files a/client/public/help-media/school-holidays/step-1.webp and b/client/public/help-media/school-holidays/step-1.webp differ diff --git a/client/public/help-media/school-holidays/step-2.webp b/client/public/help-media/school-holidays/step-2.webp index 48f28a87a..994b28661 100644 Binary files a/client/public/help-media/school-holidays/step-2.webp and b/client/public/help-media/school-holidays/step-2.webp differ diff --git a/client/public/help-media/school-holidays/step-3.webp b/client/public/help-media/school-holidays/step-3.webp index 511129ec6..87df5d201 100644 Binary files a/client/public/help-media/school-holidays/step-3.webp and b/client/public/help-media/school-holidays/step-3.webp differ diff --git a/client/public/help-media/search-journey/step-1.webp b/client/public/help-media/search-journey/step-1.webp index 3aad1c67c..f17c956dd 100644 Binary files a/client/public/help-media/search-journey/step-1.webp and b/client/public/help-media/search-journey/step-1.webp differ diff --git a/client/public/help-media/search-journey/step-2.webp b/client/public/help-media/search-journey/step-2.webp index db7a6c7fb..cb09fe1c1 100644 Binary files a/client/public/help-media/search-journey/step-2.webp and b/client/public/help-media/search-journey/step-2.webp differ diff --git a/client/public/help-media/search-place/step-3.webp b/client/public/help-media/search-place/step-3.webp index 5bf6e9626..75c35bcea 100644 Binary files a/client/public/help-media/search-place/step-3.webp and b/client/public/help-media/search-place/step-3.webp differ diff --git a/client/public/help-media/share-calendar/result.webp b/client/public/help-media/share-calendar/result.webp index 6677a9e2b..af2d8d356 100644 Binary files a/client/public/help-media/share-calendar/result.webp and b/client/public/help-media/share-calendar/result.webp differ diff --git a/client/public/help-media/share-calendar/step-1.webp b/client/public/help-media/share-calendar/step-1.webp index c721d40b2..d11d6bdb1 100644 Binary files a/client/public/help-media/share-calendar/step-1.webp and b/client/public/help-media/share-calendar/step-1.webp differ diff --git a/client/public/help-media/share-calendar/step-2.webp b/client/public/help-media/share-calendar/step-2.webp index 871b271ca..73bec0e3e 100644 Binary files a/client/public/help-media/share-calendar/step-2.webp and b/client/public/help-media/share-calendar/step-2.webp differ diff --git a/client/public/help-media/share-calendar/step-3.webp b/client/public/help-media/share-calendar/step-3.webp index e39d61826..5680ae85e 100644 Binary files a/client/public/help-media/share-calendar/step-3.webp and b/client/public/help-media/share-calendar/step-3.webp differ diff --git a/client/public/help-media/share-list/result.webp b/client/public/help-media/share-list/result.webp index 458fa0a24..91afb8b52 100644 Binary files a/client/public/help-media/share-list/result.webp and b/client/public/help-media/share-list/result.webp differ diff --git a/client/public/help-media/share-list/step-1.webp b/client/public/help-media/share-list/step-1.webp index f710ee755..39ce9d760 100644 Binary files a/client/public/help-media/share-list/step-1.webp and b/client/public/help-media/share-list/step-1.webp differ diff --git a/client/public/help-media/share-list/step-2.webp b/client/public/help-media/share-list/step-2.webp index 2f8024027..30d01410f 100644 Binary files a/client/public/help-media/share-list/step-2.webp and b/client/public/help-media/share-list/step-2.webp differ diff --git a/client/public/help-media/share-list/step-3.webp b/client/public/help-media/share-list/step-3.webp index d6cc528a9..7f26a8274 100644 Binary files a/client/public/help-media/share-list/step-3.webp and b/client/public/help-media/share-list/step-3.webp differ diff --git a/client/public/help-media/share-public/step-1.webp b/client/public/help-media/share-public/step-1.webp index 49e798758..a4bc131ea 100644 Binary files a/client/public/help-media/share-public/step-1.webp and b/client/public/help-media/share-public/step-1.webp differ diff --git a/client/public/help-media/share-public/step-2.webp b/client/public/help-media/share-public/step-2.webp index 6594effcd..ce02c4897 100644 Binary files a/client/public/help-media/share-public/step-2.webp and b/client/public/help-media/share-public/step-2.webp differ diff --git a/client/public/help-media/share-public/step-3.webp b/client/public/help-media/share-public/step-3.webp index c2e236e6c..67defc704 100644 Binary files a/client/public/help-media/share-public/step-3.webp and b/client/public/help-media/share-public/step-3.webp differ diff --git a/client/public/help-media/stats/step-2.webp b/client/public/help-media/stats/step-2.webp index 2f362b9b6..66fe3e9c1 100644 Binary files a/client/public/help-media/stats/step-2.webp and b/client/public/help-media/stats/step-2.webp differ diff --git a/client/public/help-media/studio-auto-layout/result.webp b/client/public/help-media/studio-auto-layout/result.webp index f257edb89..ecb1567eb 100644 Binary files a/client/public/help-media/studio-auto-layout/result.webp and b/client/public/help-media/studio-auto-layout/result.webp differ diff --git a/client/public/help-media/studio-content/result.webp b/client/public/help-media/studio-content/result.webp index 0f73cbf29..68d443596 100644 Binary files a/client/public/help-media/studio-content/result.webp and b/client/public/help-media/studio-content/result.webp differ diff --git a/client/public/help-media/studio-content/step-1.webp b/client/public/help-media/studio-content/step-1.webp index 7c50dfcad..c7d09beca 100644 Binary files a/client/public/help-media/studio-content/step-1.webp and b/client/public/help-media/studio-content/step-1.webp differ diff --git a/client/public/help-media/studio-content/step-2.webp b/client/public/help-media/studio-content/step-2.webp index f01313e97..08f11d548 100644 Binary files a/client/public/help-media/studio-content/step-2.webp and b/client/public/help-media/studio-content/step-2.webp differ diff --git a/client/public/help-media/studio-content/step-3.webp b/client/public/help-media/studio-content/step-3.webp index d3013d092..141efd3e9 100644 Binary files a/client/public/help-media/studio-content/step-3.webp and b/client/public/help-media/studio-content/step-3.webp differ diff --git a/client/public/help-media/studio-elements/step-1.webp b/client/public/help-media/studio-elements/step-1.webp index b71e619c4..39b90ba8e 100644 Binary files a/client/public/help-media/studio-elements/step-1.webp and b/client/public/help-media/studio-elements/step-1.webp differ diff --git a/client/public/help-media/studio-elements/step-2.webp b/client/public/help-media/studio-elements/step-2.webp index f9b04dfcf..f57a5229a 100644 Binary files a/client/public/help-media/studio-elements/step-2.webp and b/client/public/help-media/studio-elements/step-2.webp differ diff --git a/client/public/help-media/studio-export/step-1.webp b/client/public/help-media/studio-export/step-1.webp index 6f485206f..bb1a10d6a 100644 Binary files a/client/public/help-media/studio-export/step-1.webp and b/client/public/help-media/studio-export/step-1.webp differ diff --git a/client/public/help-media/studio-export/step-2.webp b/client/public/help-media/studio-export/step-2.webp index 8bf79821a..bf25c12b8 100644 Binary files a/client/public/help-media/studio-export/step-2.webp and b/client/public/help-media/studio-export/step-2.webp differ diff --git a/client/public/help-media/studio-export/step-3.webp b/client/public/help-media/studio-export/step-3.webp index 0e9f3977c..2357308bd 100644 Binary files a/client/public/help-media/studio-export/step-3.webp and b/client/public/help-media/studio-export/step-3.webp differ diff --git a/client/public/help-media/studio-format/step-1.webp b/client/public/help-media/studio-format/step-1.webp index 64ba055dc..a4998f889 100644 Binary files a/client/public/help-media/studio-format/step-1.webp and b/client/public/help-media/studio-format/step-1.webp differ diff --git a/client/public/help-media/studio-layouts/result.webp b/client/public/help-media/studio-layouts/result.webp index 074678575..8d40cd4bc 100644 Binary files a/client/public/help-media/studio-layouts/result.webp and b/client/public/help-media/studio-layouts/result.webp differ diff --git a/client/public/help-media/studio-layouts/step-1.webp b/client/public/help-media/studio-layouts/step-1.webp index c2b7bc98c..246142c38 100644 Binary files a/client/public/help-media/studio-layouts/step-1.webp and b/client/public/help-media/studio-layouts/step-1.webp differ diff --git a/client/public/help-media/studio-pages/result.webp b/client/public/help-media/studio-pages/result.webp index 2b0eb1104..26d08ed5c 100644 Binary files a/client/public/help-media/studio-pages/result.webp and b/client/public/help-media/studio-pages/result.webp differ diff --git a/client/public/help-media/studio-pages/step-1.webp b/client/public/help-media/studio-pages/step-1.webp index 7c0cccc93..82aaab56d 100644 Binary files a/client/public/help-media/studio-pages/step-1.webp and b/client/public/help-media/studio-pages/step-1.webp differ diff --git a/client/public/help-media/studio-pages/step-2.webp b/client/public/help-media/studio-pages/step-2.webp index fb36b50f7..a3310a474 100644 Binary files a/client/public/help-media/studio-pages/step-2.webp and b/client/public/help-media/studio-pages/step-2.webp differ diff --git a/client/public/help-media/studio-pages/step-3.webp b/client/public/help-media/studio-pages/step-3.webp index fc88ebe77..9cf3495de 100644 Binary files a/client/public/help-media/studio-pages/step-3.webp and b/client/public/help-media/studio-pages/step-3.webp differ diff --git a/client/public/help-media/studio-properties/step-1.webp b/client/public/help-media/studio-properties/step-1.webp index 2d21f0871..bb05ac2da 100644 Binary files a/client/public/help-media/studio-properties/step-1.webp and b/client/public/help-media/studio-properties/step-1.webp differ diff --git a/client/public/help-media/studio-properties/step-2.webp b/client/public/help-media/studio-properties/step-2.webp index ef6196963..b5a91a264 100644 Binary files a/client/public/help-media/studio-properties/step-2.webp and b/client/public/help-media/studio-properties/step-2.webp differ diff --git a/client/public/help-media/studio-properties/step-3.webp b/client/public/help-media/studio-properties/step-3.webp index bb56697b0..733745ed1 100644 Binary files a/client/public/help-media/studio-properties/step-3.webp and b/client/public/help-media/studio-properties/step-3.webp differ diff --git a/client/public/help-media/studio-spread-file/step-1.webp b/client/public/help-media/studio-spread-file/step-1.webp index 9ecf15c93..7b031623a 100644 Binary files a/client/public/help-media/studio-spread-file/step-1.webp and b/client/public/help-media/studio-spread-file/step-1.webp differ diff --git a/client/public/help-media/studio-spread-file/step-2.webp b/client/public/help-media/studio-spread-file/step-2.webp index b775aeecc..e92b67d4c 100644 Binary files a/client/public/help-media/studio-spread-file/step-2.webp and b/client/public/help-media/studio-spread-file/step-2.webp differ diff --git a/client/public/help-media/studio-travel/step-1.webp b/client/public/help-media/studio-travel/step-1.webp index 7b5291ef9..57c8c6515 100644 Binary files a/client/public/help-media/studio-travel/step-1.webp and b/client/public/help-media/studio-travel/step-1.webp differ diff --git a/client/public/help-media/studio-travel/step-2.webp b/client/public/help-media/studio-travel/step-2.webp index 8390410c0..f95d69593 100644 Binary files a/client/public/help-media/studio-travel/step-2.webp and b/client/public/help-media/studio-travel/step-2.webp differ diff --git a/client/public/help-media/studio/result.webp b/client/public/help-media/studio/result.webp index 502d34212..146a5f47e 100644 Binary files a/client/public/help-media/studio/result.webp and b/client/public/help-media/studio/result.webp differ diff --git a/client/public/help-media/studio/step-1.webp b/client/public/help-media/studio/step-1.webp index 153b785a8..3d7153758 100644 Binary files a/client/public/help-media/studio/step-1.webp and b/client/public/help-media/studio/step-1.webp differ diff --git a/client/public/help-media/suggestions/result.webp b/client/public/help-media/suggestions/result.webp index 5df34d209..1c5bc0838 100644 Binary files a/client/public/help-media/suggestions/result.webp and b/client/public/help-media/suggestions/result.webp differ diff --git a/client/public/help-media/suggestions/step-1.webp b/client/public/help-media/suggestions/step-1.webp index 2b06c1514..f51b906f4 100644 Binary files a/client/public/help-media/suggestions/step-1.webp and b/client/public/help-media/suggestions/step-1.webp differ diff --git a/client/public/help-media/suggestions/step-2.webp b/client/public/help-media/suggestions/step-2.webp index 2ec6d3115..7aabc6e36 100644 Binary files a/client/public/help-media/suggestions/step-2.webp and b/client/public/help-media/suggestions/step-2.webp differ diff --git a/client/public/help-media/suggestions/step-3.webp b/client/public/help-media/suggestions/step-3.webp index f40b9d276..bfea43d1a 100644 Binary files a/client/public/help-media/suggestions/step-3.webp and b/client/public/help-media/suggestions/step-3.webp differ diff --git a/client/public/help-media/unmark-country/step-2.webp b/client/public/help-media/unmark-country/step-2.webp index b32e14029..20fe0d7ac 100644 Binary files a/client/public/help-media/unmark-country/step-2.webp and b/client/public/help-media/unmark-country/step-2.webp differ diff --git a/client/public/help-media/weekends/step-1.webp b/client/public/help-media/weekends/step-1.webp index d59d43b4d..ee186e59a 100644 Binary files a/client/public/help-media/weekends/step-1.webp and b/client/public/help-media/weekends/step-1.webp differ diff --git a/client/public/help-media/weekends/step-2.webp b/client/public/help-media/weekends/step-2.webp index a334aa33a..caa1db2e3 100644 Binary files a/client/public/help-media/weekends/step-2.webp and b/client/public/help-media/weekends/step-2.webp differ diff --git a/client/public/help-media/weekends/step-3.webp b/client/public/help-media/weekends/step-3.webp index 7398447b3..68ce38226 100644 Binary files a/client/public/help-media/weekends/step-3.webp and b/client/public/help-media/weekends/step-3.webp differ diff --git a/client/public/help-media/years/result.webp b/client/public/help-media/years/result.webp index c252ff73a..744e10f94 100644 Binary files a/client/public/help-media/years/result.webp and b/client/public/help-media/years/result.webp differ diff --git a/client/public/help-media/years/step-3.webp b/client/public/help-media/years/step-3.webp index b141fbaaa..220915f25 100644 Binary files a/client/public/help-media/years/step-3.webp and b/client/public/help-media/years/step-3.webp differ