From f0bed8c793f8244ed476bbbca78504e302c27d08 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:33:53 +0900 Subject: [PATCH 01/17] Flags: render every country flag on platforms without flag glyphs On Windows and minimal Linux a regional-indicator pair has no colour glyph, so the Places list lost its flags and the map's flag markers turned into empty boxes. A flag-only Twemoji font (78 KB woff2, CC-BY 4.0) now leads the page and canvas font stacks behind a unicode-range, so it is fetched only once a flag shows, precached for offline use, and the map redraws its flag markers once it has loaded. Credited in Settings' data sources. --- apps/postcards/public/fonts/PROVENANCE.md | 14 +++++ .../fonts/TwemojiCountryFlags-LICENSE.md | 54 ++++++++++++++++++ .../public/fonts/TwemojiCountryFlags.woff2 | Bin 0 -> 78292 bytes apps/postcards/src/features/map/MapView.tsx | 26 ++++++--- .../postcards/src/features/passport/poster.ts | 5 +- apps/postcards/src/lib/format/format.ts | 19 +++++- apps/postcards/src/styles.css | 18 +++++- apps/postcards/src/ui/Attribution.tsx | 3 + apps/postcards/tests/e2e/flags.spec.ts | 31 ++++++++++ 9 files changed, 155 insertions(+), 15 deletions(-) create mode 100644 apps/postcards/public/fonts/PROVENANCE.md create mode 100644 apps/postcards/public/fonts/TwemojiCountryFlags-LICENSE.md create mode 100644 apps/postcards/public/fonts/TwemojiCountryFlags.woff2 create mode 100644 apps/postcards/tests/e2e/flags.spec.ts diff --git a/apps/postcards/public/fonts/PROVENANCE.md b/apps/postcards/public/fonts/PROVENANCE.md new file mode 100644 index 0000000..1698c80 --- /dev/null +++ b/apps/postcards/public/fonts/PROVENANCE.md @@ -0,0 +1,14 @@ +# Font provenance + +- `TwemojiCountryFlags.woff2` — the country-flag glyphs (regional-indicator + pairs, plus the England/Scotland/Wales tag sequences) of **Twemoji**, graphics + © Twitter, Inc and other contributors, licensed CC-BY 4.0 + (https://creativecommons.org/licenses/by/4.0/). Packaged as a flag-only font by + the MIT-licensed `country-flag-emoji-polyfill` 0.1.10 by TalkJS + (https://github.com/talkjs/country-flag-emoji-polyfill); both licences are in + `TwemojiCountryFlags-LICENSE.md`. Unmodified. + +It is bundled so a flag renders on every platform, offline: Windows and minimal +Linux have no colour flag glyphs and show the two letters or an empty box. The +`@font-face` in `src/styles.css` limits it to the regional indicators with +`unicode-range`, so a browser downloads it only once a flag is on screen. diff --git a/apps/postcards/public/fonts/TwemojiCountryFlags-LICENSE.md b/apps/postcards/public/fonts/TwemojiCountryFlags-LICENSE.md new file mode 100644 index 0000000..adff684 --- /dev/null +++ b/apps/postcards/public/fonts/TwemojiCountryFlags-LICENSE.md @@ -0,0 +1,54 @@ +## License for the Code (MIT) + +Copyright (c) 2022 TalkJS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## License for the Visual Design + +The Emoji art in the "Twemoji Country Flags" font comes from [Twemoji](https://twitter.github.io/twemoji), +and is used and redistributed under the CC-BY-4.0 [license terms](https://github.com/twitter/twemoji#license) +offered by the Twemoji project. + +### Creative Commons Attribution 4.0 International (CC BY 4.0) +https://creativecommons.org/licenses/by/4.0/legalcode +or for the human readable summary: https://creativecommons.org/licenses/by/4.0/ + + +#### You are free to: +**Share** — copy and redistribute the material in any medium or format + +**Adapt** — remix, transform, and build upon the material for any purpose, even commercially. + +The licensor cannot revoke these freedoms as long as you follow the license terms. + + +#### Under the following terms: +**Attribution** — You must give appropriate credit, provide a link to the license, +and indicate if changes were made. +You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use. + +**No additional restrictions** — You may not apply legal terms or **technological measures** +that legally restrict others from doing anything the license permits. + +#### Notices: +You do not have to comply with the license for elements of the material in the public domain +or where your use is permitted by an applicable exception or limitation. No warranties are given. +The license may not give you all of the permissions necessary for your intended use. +For example, other rights such as publicity, privacy, or moral rights may limit how you use the material. diff --git a/apps/postcards/public/fonts/TwemojiCountryFlags.woff2 b/apps/postcards/public/fonts/TwemojiCountryFlags.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b9d6ea84b392bd43479c556d264fff6e5e57940e GIT binary patch literal 78292 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 K1)JR*5&!^7A{=4> literal 0 HcmV?d00001 diff --git a/apps/postcards/src/features/map/MapView.tsx b/apps/postcards/src/features/map/MapView.tsx index 74a07f8..8063884 100644 --- a/apps/postcards/src/features/map/MapView.tsx +++ b/apps/postcards/src/features/map/MapView.tsx @@ -28,7 +28,7 @@ import { markerCitiesInView, type Bounds } from "./viewport"; import { statusShows, type FilterStatus } from "../../lib/store/useFilters"; import type { City } from "../../lib/reference/types"; import type { PlaceRef, Visit } from "../../lib/schema/models"; -import { countryFlag, formatInt } from "../../lib/format/format"; +import { countryFlag, flagFontReady, FLAG_FONT, formatInt } from "../../lib/format/format"; // Natural Earth 50m country geometry, served as a static asset (SW-cached for // offline). Fetched ONCE and cached at module scope so remounts (basemap change, @@ -128,7 +128,6 @@ function getRivers(): Promise { return riversPromise; } -const EMOJI_FONT = '"Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif'; const PILL_FONT = '600 21px "Inter Variable", system-ui, sans-serif'; /** @@ -158,11 +157,11 @@ function makeCityPill(iso2: string, favorite: boolean, wish: boolean): ImageData ctx.strokeStyle = "#ffffff"; ctx.stroke(); } else { - ctx.font = `27px ${EMOJI_FONT}`; + ctx.font = `27px ${FLAG_FONT}`; ctx.fillText(countryFlag(iso2), w / 2, h / 2 + 1); } if (favorite) { - ctx.font = `14px ${EMOJI_FONT}`; + ctx.font = `14px ${FLAG_FONT}`; ctx.fillText("⭐", w - 9, 9); } return ctx.getImageData(0, 0, w, h); @@ -195,10 +194,10 @@ function makeMonumentPin(category: string, seen: boolean): ImageData { ctx.stroke(); ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.font = `29px ${EMOJI_FONT}`; + ctx.font = `29px ${FLAG_FONT}`; ctx.fillText(st.emoji, s / 2, s / 2 + 1); if (seen) { - ctx.font = `15px ${EMOJI_FONT}`; + ctx.font = `15px ${FLAG_FONT}`; ctx.fillText("✅", s - 10, 10); } return ctx.getImageData(0, 0, s, s); @@ -221,7 +220,7 @@ function makeAirportDot(): ImageData { ctx.stroke(); ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.font = `17px ${EMOJI_FONT}`; + ctx.font = `17px ${FLAG_FONT}`; ctx.fillText("✈️", s / 2, s / 2 + 1); return ctx.getImageData(0, 0, s, s); } @@ -231,7 +230,7 @@ function makeAirportPin(iata: string, wish: boolean, favorite: boolean): ImageDa const h = 30; const pad = 8; const gap = 5; - const planeFont = `16px ${EMOJI_FONT}`; + const planeFont = `16px ${FLAG_FONT}`; const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d")!; const plane = "✈️"; @@ -1393,6 +1392,17 @@ export function MapView({ // Layers pills in the bottom-right (they were literally covering the ⓘ). map.addControl(new maplibregl.AttributionControl({ compact: true }), "bottom-left"); + // A flag drawn before the bundled flag font loaded keeps its empty boxes: + // redraw every flag marker once the font is there (a no-op when cached). + void flagFontReady().then(() => { + if (cancelled || !map) return; + for (const id of map.listImages()) { + if (!id.startsWith("pill-")) continue; + const [, cc, fav, wish] = id.split("-"); + map.updateImage(id, makeCityPill(cc ?? "", fav === "1", wish === "1")); + } + }); + // Marker images, generated lazily on demand. map.on("styleimagemissing", (e) => { if (!map || map.hasImage(e.id)) return; diff --git a/apps/postcards/src/features/passport/poster.ts b/apps/postcards/src/features/passport/poster.ts index 92256f0..7f67cf6 100644 --- a/apps/postcards/src/features/passport/poster.ts +++ b/apps/postcards/src/features/passport/poster.ts @@ -6,7 +6,7 @@ import { feature } from "topojson-client"; import type { FeatureCollection, MultiPolygon, Polygon, Position } from "geojson"; import type { ReferenceData } from "../../lib/reference/types"; import { CONTINENT_COLORS } from "../../lib/reference/continents"; -import { countryFlag } from "../../lib/format/format"; +import { countryFlag, flagFontReady, FLAG_FONT } from "../../lib/format/format"; const GEOMETRY_URL = `${import.meta.env.BASE_URL}basemap/countries-50m.json`; @@ -154,7 +154,8 @@ export async function renderPoster( } // Flag stamps on top (after all fills so nothing covers them). - ctx.font = '36px "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif'; + await flagFontReady(); + ctx.font = `36px ${FLAG_FONT}`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; for (const s of stamps) ctx.fillText(s.flag, s.x, s.y); diff --git a/apps/postcards/src/lib/format/format.ts b/apps/postcards/src/lib/format/format.ts index cd80fb6..22d6910 100644 --- a/apps/postcards/src/lib/format/format.ts +++ b/apps/postcards/src/lib/format/format.ts @@ -22,8 +22,9 @@ export function formatInt(n: number, locale = activeLocale): string { /** * Flag emoji for an ISO 3166-1 alpha-2 country/territory code ("FR" -> 🇫🇷). - * Pure Unicode regional indicators — offline, no assets; platforms without a - * flag font show the letter pair, which stays informative. + * Pure Unicode regional indicators. Platforms without flag glyphs (Windows, + * minimal Linux) get them from the bundled flag-only font that leads the app's + * font stacks (styles.css, FLAG_FONT for canvases), so a flag always renders. */ export function countryFlag(iso2: string): string { // "ZZ" is the ISO user-assigned code Postcards uses for places outside any @@ -34,6 +35,20 @@ export function countryFlag(iso2: string): string { .replace(/[A-Z]/g, (ch) => String.fromCodePoint(0x1f1e6 + ch.charCodeAt(0) - 65)); } +/** Font stack for flags drawn on a canvas: the bundled flag font first, then + * the platform's colour emoji for everything else (pins, stars). */ +export const FLAG_FONT = + '"Twemoji Country Flags", "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif'; + +/** Resolves once the flag font can draw on a canvas (a canvas never waits for + * a web font, and an image drawn before it loads keeps the empty boxes). */ +export function flagFontReady(): Promise { + if (typeof document === "undefined" || !document.fonts) return Promise.resolve(); + return document.fonts + .load('16px "Twemoji Country Flags"', "\u{1F1EB}\u{1F1F7}") + .catch(() => undefined); +} + /** Great-circle distance in km -> localized "1,234 km" (rounded to the km). */ export function formatKm(km: number, locale = activeLocale): string { return `${formatInt(km, locale)} km`; diff --git a/apps/postcards/src/styles.css b/apps/postcards/src/styles.css index c82cfb1..f060553 100644 --- a/apps/postcards/src/styles.css +++ b/apps/postcards/src/styles.css @@ -20,7 +20,7 @@ --radius-lg: 18px; --shadow: 0 1px 2px rgba(30, 24, 16, 0.05), 0 6px 18px -6px rgba(30, 24, 16, 0.12); --shadow-sm: 0 1px 2px rgba(30, 24, 16, 0.06); - --display: "Space Grotesk Variable", "Inter Variable", system-ui, sans-serif; + --display: "Twemoji Country Flags", "Space Grotesk Variable", "Inter Variable", system-ui, sans-serif; /* Semantic colours that used to be hardcoded twice (light rule + a dark-only override). Now they're tokens so a single unconditional component rule works under system-dark, explicit-dark and light alike. Light values here. */ @@ -106,6 +106,18 @@ color-scheme: dark; } +/* Country flags on every platform. A flag is a pair of Unicode regional + indicators; Windows and minimal Linux have no colour glyphs for them and show + the two letters or an empty box. This flag-only Twemoji font (CC-BY 4.0, see + public/fonts/PROVENANCE.md) covers just those code points, so it leads every + font stack below and the browser fetches it only once a flag is on screen. */ +@font-face { + font-family: "Twemoji Country Flags"; + src: url("/fonts/TwemojiCountryFlags.woff2") format("woff2"); + unicode-range: U+1F1E6-1F1FF; + font-display: swap; +} + * { box-sizing: border-box; } @@ -129,8 +141,8 @@ body { body { background: var(--bg); color: var(--text); - font: 15px/1.45 "Inter Variable", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, - sans-serif; + font: 15px/1.45 "Twemoji Country Flags", "Inter Variable", system-ui, -apple-system, "Segoe UI", + Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; font-feature-settings: "cv02", "cv11"; /* Inter: open digits & disambiguated l/I */ } diff --git a/apps/postcards/src/ui/Attribution.tsx b/apps/postcards/src/ui/Attribution.tsx index 4a72507..94cac0a 100644 --- a/apps/postcards/src/ui/Attribution.tsx +++ b/apps/postcards/src/ui/Attribution.tsx @@ -15,6 +15,9 @@ export function Attribution() { {p.dataset} ({p.license}) ))} + {/* A bundled asset, not a reference dataset, so it stays out of the + provenance an export records (public/fonts/PROVENANCE.md). */} + · Country flags: Twemoji (CC-BY-4.0) ); } diff --git a/apps/postcards/tests/e2e/flags.spec.ts b/apps/postcards/tests/e2e/flags.spec.ts new file mode 100644 index 0000000..6ea7e0f --- /dev/null +++ b/apps/postcards/tests/e2e/flags.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from "@playwright/test"; + +// Flags are Unicode regional-indicator pairs. Windows and minimal Linux have no +// glyphs for them, so the app bundles a flag-only font that leads its font +// stacks; these checks hold whatever fonts the machine running them has. +test("country flags draw from the bundled flag font", async ({ page }) => { + const fontLoads: number[] = []; + page.on("response", (r) => { + if (r.url().endsWith("/fonts/TwemojiCountryFlags.woff2")) fontLoads.push(r.status()); + }); + await page.goto("/"); + await page.getByLabel("Search a city or country").fill("Paris"); + await page.getByRole("button", { name: "Mark Paris visited" }).first().click(); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "Places", exact: true }).click(); + await expect(page.getByText("Paris", { exact: true })).toBeVisible(); + + const face = await page.evaluate(async () => { + await document.fonts.ready; + const f = [...document.fonts].find((x) => x.family.replace(/"/g, "") === "Twemoji Country Flags"); + return { + status: f?.status, + range: f?.unicodeRange, + stack: getComputedStyle(document.body).fontFamily, + }; + }); + expect(face.status).toBe("loaded"); + expect(face.range).toBe("U+1F1E6-1F1FF"); + expect(face.stack.replace(/"/g, "")).toMatch(/^Twemoji Country Flags,/); + expect(fontLoads).toContain(200); +}); From b62fdf67e6a8628ea4d8a28bfbc1cdc7e8e4ce75 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:47:41 +0900 Subject: [PATCH 02/17] Flags: lead search results and the map's place card with the flag Every place list showed the country's flag beside a name except the global search results and the map's place card, where the country was named without one. Both lead with it now, hidden from assistive tech so the option and card keep the place's name. The card's country line also lost the stray leading dot it carried when opened from a list or a search. --- apps/postcards/src/features/map/MapScreen.tsx | 8 ++++---- apps/postcards/src/features/map/MapView.tsx | 9 ++++++++- .../src/features/visits/PlaceSearch.tsx | 4 ++++ apps/postcards/src/styles.css | 15 +++++++++++++++ apps/postcards/tests/e2e/flags.spec.ts | 16 ++++++++++++++++ 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/apps/postcards/src/features/map/MapScreen.tsx b/apps/postcards/src/features/map/MapScreen.tsx index b9432f9..7285e5f 100644 --- a/apps/postcards/src/features/map/MapScreen.tsx +++ b/apps/postcards/src/features/map/MapScreen.tsx @@ -578,9 +578,9 @@ export function MapScreen({ active = true }: { active?: boolean } = {}) { if (place.kind === "city") { const c = ref.cityById(place.id); const region = c?.subdivisionId ? ref.subdivisionById(c.subdivisionId)?.name : null; - return `· ${country}${region ? ` - ${region}` : ""}`; + return `${country}${region ? ` - ${region}` : ""}`; } - return `· ${country}`; + return country; } // Fly to a place AND open its preview card — the SAME result as tapping the @@ -943,7 +943,7 @@ export function MapScreen({ active = true }: { active?: boolean } = {}) { lon: x.lon, lat: x.lat, name: x.name, - sub: `· ${x.sub}`, + sub: x.sub, place: x.place, hasPage: true, }) @@ -1022,7 +1022,7 @@ export function MapScreen({ active = true }: { active?: boolean } = {}) { lon: c.lon, lat: c.lat, name: c.name, - sub: `· ${country}${region ? ` - ${region}` : ""}`, + sub: `${country}${region ? ` - ${region}` : ""}`, place, hasPage: true, }) diff --git a/apps/postcards/src/features/map/MapView.tsx b/apps/postcards/src/features/map/MapView.tsx index 8063884..ee03e68 100644 --- a/apps/postcards/src/features/map/MapView.tsx +++ b/apps/postcards/src/features/map/MapView.tsx @@ -425,7 +425,14 @@ function openPlacePopup( body.type = "button"; body.className = "map-popup-main"; const name = document.createElement("strong"); - name.textContent = info.name; + // The country's flag leads the name, as in every place list. + if (info.place.countryId) { + const flag = document.createElement("span"); + flag.className = "map-popup-flag"; + flag.setAttribute("aria-hidden", "true"); + flag.textContent = countryFlag(info.place.countryId); + name.append(flag, info.name); + } else name.textContent = info.name; body.appendChild(name); if (info.sub) { const sub = document.createElement("span"); diff --git a/apps/postcards/src/features/visits/PlaceSearch.tsx b/apps/postcards/src/features/visits/PlaceSearch.tsx index 501ee4b..8d938fa 100644 --- a/apps/postcards/src/features/visits/PlaceSearch.tsx +++ b/apps/postcards/src/features/visits/PlaceSearch.tsx @@ -7,6 +7,7 @@ import { useToast } from "../../lib/store/useToast"; import { placeKey } from "../../lib/schema/helpers"; import type { PlaceRef } from "../../lib/schema/models"; import { useT } from "../../lib/i18n"; +import { countryFlag } from "../../lib/format/format"; /** * Global place search. Picking a result NAVIGATES — it flies the map to a @@ -229,6 +230,9 @@ export function PlaceSearch({ } onClick={() => pick(r.place)} > + + {r.place.countryId ? countryFlag(r.place.countryId) : ""} + {r.place.name} {r.detail} diff --git a/apps/postcards/src/styles.css b/apps/postcards/src/styles.css index f060553..152bf58 100644 --- a/apps/postcards/src/styles.css +++ b/apps/postcards/src/styles.css @@ -609,6 +609,12 @@ h1.brand { color: var(--muted); font-variant-numeric: tabular-nums; } +.map-popup strong .map-popup-flag { + display: inline; + margin: 0 6px 0 0; + font-size: 15px; + color: inherit; +} /* Search */ .search { @@ -713,6 +719,15 @@ h1.brand { flex: 1; min-width: 0; width: auto; + justify-content: flex-start; +} +/* The country's flag leads each option, as in every other place list. */ +.result-flag { + flex: none; + width: 1.25em; + font-size: 18px; + line-height: 1; + text-align: center; } .results-split .result-add { flex: none; diff --git a/apps/postcards/tests/e2e/flags.spec.ts b/apps/postcards/tests/e2e/flags.spec.ts index 6ea7e0f..73aec43 100644 --- a/apps/postcards/tests/e2e/flags.spec.ts +++ b/apps/postcards/tests/e2e/flags.spec.ts @@ -29,3 +29,19 @@ test("country flags draw from the bundled flag font", async ({ page }) => { expect(face.stack.replace(/"/g, "")).toMatch(/^Twemoji Country Flags,/); expect(fontLoads).toContain(200); }); + +// A country named without its flag read as a gap next to every list that shows +// one: search results and the map's place card lead with it too. +test("search results and the map's place card lead with the country's flag", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Cities in view")).toBeVisible(); + await page.getByLabel("Search a city or country").fill("Istanbul"); + const first = page.locator("#search-results [role=option]").first(); + await expect(first.locator(".result-flag")).toHaveText("🇹🇷"); + // The flag is decoration: the option's name stays the place's name. + await expect(first.locator(".result-open")).toHaveAccessibleName(/^Istanbul/); + + await first.locator(".result-open").click(); + await expect(page.locator(".map-popup-flag")).toHaveText("🇹🇷"); + await expect(page.locator(".map-popup-main strong")).toHaveText("🇹🇷Istanbul"); +}); From 9653bbcd3bb427e563de8a6205bd6966fffa1723 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:48:03 +0900 Subject: [PATCH 03/17] Map: keep every visited country's flag legible at world zoom At world zoom a country's visited cities sat on top of each other, so Europe became a pile of identical flags over the neighbours' ones, a logged airport's pill covered its country's only flag, and with markers allowed to overlap the sort key drew favourites at the bottom of the pile. A country's own flags now stack into one while they would overlap (a favourite or custom place always keeps its own), each country's main city outranks any second city, the pile's top is its highest priority, airport pills sit under the flags and shrink at world zoom, and the flag glyph is sized to fill its marker. --- apps/postcards/src/features/map/MapView.tsx | 80 ++++++++++++------- .../src/features/map/visitedLayers.ts | 79 +++++++++++++++++- .../tests/unit/visitedLayers.spec.ts | 58 +++++++++++++- 3 files changed, 180 insertions(+), 37 deletions(-) diff --git a/apps/postcards/src/features/map/MapView.tsx b/apps/postcards/src/features/map/MapView.tsx index ee03e68..759589c 100644 --- a/apps/postcards/src/features/map/MapView.tsx +++ b/apps/postcards/src/features/map/MapView.tsx @@ -20,6 +20,7 @@ import { mapDateMatches, type MapDate } from "../travel/period"; import { airportPoints, optimizeVisitedPoints, + stackSameCountry, visitedCityPoints, wishlistCityPoints, } from "./visitedLayers"; @@ -157,8 +158,10 @@ function makeCityPill(iso2: string, favorite: boolean, wish: boolean): ImageData ctx.strokeStyle = "#ffffff"; ctx.stroke(); } else { - ctx.font = `27px ${FLAG_FONT}`; - ctx.fillText(countryFlag(iso2), w / 2, h / 2 + 1); + // The bundled flag glyph fills less of its em box than a colour-emoji flag + // and sits above the middle baseline: a larger size, set lower, centres it. + ctx.font = `32px ${FLAG_FONT}`; + ctx.fillText(countryFlag(iso2), w / 2, h / 2 + 5); } if (favorite) { ctx.font = `14px ${FLAG_FONT}`; @@ -701,25 +704,8 @@ function overlayLayers(basemap: Basemap, dark: boolean): StyleSpecification["lay "icon-allow-overlap": true, }, }, - { - id: "cities-visited", - type: "symbol", - source: "cities", - layout: { - "icon-image": ["concat", "pill-", ["get", "cc"], "-", ["to-string", ["get", "fav"]], "-", ["to-string", ["get", "wish"]]], - // Grow with zoom so markers stay obvious on big screens & close views. - "icon-size": ["interpolate", ["linear"], ["zoom"], 1, 0.9, 5, 1.1, 10, 1.35], - "icon-padding": 1, - // Your flags are ALWAYS drawn (overlap allowed) — never hidden by collision - // or by browse monuments/airports — because seeing everywhere you've been - // is the whole point. Density is managed by "Show one city per area" - // (region-optimize), not by dropping flags. symbol-sort-key = -(population) - // keeps the biggest city on top where they stack; favourites are pinned. - // (Runtime-flipped by "Show every place at once": OFF re-enables thinning.) - "icon-allow-overlap": true, - "symbol-sort-key": ["get", "sortKey"], - }, - }, + // Your logged airports sit UNDER your flags: at world zoom an airport's + // pill lands right on its city and hid that country's only flag. { id: "airports", type: "symbol", @@ -734,9 +720,31 @@ function overlayLayers(basemap: Basemap, dark: boolean): StyleSpecification["lay "-", ["to-string", ["get", "fav"]], ], - "icon-size": 1, + // Smaller at world zoom, where a pill covers a small country outright. + "icon-size": ["interpolate", ["linear"], ["zoom"], 1, 0.75, 5, 1], + "icon-padding": 1, + "icon-allow-overlap": true, + }, + }, + { + id: "cities-visited", + type: "symbol", + source: "cities", + layout: { + "icon-image": ["concat", "pill-", ["get", "cc"], "-", ["to-string", ["get", "fav"]], "-", ["to-string", ["get", "wish"]]], + // Grow with zoom so markers stay obvious on big screens & close views. + "icon-size": ["interpolate", ["linear"], ["zoom"], 1, 0.9, 5, 1.1, 10, 1.35], "icon-padding": 1, + // Your flags are ALWAYS drawn (overlap allowed) — never hidden by collision + // or by browse monuments/airports — because seeing everywhere you've been + // is the whole point. Density is managed by "Show one city per area" + // (region-optimize) and by stacking a country's own flags into one at + // low zoom (stackSameCountry), not by dropping countries. The sort key + // (favourites, then each country's main city, then population) is set + // for the mode by applyMarkerOverlap, with "Show every place at once": + // OFF re-enables thinning. "icon-allow-overlap": true, + "symbol-sort-key": ["get", "sortKey"], }, }, ]; @@ -1051,7 +1059,12 @@ export function MapView({ // rebuilds the source; toggling the setting does too. `undefined` = setting // off (show all); a null granularity = on but zoomed in enough to show all. const gran = optimizeMarkersRef.current ? optimizeGranularity(map.getZoom()) : undefined; - const optTag = optimizeMarkersRef.current ? `opt:${gran ?? "all"}|` : "all|"; + // Same-country flags that would sit on each other collapse into one + // (stackSameCountry), measured at the zoom rounded down to a half step so + // a pan or a small zoom does not rebuild the sources. + const stackZoom = Math.floor(map.getZoom() * 2) / 2; + const optTag = + (optimizeMarkersRef.current ? `opt:${gran ?? "all"}|` : "all|") + `z${stackZoom}|`; const citiesKey = optTag + vis @@ -1064,7 +1077,7 @@ export function MapView({ if (citiesKey !== lastCitiesKey.current) { lastCitiesKey.current = citiesKey; const base = visitedCityPoints(vis, ref); - const fc = gran ? optimizeVisitedPoints(base, gran) : base; + const fc = stackSameCountry(gran ? optimizeVisitedPoints(base, gran) : base, stackZoom); (map.getSource("cities") as GeoJSONSource | undefined)?.setData(fc); // Warm the flag images up front — a later zoom-out must not have to fetch // any visited flag's image before it can paint (the "load in late" bug). @@ -1081,7 +1094,7 @@ export function MapView({ lastWishKey.current = wishKey; // Want-list cities get the SAME zoom-aware region optimisation as visited. const wbase = wishlistCityPoints(vis, ref); - const wfc = gran ? optimizeVisitedPoints(wbase, gran) : wbase; + const wfc = stackSameCountry(gran ? optimizeVisitedPoints(wbase, gran) : wbase, stackZoom); (map.getSource("wishlist") as GeoJSONSource | undefined)?.setData(wfc); ensurePillImages(map, wfc); } @@ -1175,7 +1188,16 @@ export function MapView({ function applyMarkerOverlap(map: MlMap) { const all = showAllRef.current; for (const id of ["cities-visited", "cities-wishlist"]) { - if (map.getLayer(id)) map.setLayoutProperty(id, "icon-allow-overlap", all); + if (!map.getLayer(id)) continue; + map.setLayoutProperty(id, "icon-allow-overlap", all); + // A low sort key wins placement when markers collide, but when they + // overlap the HIGHEST key draws on top: negate it there, so favourites and + // each country's main city stay on top of a pile instead of under it. + map.setLayoutProperty( + id, + "symbol-sort-key", + all ? ["*", -1, ["get", "sortKey"]] : ["get", "sortKey"], + ); } } @@ -1495,9 +1517,9 @@ export function MapView({ // without waiting on a React render. applyViewportPoi(map); applyInViewCities(map); - // Zoom-aware "one city per area": rebuild your flags when the zoom crosses - // a granularity bucket (key-guarded, so a same-bucket pan is a no-op). - if (optimizeMarkersRef.current) applyVisited(map); + // Zoom-aware flags: rebuild them when the zoom crosses a stacking step + // or a "one city per area" bucket (key-guarded, so a pan is a no-op). + applyVisited(map); const wasProgrammatic = suppressBoundsRef.current; if (wasProgrammatic) { suppressBoundsRef.current = false; // programmatic fly — keep the list still diff --git a/apps/postcards/src/features/map/visitedLayers.ts b/apps/postcards/src/features/map/visitedLayers.ts index 1b91382..c00f727 100644 --- a/apps/postcards/src/features/map/visitedLayers.ts +++ b/apps/postcards/src/features/map/visitedLayers.ts @@ -13,6 +13,20 @@ function isVisited(v: Visit): boolean { // bias drops favourites and your own custom pins BELOW every browse city so they // are never the one thinned out — your marks always stay on the map. const PINNED = 1_000_000_000; +// Below the pins, each country's most-populous city outranks every other city, +// so where flags collide every country keeps one before any keeps a second. +// Larger than any city's population. +const COUNTRY_REP = 100_000_000; + +/** Ids of the most-populous city per country among `cities`. */ +function countryReps(cities: { id: string; cc: string; pop: number }[]): Set { + const best = new Map(); + for (const c of cities) { + const b = best.get(c.cc); + if (!b || c.pop > b.pop) best.set(c.cc, c); + } + return new Set([...best.values()].map((b) => b.id)); +} /** * Point features for visited cities. Each carries what the flag marker needs @@ -22,6 +36,12 @@ const PINNED = 1_000_000_000; */ export function visitedCityPoints(visits: Visit[], ref: ReferenceData): FeatureCollection { const features: Feature[] = []; + const reps = countryReps( + visits.flatMap((v) => { + const c = isVisited(v) && v.place.kind === "city" ? ref.cityById(v.place.id) : undefined; + return c ? [{ id: c.id, cc: c.countryIso2, pop: c.population ?? 0 }] : []; + }), + ); for (const v of visits) { if (!isVisited(v)) continue; // User-authored custom points carry their own coordinates on the record. @@ -62,9 +82,11 @@ export function visitedCityPoints(visits: Visit[], ref: ReferenceData): FeatureC custom: 0, fav: v.favorite ? 1 : 0, wish: 0, - // Favourites are pinned below every non-favourite; within each group the - // most-populous city is kept when flags collide. - sortKey: (v.favorite ? -PINNED : 0) - (city.population ?? 0), + // Favourites are pinned below every non-favourite, then one city per + // country; within each group the most-populous city is kept when flags + // collide. + sortKey: + (v.favorite ? -PINNED : 0) - (reps.has(city.id) ? COUNTRY_REP : 0) - (city.population ?? 0), }, }); } @@ -121,6 +143,48 @@ export function optimizeVisitedPoints( return { type: "FeatureCollection", features: kept }; } +// On-screen footprint of a flag marker, in CSS pixels. +const MARKER_PX = 20; + +/** + * At world zoom a country's cities sit on top of each other, and their identical + * flags pile up over the neighbours' (Europe especially). This keeps, per + * country, only the markers at least one marker apart on screen at `zoom`, + * highest priority first (the symbol sort key): a marker left out would have + * drawn under the same flag, so no country, favourite or custom place leaves the + * map, and zooming in brings each city back. Different countries never hide + * each other here; the map's collision pass does that when it is on. + */ +export function stackSameCountry( + fc: FeatureCollection, + zoom: number, +): FeatureCollection { + const world = 512 * 2 ** zoom; + const toPx = ([lon, lat]: number[]): [number, number] => { + const s = Math.sin((Math.max(-85, Math.min(85, lat!)) * Math.PI) / 180); + return [ + ((lon! + 180) / 360) * world, + (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * world, + ]; + }; + const byPriority = [...fc.features].sort( + (a, b) => Number(a.properties?.sortKey ?? 0) - Number(b.properties?.sortKey ?? 0), + ); + const keptByCountry = new Map(); + const kept = new Set>(); + for (const f of byPriority) { + const p = f.properties ?? {}; + const at = toPx(f.geometry.coordinates); + const same = keptByCountry.get(p.cc) ?? []; + const pinned = p.custom === 1 || p.fav === 1; + if (!pinned && same.some(([x, y]) => Math.hypot(x - at[0], y - at[1]) < MARKER_PX)) continue; + same.push(at); + keptByCountry.set(p.cc, same); + kept.add(f); + } + return { type: "FeatureCollection", features: fc.features.filter((f) => kept.has(f)) }; +} + /** * Point features for wish-to-go cities. Identical property shape to * {@link visitedCityPoints} (`wish: 1` is the only difference) so want-list @@ -131,6 +195,12 @@ export function optimizeVisitedPoints( */ export function wishlistCityPoints(visits: Visit[], ref: ReferenceData): FeatureCollection { const features: Feature[] = []; + const reps = countryReps( + visits.flatMap((v) => { + const c = v.status === "wishlist" && v.place.kind === "city" ? ref.cityById(v.place.id) : undefined; + return c ? [{ id: c.id, cc: c.countryIso2, pop: c.population ?? 0 }] : []; + }), + ); for (const v of visits) { if (v.status !== "wishlist" || v.place.kind !== "city") continue; const city = ref.cityById(v.place.id); @@ -148,7 +218,8 @@ export function wishlistCityPoints(visits: Visit[], ref: ReferenceData): Feature custom: 0, fav: v.favorite ? 1 : 0, wish: 1, - sortKey: (v.favorite ? -PINNED : 0) - (city.population ?? 0), + sortKey: + (v.favorite ? -PINNED : 0) - (reps.has(city.id) ? COUNTRY_REP : 0) - (city.population ?? 0), }, }); } diff --git a/apps/postcards/tests/unit/visitedLayers.spec.ts b/apps/postcards/tests/unit/visitedLayers.spec.ts index da1eaa3..f3704d0 100644 --- a/apps/postcards/tests/unit/visitedLayers.spec.ts +++ b/apps/postcards/tests/unit/visitedLayers.spec.ts @@ -5,6 +5,7 @@ import { visitedCityPoints, wishlistCityPoints, optimizeVisitedPoints, + stackSameCountry, tripArcs, } from "../../src/features/map/visitedLayers"; import type { PlaceRef, Trip, Visit } from "../../src/lib/schema/models"; @@ -144,8 +145,57 @@ describe("declutter priority (symbol-sort-key)", () => { // Lower symbol-sort-key wins the collision, so pinned marks must sort below. expect(fav.sortKey).toBeLessThan(nonFav.sortKey); expect(cust.sortKey).toBeLessThan(nonFav.sortKey); - // A plain visited city is unchanged: -population. - expect(nonFav.sortKey).toBe(-(parisCity.population ?? 0)); + // A plain visited city: -population, less the bias its country's main city gets. + expect(nonFav.sortKey).toBe(-100_000_000 - (parisCity.population ?? 0)); + }); +}); + +describe("flags at world zoom: one per country before any second one", () => { + const city = (q: string, cc: string) => ref.searchCities(q).find((c) => c.countryIso2 === cc)!; + const lyon = city("Lyon", "FR"); + const brussels = city("Brussels", "BE"); + const antwerp = city("Antwerp", "BE"); + const visits = [ + cityVisit(paris.id, paris.name, "FR"), + cityVisit(lyon.id, lyon.name, "FR"), + cityVisit(brussels.id, brussels.name, "BE"), + cityVisit(antwerp.id, antwerp.name, "BE"), + ]; + const names = (fc: FeatureCollection) => fc.features.map((f) => f.properties?.name).sort(); + + it("ranks every country's main city ahead of any country's second city", () => { + const marseille = city("Marseille", "FR"); + const set = [ + cityVisit(paris.id, paris.name, "FR"), + cityVisit(marseille.id, marseille.name, "FR"), + cityVisit(antwerp.id, antwerp.name, "BE"), + ]; + const key = (name: string) => + visitedCityPoints(set, ref).features.find((f) => f.properties?.name === name)!.properties! + .sortKey as number; + // Marseille outnumbers Antwerp, yet Belgium keeps its one flag first. + expect(marseille.population!).toBeGreaterThan(antwerp.population!); + expect(key(antwerp.name)).toBeLessThan(key(marseille.name)); + expect(key(paris.name)).toBeLessThan(key(antwerp.name)); + }); + + it("stacks a country's own flags where they would overlap, never another country's", () => { + const fc = visitedCityPoints(visits, ref); + expect(names(stackSameCountry(fc, 1))).toEqual([brussels.name, paris.name].sort()); + // Close enough in and every city has its own flag again. + expect(names(stackSameCountry(fc, 6))).toEqual( + [antwerp.name, brussels.name, lyon.name, paris.name].sort(), + ); + }); + + it("never stacks a favourite away, and a favourite heads its country's pile", () => { + const fav = (c: typeof paris) => ({ ...cityVisit(c.id, c.name, "FR"), favorite: true }); + expect(names(stackSameCountry(visitedCityPoints([fav(paris), fav(lyon)], ref), 1))).toEqual( + [lyon.name, paris.name].sort(), + ); + expect(names(stackSameCountry(visitedCityPoints([visits[0]!, fav(lyon)], ref), 1))).toEqual([ + lyon.name, + ]); }); }); @@ -167,8 +217,8 @@ describe("wishlist points share the visited shape (unified marker + optimise)", expect(p.cc).toBe("FR"); expect(p.custom).toBe(0); expect(p.fav).toBe(0); - // Same collision key as a visited city: -population. - expect(p.sortKey).toBe(-(parisCity.population ?? 0)); + // Same collision key as a visited city (the only one of its country). + expect(p.sortKey).toBe(-100_000_000 - (parisCity.population ?? 0)); }); it("carries the favourite star and pins a favourite want-list city (kept in a collision)", () => { From 8a91be8be9c8de9b4fe2f6b9b5b82f4cae9e033e Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:54:48 +0900 Subject: [PATCH 04/17] Top bar: fit the phone search field and draw control icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At 375px the brand's wordmark and four action buttons left the search field 85px wide, its placeholder clipped to "Sea"; below 480px the brand now shows its pin mark only (the wordmark stays its accessible name) and the field gets 158px. The search, GitHub star, map mode and map favourite icons were emoji, which draw as nothing without a colour emoji font (every Windows browser): they are inline SVG or canvas shapes now. Chromium's built-in clear button no longer doubles the field's own ✕. --- apps/postcards/src/app/App.tsx | 13 ++++- apps/postcards/src/features/map/MapScreen.tsx | 14 ++++-- apps/postcards/src/features/map/MapView.tsx | 23 +++++++-- .../src/features/visits/PlaceSearch.tsx | 7 +-- apps/postcards/src/styles.css | 30 ++++++++++- apps/postcards/src/ui/icons.tsx | 50 +++++++++++++++++++ apps/postcards/tests/e2e/topbar-phone.spec.ts | 34 +++++++++++++ 7 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 apps/postcards/tests/e2e/topbar-phone.spec.ts diff --git a/apps/postcards/src/app/App.tsx b/apps/postcards/src/app/App.tsx index 8ebb00a..a00b857 100644 --- a/apps/postcards/src/app/App.tsx +++ b/apps/postcards/src/app/App.tsx @@ -18,7 +18,16 @@ import { UpdateBanner } from "../ui/UpdateBanner"; import { handoffRequested } from "../lib/moved/moved"; import { followOtherTabs, loadPortable } from "../lib/store/portable"; import { ConnectionStatus } from "../ui/ConnectionStatus"; -import { MapIcon, ChartIcon, ListIcon, RouteIcon, BookIcon, GearIcon, InfoIcon } from "../ui/icons"; +import { + MapIcon, + ChartIcon, + ListIcon, + RouteIcon, + BookIcon, + GearIcon, + InfoIcon, + StarIcon, +} from "../ui/icons"; import { useState } from "react"; import { useInstallPrompt } from "../lib/hooks/useInstallPrompt"; import { useAutoSync } from "../lib/hooks/useAutoSync"; @@ -300,7 +309,7 @@ export function App() { aria-label={t("topbar.githubStar")} > - ⭐ + {t("topbar.github")} diff --git a/apps/postcards/src/features/map/MapScreen.tsx b/apps/postcards/src/features/map/MapScreen.tsx index 7285e5f..1a3a03f 100644 --- a/apps/postcards/src/features/map/MapScreen.tsx +++ b/apps/postcards/src/features/map/MapScreen.tsx @@ -35,6 +35,7 @@ import { import { activeChips } from "../filter/applyFilters"; import { FilterPanel } from "../../ui/FilterPanel"; import { FilterSummary } from "../../ui/FilterSummary"; +import { CityIcon, MonumentIcon, PlaneIcon } from "../../ui/icons"; import { useT, type MessageKey } from "../../lib/i18n"; // Fewer rows, faster everything: the list pages in small steps, and the @@ -720,7 +721,14 @@ export function MapScreen({ active = true }: { active?: boolean } = {}) {
{(["all", "cities", "monuments", "airports"] as FilterMode[]).map((m) => { const label = t(`filter.mode.${m}` as const); - const icon = m === "cities" ? "🏙" : m === "monuments" ? "🏛" : m === "airports" ? "✈" : ""; + const Icon = + m === "cities" + ? CityIcon + : m === "monuments" + ? MonumentIcon + : m === "airports" + ? PlaneIcon + : null; const active = mode === m; return ( ); })} diff --git a/apps/postcards/src/features/map/MapView.tsx b/apps/postcards/src/features/map/MapView.tsx index 759589c..cca9ede 100644 --- a/apps/postcards/src/features/map/MapView.tsx +++ b/apps/postcards/src/features/map/MapView.tsx @@ -131,6 +131,24 @@ function getRivers(): Promise { const PILL_FONT = '600 21px "Inter Variable", system-ui, sans-serif'; +/** Gold favourite star, drawn as a shape: a ⭐ emoji drew nothing on systems + * without a colour emoji font. White rim so it reads on any flag. */ +function drawStar(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void { + ctx.beginPath(); + for (let i = 0; i < 10; i++) { + const a = -Math.PI / 2 + (i * Math.PI) / 5; + const rr = i % 2 ? r * 0.45 : r; + ctx.lineTo(cx + rr * Math.cos(a), cy + rr * Math.sin(a)); + } + ctx.closePath(); + ctx.lineWidth = 2; + ctx.strokeStyle = "#ffffff"; + ctx.lineJoin = "round"; + ctx.stroke(); + ctx.fillStyle = "#f5b301"; + ctx.fill(); +} + /** * City marker, at ONE size for both states so a want-list city is never bigger * than a visited one. VISITED is the bare country flag. WANT-LIST is a distinct @@ -163,10 +181,7 @@ function makeCityPill(iso2: string, favorite: boolean, wish: boolean): ImageData ctx.font = `32px ${FLAG_FONT}`; ctx.fillText(countryFlag(iso2), w / 2, h / 2 + 5); } - if (favorite) { - ctx.font = `14px ${FLAG_FONT}`; - ctx.fillText("⭐", w - 9, 9); - } + if (favorite) drawStar(ctx, w - 9, 9, 7); return ctx.getImageData(0, 0, w, h); } diff --git a/apps/postcards/src/features/visits/PlaceSearch.tsx b/apps/postcards/src/features/visits/PlaceSearch.tsx index 8d938fa..4c084df 100644 --- a/apps/postcards/src/features/visits/PlaceSearch.tsx +++ b/apps/postcards/src/features/visits/PlaceSearch.tsx @@ -8,6 +8,7 @@ import { placeKey } from "../../lib/schema/helpers"; import type { PlaceRef } from "../../lib/schema/models"; import { useT } from "../../lib/i18n"; import { countryFlag } from "../../lib/format/format"; +import { SearchIcon } from "../../ui/icons"; /** * Global place search. Picking a result NAVIGATES — it flies the map to a @@ -36,7 +37,7 @@ export function PlaceSearch({ // On a phone the top-bar field is narrow (it shares the row with the brand and // the action icons), so the full "Search a city or country…" placeholder gets // clipped mid-word. Use a short placeholder there — it stays fully readable, - // and the leading 🔍 plus the accessible name still convey what it searches. + // and the leading magnifier plus the accessible name still convey what it searches. const [narrow, setNarrow] = useState( () => typeof matchMedia !== "undefined" && matchMedia("(max-width: 899.98px)").matches, ); @@ -147,7 +148,7 @@ export function PlaceSearch({ return (
- {/* A leading 🔍 makes the field unmistakably a search even when the top-bar + {/* A leading magnifier makes the field unmistakably a search even when the top-bar squeezes the placeholder to "Search a…" on a phone. It's a pointer affordance that focuses the field on tap; the input already carries the accessible name, so this is aria-hidden + non-focusable (no duplicate @@ -160,7 +161,7 @@ export function PlaceSearch({ title={t("search.aria")} onClick={() => inputRef.current?.focus()} > - 🔍 + ); } + +// Small inline icons that replace emoji used as controls: without a colour +// emoji font (Windows browsers, minimal Linux) the emoji drew as nothing. +// `size` in CSS pixels; each inherits the text colour. + +export function SearchIcon({ size = 16 }: { size?: number }) { + return ( + + + + + ); +} + +export function StarIcon({ size = 15 }: { size?: number }) { + return ( + + + + ); +} + +export function CityIcon({ size = 15 }: { size?: number }) { + return ( + + + + + + + ); +} + +export function MonumentIcon({ size = 15 }: { size?: number }) { + return ( + + + + + + ); +} + +export function PlaneIcon({ size = 15 }: { size?: number }) { + return ( + + + + ); +} diff --git a/apps/postcards/tests/e2e/topbar-phone.spec.ts b/apps/postcards/tests/e2e/topbar-phone.spec.ts new file mode 100644 index 0000000..cbf7cdd --- /dev/null +++ b/apps/postcards/tests/e2e/topbar-phone.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from "@playwright/test"; + +// A phone top bar holds the brand, the search and four action buttons. At 375px +// the search used to be 85px wide and clipped its placeholder to "Sea". +test.use({ viewport: { width: 375, height: 740 } }); + +test("the phone search field shows its whole placeholder", async ({ page }) => { + await page.goto("/"); + const input = page.getByLabel("Search a city or country"); + await expect(input).toBeVisible(); + const fit = await input.evaluate((el: HTMLInputElement) => { + const cs = getComputedStyle(el); + const ctx = document.createElement("canvas").getContext("2d")!; + ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; + const room = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); + return { room, text: ctx.measureText(el.placeholder).width }; + }); + expect(fit.room).toBeGreaterThanOrEqual(fit.text); + // The brand keeps its accessible name while only its mark shows. + await expect(page.getByRole("button", { name: "Postcards" })).toBeVisible(); +}); + +// Emoji used as control icons drew as nothing without a colour emoji font +// (every Windows browser): the controls carry drawn icons instead. +test("control icons are drawn, not emoji", async ({ page }) => { + await page.goto("/"); + for (const name of ["Cities", "Monuments", "Airports"]) { + const b = page.locator(".map-mode").getByRole("button", { name, exact: true }); + await expect(b.locator("svg")).toBeVisible(); + expect(await b.textContent()).not.toMatch(/\p{Extended_Pictographic}/u); + } + await expect(page.locator(".search-icon svg")).toBeVisible(); + await expect(page.locator(".topbar-star .star-glyph svg")).toBeVisible(); +}); From 2654a38b792ecd8961351ddf108f63b4bb7c33d6 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:02:38 +0900 Subject: [PATCH 05/17] Navigation: keep the screen in the address, across reloads and Forward A reload always landed on the map and the address stayed `/`, so a city page, a country page or a tab could not survive a reload or be linked, and the browser's Forward did nothing. The tab and the open place page now live in the hash (`#/places/country/FR`), restored before the first render; each navigation pushes an entry carrying its position, so Back keeps its rules (dialog first, sub-view, the app's own history, never quitting at home), walks the screens from before a reload, and Forward re-opens what Back left. The trip composer, an unsaved form, stays out. --- apps/postcards/src/app/App.tsx | 94 +++++++++++++------ apps/postcards/src/app/route.ts | 40 ++++++++ apps/postcards/src/lib/store/useUi.ts | 3 + .../tests/e2e/navigation-url.spec.ts | 38 ++++++++ apps/postcards/tests/unit/route.spec.ts | 22 +++++ 5 files changed, 168 insertions(+), 29 deletions(-) create mode 100644 apps/postcards/src/app/route.ts create mode 100644 apps/postcards/tests/e2e/navigation-url.spec.ts create mode 100644 apps/postcards/tests/unit/route.spec.ts diff --git a/apps/postcards/src/app/App.tsx b/apps/postcards/src/app/App.tsx index a00b857..cf23531 100644 --- a/apps/postcards/src/app/App.tsx +++ b/apps/postcards/src/app/App.tsx @@ -32,6 +32,7 @@ import { useState } from "react"; import { useInstallPrompt } from "../lib/hooks/useInstallPrompt"; import { useAutoSync } from "../lib/hooks/useAutoSync"; import { useT, type MessageKey } from "../lib/i18n"; +import { parseRoute, routeHash, type Route } from "./route"; // Code-split MapLibre so it loads only when the map is shown. const MapScreen = lazy(() => @@ -57,6 +58,16 @@ const TABS: { id: Tab; label: MessageKey; keys: string[]; Icon: () => JSX.Elemen const DIALOG_LAYER_SELECTOR = ".modal-backdrop, .lightbox, .maplibregl-popup:not(.map-keep-hidden *), .journal-composer-busy"; +// Land on the screen the address names (a reload, a shared link) before the +// first render, so reloading Places never spins the map up first. +const initialRoute = typeof location === "undefined" ? null : parseRoute(location.hash); +if (initialRoute) useUi.setState(initialRoute); + +/** What the address shows for the current screen (the trip composer stays out). */ +function currentRoute(s: Route): Route { + return { tab: s.tab, cityPageId: s.cityPageId, countryPageId: s.countryPageId }; +} + // First run: show the "How it works" intro once so a newcomer learns what the // app is and what's optionally downloadable, before touching anything. Stored, // so it never reappears; the top-bar button still opens it anytime. @@ -217,45 +228,70 @@ export function App() { // Back NEVER quits the app: at the home screen (map, empty history) it just // re-arms and stays put — like a native app, where you leave with the home/tab // gesture, not by backing out into a blank page. - useEffect(() => { - const arm = () => history.pushState({ pc: true }, ""); - arm(); - function onPop() { + // The screen also lives in the address (route.ts): each navigation pushes an + // entry carrying its position, so a reload lands where you were, Back after a + // reload walks the screens before it, and Forward re-opens what Back left. + // A layout effect, so a Back pressed as soon as the screen shows is caught. + useLayoutEffect(() => { + const state = history.state as { pc?: unknown } | null; + let at = typeof state?.pc === "number" ? state.pc : 0; + const hashNow = () => routeHash(currentRoute(useUi.getState())); + history.replaceState({ ...state, pc: at }, "", hashNow()); + // Point the address at the screen: a new entry when the screen moved, and + // one spare entry above the first, so Back from home has one to consume. + const sync = () => { + if (location.hash === hashNow() && at > 0) return; + at += 1; + history.pushState({ pc: at }, "", hashNow()); + }; + sync(); + const unsubscribe = useUi.subscribe((s, prev) => { + if ( + s.tab !== prev.tab || + s.cityPageId !== prev.cityPageId || + s.countryPageId !== prev.countryPageId + ) + sync(); + }); + function onPop(e: PopStateEvent) { + const to = (e.state as { pc?: unknown } | null)?.pc; + // A fragment link (the skip link) makes an entry of its own: not a screen. + if (typeof to !== "number") return; + const forward = to > at; + at = to; const ui = useUi.getState(); + const landed = parseRoute(location.hash); + if (forward) { + if (landed) ui.openRoute(landed); + sync(); + return; + } const dialogOpen = !!document.querySelector(DIALOG_LAYER_SELECTOR); if (dialogOpen) { // Let the open layer close via its own Escape handler. window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); - arm(); - return; - } - // Step out of a local sub-view first (mirrors Escape), then the LAST - // screen: pop the app's own navigation history. - if (runEscapeInterceptors()) { - arm(); - return; - } - if (ui.goBack()) { - arm(); - return; - } - // A detail page is still open with no history behind it (e.g. deep-linked or - // opened from search): close it in place — Back must never fall through and - // quit the app while you're looking at a city/country page. - if (ui.cityPageId || ui.countryPageId || ui.tripEditId) { + } else if (runEscapeInterceptors()) { + // Stepped out of a local sub-view first (mirrors Escape). + } else if (ui.goBack()) { + // The LAST screen: popped the app's own navigation history. + } else if (landed) { + // No history of its own (after a reload): the screen this entry names. + useUi.setState({ ...landed, tripEditId: null }); + } else if (ui.cityPageId || ui.countryPageId || ui.tripEditId) { + // A detail page with nothing behind it: close it in place — Back must + // never fall through and quit the app on a city/country page. ui.closePages(); - arm(); - return; } - // At the home screen (map) with nothing left in history: DON'T let Back quit - // the app — re-arm so the map is the terminal home for the Back gesture - // (matches a native app; use the tab/home gesture to actually leave). Fixes + // At the home screen with nothing left, this re-arms instead of letting + // Back quit the app (use the tab/home gesture to actually leave). Fixes // "map → places → country → back back … quit the application". - arm(); + sync(); } window.addEventListener("popstate", onPop); - return () => window.removeEventListener("popstate", onPop); - // eslint-disable-next-line react-hooks/exhaustive-deps + return () => { + window.removeEventListener("popstate", onPop); + unsubscribe(); + }; }, []); const currentTab = TABS.find((x) => x.id === tab); diff --git a/apps/postcards/src/app/route.ts b/apps/postcards/src/app/route.ts new file mode 100644 index 0000000..284404d --- /dev/null +++ b/apps/postcards/src/app/route.ts @@ -0,0 +1,40 @@ +import type { Tab } from "../lib/store/useUi"; + +// The screen in the address bar, so a reload lands where you were and a link +// opens that screen: `#/places`, `#/map/city/2988507`, `#/stats/country/FR`. +// Hash-based because static hosts (GitHub Pages, the native wrap) serve only +// the one index.html. The trip composer is an unsaved form, so it stays out. + +export interface Route { + tab: Tab; + cityPageId: string | null; + countryPageId: string | null; +} + +const TABS: readonly Tab[] = ["map", "places", "trips", "journal", "stats", "settings"]; + +export function routeHash(r: Route): string { + const page = r.cityPageId + ? `/city/${encodeURIComponent(r.cityPageId)}` + : r.countryPageId + ? `/country/${r.countryPageId}` + : ""; + return `#/${r.tab}${page}`; +} + +/** The route a hash names, or null for an empty or unknown one. */ +export function parseRoute(hash: string): Route | null { + const [tab, kind, id, ...rest] = hash.replace(/^#\/?/, "").split("/"); + if (!TABS.includes(tab as Tab) || rest.length) return null; + const route: Route = { tab: tab as Tab, cityPageId: null, countryPageId: null }; + if (kind === undefined) return route; + if (kind === "city" && id) { + try { + return { ...route, cityPageId: decodeURIComponent(id) }; + } catch { + return null; + } + } + if (kind === "country" && id && /^[A-Z]{2}$/.test(id)) return { ...route, countryPageId: id }; + return null; +} diff --git a/apps/postcards/src/lib/store/useUi.ts b/apps/postcards/src/lib/store/useUi.ts index 3093512..4acad76 100644 --- a/apps/postcards/src/lib/store/useUi.ts +++ b/apps/postcards/src/lib/store/useUi.ts @@ -63,6 +63,8 @@ interface UiState { openPlaces: (view: PlacesView) => void; journalDraftRequest: { place: PlaceRef; nonce: number } | null; openJournalDraft: (place: PlaceRef) => void; + /** Open the screen an address names (browser Forward), as a new history step. */ + openRoute: (r: Pick) => void; history: NavState[]; /** Return to the previous screen. True if there was somewhere to go back to. */ goBack: () => boolean; @@ -160,6 +162,7 @@ export const useUi = create((set, get) => { tripEditId: null, journalDraftRequest: { place, nonce: (get().journalDraftRequest?.nonce ?? 0) + 1 }, }), + openRoute: (r) => set({ history: pushHistory(), ...r, tripEditId: null }), history: [], goBack: () => { const h = get().history; diff --git a/apps/postcards/tests/e2e/navigation-url.spec.ts b/apps/postcards/tests/e2e/navigation-url.spec.ts new file mode 100644 index 0000000..f7809fd --- /dev/null +++ b/apps/postcards/tests/e2e/navigation-url.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from "@playwright/test"; + +// The current screen lives in the address (#/places, #/places/country/FR), so a +// reload lands where you were and the browser's Back and Forward walk screens. +test("a reload keeps the tab and the open page; Back and Forward walk them", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveURL(/#\/map$/); + await page.getByRole("button", { name: "Places", exact: true }).click(); + await expect(page).toHaveURL(/#\/places$/); + await page.reload(); + await expect(page.getByRole("button", { name: "Places", exact: true })).toHaveAttribute( + "aria-current", + "page", + ); + + await page.getByLabel("Search a city or country").fill("France"); + await page.locator("#search-results .result-open", { hasText: "France" }).first().click(); + await expect(page).toHaveURL(/#\/places\/country\/FR$/); + await page.reload(); + const heading = page.getByRole("heading", { name: /France/ }).first(); + await expect(heading).toBeVisible(); + + await page.goBack(); + await expect(page).toHaveURL(/#\/places$/); + await expect(heading).toBeHidden(); + await page.goForward(); + await expect(page).toHaveURL(/#\/places\/country\/FR$/); + await expect(heading).toBeVisible(); +}); + +test("Back at the home screen stays in the app", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Cities in view")).toBeVisible(); + await page.goBack(); + await page.goBack(); + await expect(page).toHaveURL(/localhost:4173\/#\/map$/); + await expect(page.getByText("Cities in view")).toBeVisible(); +}); diff --git a/apps/postcards/tests/unit/route.spec.ts b/apps/postcards/tests/unit/route.spec.ts new file mode 100644 index 0000000..657371d --- /dev/null +++ b/apps/postcards/tests/unit/route.spec.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { parseRoute, routeHash } from "../../src/app/route"; + +describe("app: the screen in the address", () => { + it("round-trips a tab, a city page and a country page", () => { + for (const r of [ + { tab: "places" as const, cityPageId: null, countryPageId: null }, + { tab: "map" as const, cityPageId: "2988507", countryPageId: null }, + { tab: "stats" as const, cityPageId: null, countryPageId: "FR" }, + { tab: "journal" as const, cityPageId: "custom:a b/c", countryPageId: null }, + ]) + expect(parseRoute(routeHash(r))).toEqual(r); + expect(routeHash({ tab: "stats", cityPageId: null, countryPageId: "FR" })).toBe( + "#/stats/country/FR", + ); + }); + + it("names nothing for an empty, foreign or malformed hash", () => { + for (const h of ["", "#", "#main", "#/nowhere", "#/places/country/fr", "#/map/city/", "#/map/city/1/x", "#/map/city/%E0%A4%A"]) + expect(parseRoute(h), h).toBeNull(); + }); +}); From 48bef26ea707be9594b4369fbba38cd83f69e4bf Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:07:45 +0900 Subject: [PATCH 06/17] Backup: show the backup reminder atop Places, not only deep in Settings The reminder to export a backup, the one thing between a lost phone and lost travels, only existed 2,226 px down Settings. Once a backup is due (data and none for 30 days, not snoozed) the same reminder, with the same wording, Back up now and Later, now heads Places as one slim line; the Settings one is the same component. --- apps/postcards/src/features/backup/Backup.tsx | 88 ++++++++++++------- .../src/features/visits/PlacesScreen.tsx | 29 ++++++ apps/postcards/src/styles.css | 10 +++ .../tests/unit/placesScreen.spec.tsx | 26 ++++++ 4 files changed, 121 insertions(+), 32 deletions(-) diff --git a/apps/postcards/src/features/backup/Backup.tsx b/apps/postcards/src/features/backup/Backup.tsx index 52cef33..63f0c7c 100644 --- a/apps/postcards/src/features/backup/Backup.tsx +++ b/apps/postcards/src/features/backup/Backup.tsx @@ -16,6 +16,7 @@ import { snoozeReminder, } from "../../lib/backupReminder"; import { useT } from "../../lib/i18n"; +import type { Story, Trip, Visit } from "../../lib/schema/models"; /** * Get the file to the user, wherever they want it. Inside the native wrap @@ -62,6 +63,58 @@ async function deliverBlob(filename: string, blob: Blob, type: string): Promise< await downloadBlob(filename, blob); } +/** The full .json backup, delivered, and the reminder clock reset: a places- + * only .csv/.md export is a share, not a backup. */ +export async function backUpNow(visits: Visit[], trips: Trip[], stories: Story[]): Promise { + // Loaded on click: the codec pulls in the Zod schemas (~65 KB min), which + // nothing on the startup path needs — keep them out of the boot chunk. + const { serializeFile, EXPORT_FILENAME } = await import("./exportJson"); + await deliver(EXPORT_FILENAME, serializeFile(visits, trips, stories), "application/json"); + markBackedUp(Date.now()); +} + +/** "It's been N days since your last backup": the nudge Settings and Places + * both show once a backup is due (lib/backupReminder decides when). */ +export function BackupReminder({ + onBackUp, + onLater, + compact = false, +}: { + onBackUp: () => void; + onLater: () => void; + /** One slim line, for a screen that is about something else. */ + compact?: boolean; +}) { + const t = useT(); + const daysSince = daysSinceBackup(Date.now()); + return ( +
+ 🛟 + + {daysSince == null + ? t("backup.reminder.never") + : t.plural("backup.reminder.days", daysSince)}{" "} + {t("backup.reminder.suffix")} + + + + + +
+ ); +} + export function Backup() { const t = useT(); const ref = useMemo(() => getReferenceData(), []); @@ -76,18 +129,12 @@ export function Backup() { const RESET_WORD = "RESET"; const hasData = visits.length > 0 || trips.length > 0 || stories.length > 0; // Whether to nudge a backup right now (computed once on open, editable by the - // export/snooze actions). daysSince is for the wording ("N days" vs "never"). + // export/snooze actions). const [reminderDue, setReminderDue] = useState(() => isBackupDue(hasData, Date.now())); - const daysSince = daysSinceBackup(Date.now()); async function exportJson() { try { - // Loaded on click: the codec pulls in the Zod schemas (~65 KB min), which - // nothing on the startup path needs — keep them out of the boot chunk. - const { serializeFile, EXPORT_FILENAME } = await import("./exportJson"); - await deliver(EXPORT_FILENAME, serializeFile(visits, trips, stories), "application/json"); - // A full .json export is a real backup — reset the reminder clock. - markBackedUp(Date.now()); + await backUpNow(visits, trips, stories); setReminderDue(false); } catch { setMessage({ kind: "err", text: t("backup.msg.exportJsonErr") }); @@ -213,30 +260,7 @@ export function Backup() { {reminderDue && ( -
- 🛟 - - {daysSince == null - ? t("backup.reminder.never") - : t.plural("backup.reminder.days", daysSince)}{" "} - {t("backup.reminder.suffix")} - - - - - -
+ void exportJson()} onLater={() => setReminderDue(false)} /> )}

