Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f0bed8c
Flags: render every country flag on platforms without flag glyphs
davd-gzl Sep 30, 2026
b62fdf6
Flags: lead search results and the map's place card with the flag
davd-gzl Sep 30, 2026
9653bbc
Map: keep every visited country's flag legible at world zoom
davd-gzl Sep 30, 2026
8a91be8
Top bar: fit the phone search field and draw control icons
davd-gzl Sep 30, 2026
2654a38
Navigation: keep the screen in the address, across reloads and Forward
davd-gzl Sep 30, 2026
48bef26
Backup: show the backup reminder atop Places, not only deep in Settings
davd-gzl Sep 30, 2026
2519d03
Flags: draw the bundled flags at the size of colour-emoji flags
davd-gzl Sep 30, 2026
6b0501b
Startup: paint the shell at once and load reference data by priority
davd-gzl Sep 30, 2026
c6315da
Navigation: leave the address bare on the home screen
davd-gzl Sep 30, 2026
80603e1
Merge branch 'audit-fixes' into ux-flags-map
davd-gzl Sep 30, 2026
bdd55d9
Merge branch 'audit-fixes' into ux-flags-map
davd-gzl Sep 30, 2026
7667163
Tests: give the fake map in the map-load spec its image list
davd-gzl Sep 30, 2026
892ffc8
Map: draw your logged stations once the station data lands
davd-gzl Sep 30, 2026
8267910
Map: fill the in-view list when the late reference data lands
davd-gzl Sep 30, 2026
86b478e
Map: jump to a picked place's list row under reduced motion
davd-gzl Sep 30, 2026
fab7012
Startup: show the boot guard's reload message in view
davd-gzl Sep 30, 2026
6f1e140
Startup: name the railway stations among the data loaded after first …
davd-gzl Sep 30, 2026
bea6f9c
Startup: ask for the railway stations once the first render has settled
davd-gzl Sep 30, 2026
6054e41
Startup: fetch the railway stations once the map has loaded
davd-gzl Sep 30, 2026
aeaae09
Merge origin/main into ux-flags-map
davd-gzl Sep 30, 2026
065e6de
Merge origin/main into ux-flags-map
davd-gzl Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions apps/postcards/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,19 @@
<meta name="referrer" content="strict-origin-when-cross-origin" />
<link rel="icon" type="image/png" href="/icons/icon-192.png" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<!-- Startup fetches otherwise wait for the whole bundle to download and
evaluate: preload the render-blocking reference JSONs (and the basemap
geometry the map needs right after) so the network runs in parallel.
%BASE_URL% matches the import.meta.env.BASE_URL the code fetches with;
`crossorigin` (anonymous) matches fetch()'s default credentials mode so
the preloaded response is consumed, not downloaded twice. On repeat
visits these resolve from the service-worker cache and cost nothing. -->
<!-- The first render waits for the city gazetteer and its regions only
(initReferenceData): preload those two so they download alongside the
bundle instead of after it. Nothing else is preloaded — airports,
heritage sites, railway stations and the basemap geometry are fetched
right after the first render, and preloading them made them compete with it (on a
throttled phone, dropping them brought the app up a second sooner and
the map too). %BASE_URL% matches the import.meta.env.BASE_URL the code
fetches with; `crossorigin` (anonymous) matches fetch()'s default
credentials mode so the preloaded response is consumed, not
downloaded twice. On repeat visits these resolve from the
service-worker cache and cost nothing. -->
<link rel="preload" href="%BASE_URL%reference/cities.json" as="fetch" crossorigin />
<link rel="preload" href="%BASE_URL%reference/airports.json" as="fetch" crossorigin />
<link rel="preload" href="%BASE_URL%reference/subdivisions.json" as="fetch" crossorigin />
<link rel="preload" href="%BASE_URL%reference/heritage.json" as="fetch" crossorigin />
<link rel="preload" href="%BASE_URL%basemap/countries-50m.json" as="fetch" crossorigin />
<meta name="theme-color" content="#4338ca" />
<meta name="description" content="Remember the places you've been — private, offline, in a file you own." />
<title>Postcards</title>
Expand All @@ -83,7 +84,10 @@
</script>
</head>
<body>
<div id="root"></div>
<!-- The app shell, painted as soon as the stylesheet lands: the bundle and
the reference data take seconds on a phone, and the page stayed blank
until they had. React replaces it on its first render. -->
<div id="root"><div class="app"><header class="topbar"><h1 class="brand-wrap"><span class="brand">Postcards</span></h1></header></div></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
9 changes: 6 additions & 3 deletions apps/postcards/public/boot-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,12 @@
});
shown.appendChild(p);
shown.appendChild(b);
// Into the app's own container, empty while the app has not run; the page
// locks body scrolling and gives #root the full height.
(document.getElementById("root") || document.body).appendChild(shown);
// Into the app's own container, in place of the static shell index.html
// paints there (a full-height app frame that left the message below the
// fold); the page locks body scrolling and gives #root the full height.
var root = document.getElementById("root");
if (root) root.replaceChildren(shown);
else document.body.appendChild(shown);
}

// The entry script, or a module it imports (modulepreloaded), that fails to
Expand Down
14 changes: 14 additions & 0 deletions apps/postcards/public/fonts/PROVENANCE.md
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 54 additions & 0 deletions apps/postcards/public/fonts/TwemojiCountryFlags-LICENSE.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file not shown.
133 changes: 101 additions & 32 deletions apps/postcards/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,23 @@ import { LoadBoundary } from "../ui/LoadFailure";
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, type PointerEvent as ReactPointerEvent } from "react";
import { useInstallPrompt } from "../lib/hooks/useInstallPrompt";
import { useAutoSync } from "../lib/hooks/useAutoSync";
import { useT, type MessageKey } from "../lib/i18n";
import { HOME, parseRoute, routeHash, type Route } from "./route";
import { useReferenceExtrasPending } from "../lib/reference/useGazetteer";
import { requestStations } from "../lib/reference/referenceData";

// Code-split MapLibre so it loads only when the map is shown.
const MapScreen = lazy(() =>
Expand All @@ -50,6 +62,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 composers stay 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.
Expand Down Expand Up @@ -100,7 +122,21 @@ export function App() {
}
};
const mapVisible = tab === "map" && !cityPageId && !countryPageId && !tripEditId && !storyEditId;
// Airports, heritage sites and stations land a moment after the first paint.
// The map, Places, Stats and country pages refresh when they do; trips
// (distances), the journal and its composer (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 || !!storyEditId || tab === "trips" || tab === "journal");
const firstRender = useRef(true);
// The railway stations wait for the map to load (see loadExtras); every other
// screen may read them, and has no map to compete with, so it asks at once.
useEffect(() => {
if (!mapVisible) requestStations();
}, [mapVisible]);

// Scroll memory. <main> is the single scroll container reused across tabs and
// detail pages, so its scrollTop leaks between views: open a city while scrolled
Expand Down Expand Up @@ -232,45 +268,74 @@ 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()));
const onScreen = () => routeHash(parseRoute(location.hash) ?? HOME) === hashNow();
// The screen came from the address, so it is left as it is (its query and
// a fragment naming no screen included).
history.replaceState({ ...state, pc: at }, "");
// 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 (onScreen() && at > 0) return;
at += 1;
const url = onScreen() ? undefined : hashNow() || location.pathname + location.search;
history.pushState({ pc: at }, "", url);
};
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 || ui.storyEditId) {
} 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, storyEditId: null });
} else if (ui.cityPageId || ui.countryPageId || ui.tripEditId || ui.storyEditId) {
// 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);
Expand Down Expand Up @@ -324,7 +389,7 @@ export function App() {
aria-label={t("topbar.githubStar")}
>
<span className="star-glyph" aria-hidden>
⭐
<StarIcon />
</span>
<span>{t("topbar.github")}</span>
</a>
Expand Down Expand Up @@ -423,7 +488,11 @@ export function App() {
</LoadBoundary>
</div>
)}
{cityPageId ? (
{waitForExtras ? (
<p className="muted empty" role="status">
{t("app.loading")}
</p>
) : cityPageId ? (
<CityScreen cityId={cityPageId} onBack={() => useUi.getState().closeCity()} />
) : countryPageId ? (
<CountryScreen iso2={countryPageId} onBack={() => useUi.getState().closeCity()} />
Expand Down
46 changes: 46 additions & 0 deletions apps/postcards/src/app/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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 home screen (the map, no page) is the bare address,
// and so is any fragment that names no screen. The trip and postcard
// composers are unsaved forms, so they stay out.

export interface Route {
tab: Tab;
cityPageId: string | null;
countryPageId: string | null;
}

const TABS: readonly Tab[] = ["map", "places", "trips", "journal", "stats", "settings"];

export const HOME: Route = { tab: "map", cityPageId: null, countryPageId: null };

/** The hash naming a screen; "" for home. */
export function routeHash(r: Route): string {
if (r.tab === "map" && !r.cityPageId && !r.countryPageId) return "";
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;
}
Loading
Loading