{t("backup.intro")}

diff --git a/apps/postcards/src/features/visits/PlacesScreen.tsx b/apps/postcards/src/features/visits/PlacesScreen.tsx index a28b5d0..74611b2 100644 --- a/apps/postcards/src/features/visits/PlacesScreen.tsx +++ b/apps/postcards/src/features/visits/PlacesScreen.tsx @@ -40,6 +40,10 @@ import { placeMatches, sortPlaces, activeChips } from "../filter/applyFilters"; import { FilterPanel } from "../../ui/FilterPanel"; import { FilterSummary } from "../../ui/FilterSummary"; import { useT, type TFunction } from "../../lib/i18n"; +import { useTrips } from "../../lib/store/useTrips"; +import { useStories } from "../../lib/store/useStories"; +import { isBackupDue } from "../../lib/backupReminder"; +import { BackupReminder, backUpNow } from "../backup/Backup"; // The Places screen is ONE unified explore-&-track surface (spec 018): two // independent single-select axes drive it — a KIND (what you're looking at) and a @@ -456,6 +460,29 @@ function NoMatch({ q, onClear }: { q: string; onClear: () => void }) { /** Browse and track every place kind — one kind axis × one status axis, plus the * cross-cutting Moments / Photos / Passport collections. */ +/** The backup nudge used to live only 2,000 px down Settings, where nobody + * scrolls: once a backup is due (lib/backupReminder) it heads Places too. */ +function PlacesBackupReminder() { + const visits = useVisits((s) => s.visits); + const trips = useTrips((s) => s.trips); + const stories = useStories((s) => s.stories); + const [dismissed, setDismissed] = useState(false); + const hasData = visits.length > 0 || trips.length > 0 || stories.length > 0; + if (dismissed || !isBackupDue(hasData, Date.now())) return null; + return ( + + void backUpNow(visits, trips, stories).then( + () => setDismissed(true), + () => undefined, // not delivered: the nudge stays + ) + } + onLater={() => setDismissed(true)} + /> + ); +} + export function PlacesScreen() { const t = useT(); const ref = useMemo(() => getReferenceData(), []); @@ -839,6 +866,8 @@ export function PlacesScreen() {
+ + {/* The two independent axes. Each place kind appears in exactly ONE control (the kind axis) — no kind duplicated in a status/collection row (US1). */}
diff --git a/apps/postcards/src/styles.css b/apps/postcards/src/styles.css index 93836b8..23254b5 100644 --- a/apps/postcards/src/styles.css +++ b/apps/postcards/src/styles.css @@ -2138,6 +2138,16 @@ a.btn-ghost { align-items: center; gap: 12px; } +/* The same nudge as a slim line atop Places. */ +.backup-reminder-line { + padding: 7px 12px; + gap: 6px 10px; +} +.backup-reminder-line .backup-reminder-text { + min-width: 0; + flex-basis: 220px; + font-size: 13px; +} .attribution { margin-top: 22px; font-size: 12px; diff --git a/apps/postcards/tests/unit/placesScreen.spec.tsx b/apps/postcards/tests/unit/placesScreen.spec.tsx index d43c12d..4eb3349 100644 --- a/apps/postcards/tests/unit/placesScreen.spec.tsx +++ b/apps/postcards/tests/unit/placesScreen.spec.tsx @@ -95,3 +95,29 @@ describe("PlacesScreen filters on a world browse", () => { expect(panel.textContent).toContain("Date"); }); }); + +describe("PlacesScreen backup reminder", () => { + const never = /You haven't backed up yet/; + + it("heads Places once there is data and no backup, and Later snoozes it", () => { + useVisits.setState({ visits: [visitOf(site("cultural"))] }); + render(); + expect(screen.getByText(never)).toBeTruthy(); + act(() => screen.getByRole("button", { name: "Later" }).click()); + expect(screen.queryByText(never)).toBeNull(); + // Snoozed for the week everywhere, Settings included (lib/backupReminder). + cleanup(); + render(); + expect(screen.queryByText(never)).toBeNull(); + }); + + it("stays away with nothing to lose or a recent backup", () => { + render(); + expect(screen.queryByRole("button", { name: "Back up now" })).toBeNull(); + cleanup(); + localStorage.setItem("postcards-last-backup", String(Date.now())); + useVisits.setState({ visits: [visitOf(site("cultural"))] }); + render(); + expect(screen.queryByRole("button", { name: "Back up now" })).toBeNull(); + }); +}); From 2519d03632b0eab2c08f1d6b4a1179606c1e7b4c Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:10:55 +0900 Subject: [PATCH 07/17] Flags: draw the bundled flags at the size of colour-emoji flags The Twemoji flag glyph fills about 85% of the em a colour-emoji flag fills, so every flag in a list came out a size smaller than the layout was drawn for. The font face now carries size-adjust: 118%, which also covers canvases, so the map marker goes back to its original font size. --- apps/postcards/src/features/map/MapView.tsx | 5 ++--- apps/postcards/src/styles.css | 3 +++ apps/postcards/tests/e2e/flags.spec.ts | 3 +++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/postcards/src/features/map/MapView.tsx b/apps/postcards/src/features/map/MapView.tsx index cca9ede..db6b711 100644 --- a/apps/postcards/src/features/map/MapView.tsx +++ b/apps/postcards/src/features/map/MapView.tsx @@ -176,9 +176,8 @@ function makeCityPill(iso2: string, favorite: boolean, wish: boolean): ImageData ctx.strokeStyle = "#ffffff"; ctx.stroke(); } else { - // The bundled flag glyph fills less of its em box than a colour-emoji flag - // and sits above the middle baseline: a larger size, set lower, centres it. - ctx.font = `32px ${FLAG_FONT}`; + // The bundled flag glyph sits above the middle baseline: set lower to centre. + ctx.font = `27px ${FLAG_FONT}`; ctx.fillText(countryFlag(iso2), w / 2, h / 2 + 5); } if (favorite) drawStar(ctx, w - 9, 9, 7); diff --git a/apps/postcards/src/styles.css b/apps/postcards/src/styles.css index 23254b5..905ce8e 100644 --- a/apps/postcards/src/styles.css +++ b/apps/postcards/src/styles.css @@ -115,6 +115,9 @@ font-family: "Twemoji Country Flags"; src: url("/fonts/TwemojiCountryFlags.woff2") format("woff2"); unicode-range: U+1F1E6-1F1FF; + /* Its flags fill less of the em than colour-emoji flags: scale them to match, + so a flag keeps the size the layout was drawn for. */ + size-adjust: 118%; font-display: swap; } diff --git a/apps/postcards/tests/e2e/flags.spec.ts b/apps/postcards/tests/e2e/flags.spec.ts index 73aec43..ba6098b 100644 --- a/apps/postcards/tests/e2e/flags.spec.ts +++ b/apps/postcards/tests/e2e/flags.spec.ts @@ -21,11 +21,14 @@ test("country flags draw from the bundled flag font", async ({ page }) => { return { status: f?.status, range: f?.unicodeRange, + // Its glyphs fill less of the em than colour-emoji flags: scaled to match. + sizeAdjust: (f as (FontFace & { sizeAdjust?: string }) | undefined)?.sizeAdjust, stack: getComputedStyle(document.body).fontFamily, }; }); expect(face.status).toBe("loaded"); expect(face.range).toBe("U+1F1E6-1F1FF"); + expect(face.sizeAdjust).toBe("118%"); expect(face.stack.replace(/"/g, "")).toMatch(/^Twemoji Country Flags,/); expect(fontLoads).toContain(200); }); From 6b0501bcd5f0a1d38709e9e7004fc9603b84e8a5 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:58:21 +0900 Subject: [PATCH 08/17] Startup: paint the shell at once and load reference data by priority On a 4G phone with a 4x slower CPU nothing painted for about 7.5 s: the entry imported MapLibre's chunk (its stylesheet was assigned to that chunk, and the trip composer's route map imported it statically), so all of MapLibre downloaded and ran before the app started, and the first render then waited for every reference file while five preloads fought the bundle for bandwidth. MapLibre now loads with the map, index.html carries the top bar as a static shell, the first render waits only for the cities and their regions (the two files still preloaded), and airports, heritage sites, languages and article names merge in behind it with the gazetteer event; search, Places and the map refresh on it, and a reload straight onto Trips, the Journal or a place page waits for them. Measured on that profile (median of 3): first paint 7.6 s to 1.6 s, app usable 8.5 s to 4.9 s, map 8.5 s to 7.0 s. --- apps/postcards/index.html | 26 +++--- apps/postcards/src/app/App.tsx | 14 ++- apps/postcards/src/features/map/MapView.tsx | 1 + .../src/features/travel/MyPlacesPicker.tsx | 11 ++- .../src/features/visits/PlaceSearch.tsx | 5 +- .../src/features/visits/PlacesScreen.tsx | 8 +- apps/postcards/src/lib/i18n/en.ts | 1 + apps/postcards/src/lib/i18n/fr.ts | 1 + apps/postcards/src/lib/i18n/ko.ts | 1 + .../src/lib/reference/referenceData.ts | 90 +++++++++++++------ .../src/lib/reference/useGazetteer.ts | 12 ++- apps/postcards/tests/e2e/startup.spec.ts | 31 +++++++ .../tests/unit/referenceStartup.spec.ts | 45 ++++++++++ apps/postcards/vite.config.ts | 4 + 14 files changed, 203 insertions(+), 47 deletions(-) create mode 100644 apps/postcards/tests/e2e/startup.spec.ts create mode 100644 apps/postcards/tests/unit/referenceStartup.spec.ts diff --git a/apps/postcards/index.html b/apps/postcards/index.html index 83b4638..ef2437a 100644 --- a/apps/postcards/index.html +++ b/apps/postcards/index.html @@ -53,18 +53,19 @@ - + - - - Postcards @@ -80,7 +81,10 @@ -
+ +

Postcards

diff --git a/apps/postcards/src/app/App.tsx b/apps/postcards/src/app/App.tsx index cf23531..0e1f445 100644 --- a/apps/postcards/src/app/App.tsx +++ b/apps/postcards/src/app/App.tsx @@ -33,6 +33,7 @@ import { useInstallPrompt } from "../lib/hooks/useInstallPrompt"; import { useAutoSync } from "../lib/hooks/useAutoSync"; import { useT, type MessageKey } from "../lib/i18n"; import { parseRoute, routeHash, type Route } from "./route"; +import { useReferenceExtrasPending } from "../lib/reference/useGazetteer"; // Code-split MapLibre so it loads only when the map is shown. const MapScreen = lazy(() => @@ -106,6 +107,13 @@ export function App() { const mapShown = useRef(false); if (tab === "map") mapShown.current = true; const mapVisible = tab === "map" && !cityPageId && !countryPageId && !tripEditId; + // Airports and heritage sites land a moment after the first paint. The map, + // Places, Stats and country pages refresh when they do; trips (distances), + // the journal (story maps) and a place's page read them once, so a reload + // straight onto one waits that moment instead of showing it half-resolved. + const extrasPending = useReferenceExtrasPending(); + const waitForExtras = + extrasPending && (!!cityPageId || !!tripEditId || tab === "trips" || tab === "journal"); const firstRender = useRef(true); // Scroll memory.
is the single scroll container reused across tabs and @@ -410,7 +418,11 @@ export function App() {
)} - {cityPageId ? ( + {waitForExtras ? ( +

+ {t("app.loading")} +

+ ) : cityPageId ? ( useUi.getState().closeCity()} /> ) : countryPageId ? ( useUi.getState().closeCity()} /> diff --git a/apps/postcards/src/features/map/MapView.tsx b/apps/postcards/src/features/map/MapView.tsx index db6b711..3f38383 100644 --- a/apps/postcards/src/features/map/MapView.tsx +++ b/apps/postcards/src/features/map/MapView.tsx @@ -1828,6 +1828,7 @@ export function MapView({ lastCitiesKey.current = ""; lastWishKey.current = ""; lastAirKey.current = ""; + lastPoiKey.current = ""; // heritage sites and airports arrive after first paint applyVisited(map); applyInViewCities(map); // the full set changes which cities fall in view // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/postcards/src/features/travel/MyPlacesPicker.tsx b/apps/postcards/src/features/travel/MyPlacesPicker.tsx index 43581e0..b73f386 100644 --- a/apps/postcards/src/features/travel/MyPlacesPicker.tsx +++ b/apps/postcards/src/features/travel/MyPlacesPicker.tsx @@ -1,11 +1,14 @@ -import { useDeferredValue, useMemo, useState } from "react"; +import { lazy, Suspense, useDeferredValue, useMemo, useState } from "react"; import { getReferenceData } from "../../lib/reference/referenceData"; import { searchPlaces } from "../visits/search"; -import { RouteMap } from "./RouteMap"; import { useT } from "../../lib/i18n"; import type { PlaceRef, TravelMode } from "../../lib/schema/models"; import { placeFlag, type MyPlace } from "./myPlaces"; +// MapLibre (~280 KB compressed) loads with the map view that needs it: a +// static import here put it on every app start, ahead of the first paint. +const RouteMap = lazy(() => import("./RouteMap").then((m) => ({ default: m.RouteMap }))); + // Pick trip stops fast. Two ways: // • List — the places you've BEEN (visited + past trips) as instant taps, AND a // search that reaches ANY airport or city in the gazetteer (airports are central @@ -128,7 +131,9 @@ export function MyPlacesPicker({ ) : places.length === 0 ? (

{t("trip.compose.noPlaces")}

) : ( - + {t("map.loading")}

}> + +
)} ); diff --git a/apps/postcards/src/features/visits/PlaceSearch.tsx b/apps/postcards/src/features/visits/PlaceSearch.tsx index 4c084df..edc74d5 100644 --- a/apps/postcards/src/features/visits/PlaceSearch.tsx +++ b/apps/postcards/src/features/visits/PlaceSearch.tsx @@ -1,6 +1,7 @@ import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { getReferenceData } from "../../lib/reference/referenceData"; import { searchPlaces } from "./search"; +import { useGazetteerGeneration } from "../../lib/reference/useGazetteer"; import { useVisits, findByPlace, visitIndex } from "../../lib/store/useVisits"; import { useUi } from "../../lib/store/useUi"; import { useToast } from "../../lib/store/useToast"; @@ -59,7 +60,9 @@ export function PlaceSearch({ // if the next keystroke lands first). notFound keys on the deferred query so // the add-place form doesn't flash while results lag a beat behind. const dq = useDeferredValue(q); - const results = useMemo(() => searchPlaces(ref, dq), [ref, dq]); + // Airports and heritage sites land a moment after first paint: search again then. + const gazGen = useGazetteerGeneration(); + const results = useMemo(() => searchPlaces(ref, dq), [ref, dq, gazGen]); const notFound = dq.trim().length >= 2 && results.length === 0; // Keep the active option visible as arrows move it. diff --git a/apps/postcards/src/features/visits/PlacesScreen.tsx b/apps/postcards/src/features/visits/PlacesScreen.tsx index 74611b2..d772c44 100644 --- a/apps/postcards/src/features/visits/PlacesScreen.tsx +++ b/apps/postcards/src/features/visits/PlacesScreen.tsx @@ -9,6 +9,7 @@ import { type RefObject, } from "react"; import { getReferenceData } from "../../lib/reference/referenceData"; +import { useGazetteerGeneration } from "../../lib/reference/useGazetteer"; import { useVisits } from "../../lib/store/useVisits"; import { useToast } from "../../lib/store/useToast"; import { useUi, type PlacesView } from "../../lib/store/useUi"; @@ -303,6 +304,7 @@ function RowMenu({ const VisitRow = memo(function VisitRow({ v, wishlist }: { v: Visit; wishlist?: boolean }) { const t = useT(); const ref = useMemo(() => getReferenceData(), []); + useGazetteerGeneration(); // a monument's glyph and an airport's place resolve once they land const toggleVisit = useVisits((s) => s.toggleVisit); const toggleFavorite = useVisits((s) => s.toggleFavorite); const [menuOpen, setMenuOpen] = useState(false); @@ -574,7 +576,9 @@ export function PlacesScreen() { }); }, [collection]); - const heritageAvailable = useMemo(() => ref.allHeritage().length > 0, [ref]); + // Heritage sites and airports land a moment after first paint (initReferenceData). + const gazGen = useGazetteerGeneration(); + const heritageAvailable = useMemo(() => ref.allHeritage().length > 0, [ref, gazGen]); // ── Personal records (kind = All): the user's own saved places, all kinds mixed, // narrowed by the status axis. ──────────────────────────────────────────────── @@ -699,7 +703,7 @@ export function PlacesScreen() { return { rows: [] as BrowseRow[], hasMore: false }; return browseList(kind, status, currentFilters(filters), ref, visits, deferredFilter.trim(), shown); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [kind, status, filters.continent, filters.minPop, filters.category, filters.country, ref, visits, deferredFilter, shown]); + }, [kind, status, filters.continent, filters.minPop, filters.category, filters.country, ref, visits, deferredFilter, shown, gazGen]); const browseRows = browse.rows; // The years your visits span, newest first, for the date filter chips. diff --git a/apps/postcards/src/lib/i18n/en.ts b/apps/postcards/src/lib/i18n/en.ts index e5ab29f..6574460 100644 --- a/apps/postcards/src/lib/i18n/en.ts +++ b/apps/postcards/src/lib/i18n/en.ts @@ -38,6 +38,7 @@ export const en = { "nav.sectionsAria": "Sections", "nav.sectionStatus": "{section} section", "app.skipToContent": "Skip to content", + "app.loading": "Loading…", "topbar.goToMap": "Go to the map", "topbar.install": "Install app", "topbar.installAria": "Install the app", diff --git a/apps/postcards/src/lib/i18n/fr.ts b/apps/postcards/src/lib/i18n/fr.ts index 341343b..7ae22a6 100644 --- a/apps/postcards/src/lib/i18n/fr.ts +++ b/apps/postcards/src/lib/i18n/fr.ts @@ -31,6 +31,7 @@ export const fr: Messages = { "nav.sectionsAria": "Sections", "nav.sectionStatus": "Section {section}", "app.skipToContent": "Aller au contenu", + "app.loading": "Chargement…", "topbar.goToMap": "Aller à la carte", "topbar.install": "Installer l'appli", "topbar.installAria": "Installer l'application", diff --git a/apps/postcards/src/lib/i18n/ko.ts b/apps/postcards/src/lib/i18n/ko.ts index ea17de8..187d14c 100644 --- a/apps/postcards/src/lib/i18n/ko.ts +++ b/apps/postcards/src/lib/i18n/ko.ts @@ -32,6 +32,7 @@ export const ko: Messages = { "nav.sectionsAria": "섹션", "nav.sectionStatus": "{section} 섹션", "app.skipToContent": "본문으로 건너뛰기", + "app.loading": "불러오는 중…", "topbar.goToMap": "지도로 이동", "topbar.install": "앱 설치", "topbar.installAria": "앱 설치", diff --git a/apps/postcards/src/lib/reference/referenceData.ts b/apps/postcards/src/lib/reference/referenceData.ts index 9c88615..e769311 100644 --- a/apps/postcards/src/lib/reference/referenceData.ts +++ b/apps/postcards/src/lib/reference/referenceData.ts @@ -125,8 +125,8 @@ class ReferenceDataImpl implements ReferenceData { readonly countries: Country[]; readonly provenance: ReferenceProvenance[] = provenance; private cities: IndexedCity[] = []; - private airports: IndexedAirport[]; - private heritage: IndexedHeritage[]; + private airports: IndexedAirport[] = []; + private heritage: IndexedHeritage[] = []; private byIso2 = new Map(); private byNumeric = new Map(); private cityIndex = new Map(); @@ -136,8 +136,8 @@ class ReferenceDataImpl implements ReferenceData { private subIndex = new Map(); private subsByCountry = new Map(); private countrySearch: { c: Country; search: string }[]; - private languages: Record; - private articleNames: Record; + private languages: Record = {}; + private articleNames: Record = {}; // The world gazetteer (bundled core → optional full set). Kept separate from the // community-pack places so a gazetteer swap never drops installed packs. @@ -194,16 +194,12 @@ class ReferenceDataImpl implements ReferenceData { languages: Record = {}, articleNames: Record = {}, ) { - this.languages = languages; - this.articleNames = articleNames; // Population-descending order is the contract everywhere (search relevance, // the cities-in-view list's presorted fast path). The bundled file is already // sorted, so this is a near-free adaptive pass; it guarantees injected data too. this.baseCities = cities .map((c) => ({ ...c, search: normalize(c.name) })) .sort((a, b) => (b.population ?? 0) - (a.population ?? 0)); - this.airports = airports.map((a) => ({ ...a, search: normalize(a.name) })); - this.heritage = heritage.map((h) => ({ ...h, search: normalize(h.name) })); this.countries = buildCountries(cities, subdivisions); this.countrySearch = this.countries.map((c) => ({ c, search: normalize(c.name) })); for (const c of this.countries) { @@ -212,19 +208,37 @@ class ReferenceDataImpl implements ReferenceData { } // Build the live city set (world gazetteer + any installed pack places). this.remergeCities(); + this.setExtras(airports, heritage, languages, articleNames); + for (const s of subdivisions) { + this.subIndex.set(s.id, s); + const arr = this.subsByCountry.get(s.countryIso2); + if (arr) arr.push(s); + else this.subsByCountry.set(s.countryIso2, [s]); + } + } + + /** Install the airports, heritage sites, languages and article names, which + * arrive after first paint (see initReferenceData). */ + setExtras( + airports: Airport[], + heritage: HeritageSite[], + languages: Record, + articleNames: Record, + ): void { + this.languages = languages; + this.articleNames = articleNames; + this.airports = airports.map((a) => ({ ...a, search: normalize(a.name) })); + this.heritage = heritage.map((h) => ({ ...h, search: normalize(h.name) })); + this.airportIndex.clear(); for (const a of this.airports) this.airportIndex.set(a.id, a); + this.heritageIndex.clear(); + this.heritageByCountry.clear(); for (const h of this.heritage) { this.heritageIndex.set(h.id, h); const arr = this.heritageByCountry.get(h.countryIso2); if (arr) arr.push(h); else this.heritageByCountry.set(h.countryIso2, [h]); } - for (const s of subdivisions) { - this.subIndex.set(s.id, s); - const arr = this.subsByCountry.get(s.countryIso2); - if (arr) arr.push(s); - else this.subsByCountry.set(s.countryIso2, [s]); - } } countryByIso2(iso2: string): Country | undefined { @@ -366,23 +380,16 @@ export function initReferenceDataSync( export async function initReferenceData(): Promise { if (instance) return instance; try { - const [cities, subdivisions, airports, heritage, landmarks, languages, articleNames] = await Promise.all([ + // The first render waits only for what every first screen reads: the city + // gazetteer and its regions. On a phone the rest used to hold the first + // paint back by seconds; it follows in the background (loadExtras). + const [cities, subdivisions] = await Promise.all([ fetch(CITIES_URL).then((r) => (r.ok ? r.json() : Promise.reject(new Error("cities")))), fetch(SUBDIVISIONS_URL).then((r) => (r.ok ? r.json() : [])), - fetch(AIRPORTS_URL).then((r) => (r.ok ? r.json() : [])), - fetch(HERITAGE_URL).then((r) => (r.ok ? r.json() : [])), - fetch(LANDMARKS_URL).then((r) => (r.ok ? r.json() : [])), - fetch(LANGUAGES_URL).then((r) => (r.ok ? r.json() : {})), - fetch(ARTICLE_NAMES_URL).then((r) => (r.ok ? r.json() : {})), ]); - const ref = initReferenceDataSync( - cities as City[], - subdivisions as Subdivision[], - airports as Airport[], - [...(heritage as HeritageSite[]), ...(landmarks as HeritageSite[])], - languages as Record, - articleNames as Record, - ); + const ref = initReferenceDataSync(cities as City[], subdivisions as Subdivision[]); + extrasPending = true; + void loadExtras(ref as ReferenceDataImpl); // The full 135k-city set is NOT auto-fetched — it's a one-tap download in // Settings (like a tile pack). Only re-load it here if the user already opted // in on a previous run; then it comes straight from the SW cache. @@ -394,6 +401,33 @@ export async function initReferenceData(): Promise { } } +let extrasPending = false; +/** Whether the airports and heritage sites are still on their way (loadExtras). */ +export function referenceExtrasPending(): boolean { + return extrasPending; +} + +/** Airports, heritage sites + landmarks, languages and article names, merged + * in once they arrive; the gazetteer event lets screens that hold snapshots + * (the map's markers, stats, country pages) refresh. */ +async function loadExtras(impl: ReferenceDataImpl): Promise { + const json = (url: string, empty: T): Promise => + fetch(url) + .then((r) => (r.ok ? (r.json() as Promise) : empty)) + .catch(() => empty); + const [airports, heritage, landmarks, languages, articleNames] = await Promise.all([ + json(AIRPORTS_URL, []), + json(HERITAGE_URL, []), + json(LANDMARKS_URL, []), + json>(LANGUAGES_URL, {}), + json>(ARTICLE_NAMES_URL, {}), + ]); + impl.setExtras(airports, [...heritage, ...landmarks], languages, articleNames); + extrasPending = false; + generation++; + window.dispatchEvent(new Event(GAZETTEER_UPGRADED_EVENT)); +} + /** Wait for a calm moment — the 17 MB gazetteer must never race first paint, * the map spinning up, or the service worker's install for bandwidth/CPU. */ function whenIdle(): Promise { diff --git a/apps/postcards/src/lib/reference/useGazetteer.ts b/apps/postcards/src/lib/reference/useGazetteer.ts index 80e3fa4..9cf1ec8 100644 --- a/apps/postcards/src/lib/reference/useGazetteer.ts +++ b/apps/postcards/src/lib/reference/useGazetteer.ts @@ -1,5 +1,9 @@ import { useSyncExternalStore } from "react"; -import { GAZETTEER_UPGRADED_EVENT, gazetteerGeneration } from "./referenceData"; +import { + GAZETTEER_UPGRADED_EVENT, + gazetteerGeneration, + referenceExtrasPending, +} from "./referenceData"; /** * Re-render when the background full-gazetteer upgrade lands (core ~24k cities @@ -13,6 +17,12 @@ export function useGazetteerGeneration(): number { return useSyncExternalStore(subscribe, gazetteerGeneration, gazetteerGeneration); } +/** True until the airports and heritage sites, which follow the first paint, + * have landed (initReferenceData). */ +export function useReferenceExtrasPending(): boolean { + return useSyncExternalStore(subscribe, referenceExtrasPending, referenceExtrasPending); +} + function subscribe(onChange: () => void): () => void { window.addEventListener(GAZETTEER_UPGRADED_EVENT, onChange); return () => window.removeEventListener(GAZETTEER_UPGRADED_EVENT, onChange); diff --git a/apps/postcards/tests/e2e/startup.spec.ts b/apps/postcards/tests/e2e/startup.spec.ts new file mode 100644 index 0000000..4c5408f --- /dev/null +++ b/apps/postcards/tests/e2e/startup.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from "@playwright/test"; + +// On a throttled phone nothing painted for ~7 s: the page waited for the +// bundle, MapLibre (pulled into the entry by its stylesheet and the trip +// composer's route map) and every reference file. The shell now paints from the +// HTML, MapLibre loads with the map, and the first render waits only for the +// cities and their regions. +test("the first paint needs neither MapLibre nor the airport and heritage lists", async ({ + page, +}) => { + const html = await (await page.request.get("/")).text(); + expect(html).toMatch(/
/); + expect(html).not.toMatch(/maplibre/); + const preloads = [...html.matchAll(/rel="preload" href="([^"]+)"/g)].map((m) => m[1]); + expect(preloads).toEqual(["/reference/cities.json", "/reference/subdivisions.json"]); + + const entry = html.match(/