Theseus 0.3.61: Privacy tidied — country dropdown, VPN row honest, language names in the picker

Privacy › Location is three modes now: Show real, Hide, Manual. Manual reveals a
50-country dropdown whose pick becomes the coordinates navigator.geolocation
returns to pages — country-capital granularity, no regions or free-form cities.
Old profiles on the retired "Spoof (region)" auto-migrate to Manual + the
region's representative country on first open, so nothing breaks.

VPN row in Privacy stops opening the wrong add-on: the sidebar now no-ops on a
specific panelId that isn't registered (used to silently substitute panels[0],
which surfaced Aegis whenever the VPN add-on was disabled), and the row hides
itself when vpn:main isn't in the sidebar panel list.

Language picker (globe chip menu + Settings › General › Website language) drops
the BCP-47 tag from every visible label — the tag surfaces only as the 2-letter
chip in the URL bar once picked. "English" is the UK original; the US variant
row is retired (same 2-letter chip, ~same text). Ukrainian dropped from the
quick list too. "Automatic" reads as the OS language name (Intl.DisplayNames)
instead of a raw en-US style tag.
This commit is contained in:
Silent Mode 2026-10-01 00:48:19 +02:00
parent df2b1f57ff
commit a3fb8917c3
4 changed files with 177 additions and 128 deletions

View file

@ -723,20 +723,24 @@
const langBtn = $("langBtn"), langCode = $("langCode");
let systemLocaleCached = "en-US";
function short(tag) { return String(tag || "").split("-")[0].toUpperCase().slice(0, 2) || "??"; }
function langName(tag) {
try { return new Intl.DisplayNames(["en"], { type: "language" }).of(tag) || tag; }
catch { return tag; }
}
function renderLang(s) {
if (!s) return;
const auto = (s.languageMode || "show") === "show";
if (auto) {
langBtn.classList.remove("on");
langCode.textContent = "AUTO";
langBtn.title = `Website language: automatic (OS: ${systemLocaleCached}). Click to change.`;
langBtn.title = `Website language: automatic — ${langName(systemLocaleCached)}. Click to change.`;
} else {
const tag = (s.languageMode === "manual" && s.languageValue) ? s.languageValue
: (s.languageMode === "spoof" && s.languageSpoof) ? s.languageSpoof
: (s.languageMode === "hide") ? "en-US" : systemLocaleCached;
langBtn.classList.add("on");
langCode.textContent = short(tag);
langBtn.title = `Websites see you in ${tag}. Click to change.`;
langBtn.title = `Websites see you in ${langName(tag)}. Click to change.`;
}
}
langBtn.onclick = () => {

34
main.js
View file

@ -463,8 +463,8 @@ const SETTINGS_DEFAULTS = {
clearStorageOnQuit: true, // drop localStorage / IndexedDB / service workers / cache API
// Anti-fingerprinting — each: show (real) | hide (neutral) | spoof (auto decoy) | manual (user value)
timezoneMode: "show", timezoneValue: "Europe/Berlin", // IANA zone for manual
languageMode: "show", languageSpoof: "en-US", languageValue: "en-US", // spoof = top-10 pick, manual = free text
locationMode: "hide", locationRegion: "europe", locationLat: "40.7128", locationLon: "-74.0060", // spoof by region, or manual coords
languageMode: "show", languageSpoof: "en-US", languageValue: "en-GB", // spoof = top-10 pick, manual = free text (English = UK original; US variant retired from the picker)
locationMode: "hide", locationRegion: "europe", locationCountry: "DE", locationLat: "40.7128", locationLon: "-74.0060", // manual = pick a country (locationCountry drives lat/lon); legacy spoof/region still handled by effLocation() for old profiles
searchEngine: "startpage",// default search engine (built-in id or a custom id)
installedEngines: DEFAULT_ENABLED.slice(), // built-in engines added to the user's list (visible in Settings)
enabledEngines: DEFAULT_ENABLED.slice(), // subset that's currently toggled on (shown in the toolbar dropdown)
@ -2853,7 +2853,15 @@ function setSidebar(show, panelId) {
}
if (show) {
const wantId = panelId || sidebarActivePanelId || panels[0].panelId;
const panel = panels.find((p) => p.panelId === wantId) || panels[0];
// A CALLER-supplied panelId that isn't registered used to silently fall
// back to panels[0], which surfaced the wrong add-on (Settings › Privacy
// › VPN opening Aegis whenever the VPN add-on was disabled). Fall back
// only when the id came from restore state; a specific request is a no-op.
let panel = panels.find((p) => p.panelId === wantId);
if (!panel) {
if (panelId) { console.warn(`setSidebar: no panel "${panelId}"; ignoring`); return; }
panel = panels[0];
}
if (sidebarActivePanelId !== panel.panelId) {
sidebarActivePanelId = panel.panelId;
try { sidebar.webContents.loadFile(panel.pageFile); } catch (e) { console.warn("sidebar loadFile failed:", e?.message); }
@ -4050,9 +4058,12 @@ ipcMain.handle("tab-context-menu-popup", (e, tabId, rect) => {
// (languageMode="show", follow OS) or a specific BCP-47 tag
// (languageMode="manual", languageValue=<tag>). One truth source, two entry
// points. Applied via applyAcceptLanguage() + applyFingerprintAll() below.
// Labels are the language's own name in its own script — no BCP-47 tag in
// the label. The tag only surfaces as the 2-letter code on the URL-bar chip
// once picked. English is the UK original (en-GB); the US variant is 90%+
// the same text and shared the "EN" chip code, so it isn't a separate row.
const WEBSITE_LANGUAGE_QUICK = [
{ tag: "en-US", label: "English (US)" },
{ tag: "en-GB", label: "English (UK)" },
{ tag: "en-GB", label: "English" },
{ tag: "es-ES", label: "Español" },
{ tag: "pt-BR", label: "Português (Brasil)" },
{ tag: "fr-FR", label: "Français" },
@ -4060,7 +4071,6 @@ const WEBSITE_LANGUAGE_QUICK = [
{ tag: "it-IT", label: "Italiano" },
{ tag: "nl-NL", label: "Nederlands" },
{ tag: "ru-RU", label: "Русский" },
{ tag: "uk-UA", label: "Українська" },
{ tag: "pl-PL", label: "Polski" },
{ tag: "tr-TR", label: "Türkçe" },
{ tag: "ar", label: "العربية" },
@ -4087,6 +4097,12 @@ function setWebsiteLanguage(mode, value) {
applyAcceptLanguage();
try { chrome?.webContents.send("settings-update", settings); } catch {}
}
// Human-readable name for a BCP-47 tag ("de" → "German") via V8 Intl.
// Falls back to the tag itself if the locale isn't recognised.
function languageNameFor(tag) {
try { return new Intl.DisplayNames(["en"], { type: "language" }).of(tag) || tag; }
catch { return tag; }
}
ipcMain.handle("system-locale", () => app.getLocale() || "en-US");
ipcMain.handle("website-language-menu-popup", (e, rect) => {
if (chrome && e.sender !== chrome.webContents) throw new Error("website-language-menu-popup: untrusted sender");
@ -4094,11 +4110,11 @@ ipcMain.handle("website-language-menu-popup", (e, rect) => {
const isAuto = settings.languageMode === "show";
const current = isAuto ? null : (settings.languageValue || "").toLowerCase();
const template = [
{ label: `Automatic (OS: ${osLoc})${isAuto ? " ✓" : ""}`,
{ label: `Automatic (${languageNameFor(osLoc)})${isAuto ? " ✓" : ""}`,
click: () => setWebsiteLanguage("show") },
{ type: "separator" },
...WEBSITE_LANGUAGE_QUICK.map((L) => ({
label: `${L.label} — ${L.tag}${current === L.tag.toLowerCase() ? " ✓" : ""}`,
label: `${L.label}${current === L.tag.toLowerCase() ? " ✓" : ""}`,
click: () => setWebsiteLanguage("manual", L.tag),
})),
{ type: "separator" },
@ -6099,7 +6115,7 @@ ipcMain.handle("settings-set", (_e, key, val) => {
if (key === "theme") applyTheme();
if (key === "backgroundThrottle") applyThrottle();
if (["timezoneMode", "timezoneValue", "languageMode", "languageSpoof", "languageValue",
"locationMode", "locationRegion", "locationLat", "locationLon", "hideMediaDevices"].includes(key)) { applyFingerprintAll(); applyAcceptLanguage(); }
"locationMode", "locationRegion", "locationCountry", "locationLat", "locationLon", "hideMediaDevices"].includes(key)) { applyFingerprintAll(); applyAcceptLanguage(); }
// Push updates chrome-side reactively so toolbar-size changes (urlBarSize,
// searchBoxSize) take effect without a relaunch. Whole settings object
// fits in one message and chrome only cares about a handful of fields.

View file

@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
"version": "0.3.60",
"version": "0.3.61",
"license": "MPL-2.0",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",

View file

@ -684,57 +684,12 @@
</div>
</div>
<div class="row">
<div class="txt"><div class="t">Location</div><div class="d"><b>Hide</b> denies geolocation; <b>Spoof</b> reports a chosen region; <b>Manual</b> reports exact coordinates.</div></div>
<div class="txt"><div class="t">Location</div><div class="d"><b>Hide</b> denies geolocation entirely; <b>Manual</b> reports the coordinates of a country you pick, so a page's <code>navigator.geolocation</code> answers with a plausible position instead of your real one.</div></div>
<div class="ctl">
<select id="locationMode"><option value="show">Show real</option><option value="hide">Hide (block)</option><option value="spoof">Spoof (region)</option><option value="manual">Manual…</option></select>
<select id="locationRegion" hidden>
<option value="europe">Europe</option>
<option value="asia">Asia</option>
<option value="north_america">North America</option>
<option value="south_america">South America</option>
<option value="africa">Africa</option>
<option value="middle_east">Middle East</option>
<option value="australia">Australia</option>
<select id="locationMode"><option value="show">Show real</option><option value="hide">Hide (block)</option><option value="manual">Manual…</option></select>
<select id="locationCountry" hidden>
<!-- Populated in JS from the COUNTRIES table (kept in one place). -->
</select>
<!-- Manual location = pick a city from a native select; its lat/lon
are looked up client-side (see CITIES below) and written to
locationLat / locationLon settings, which drive the
navigator.geolocation override in main.js. The raw lat/lon
inputs used to sit here but nobody types coordinates by hand;
the city picker gives the same override with a single click. -->
<select id="locationCity" hidden>
<option value="">Pick a city…</option>
<option value="london">London</option>
<option value="berlin">Berlin</option>
<option value="paris">Paris</option>
<option value="madrid">Madrid</option>
<option value="rome">Rome</option>
<option value="moscow">Moscow</option>
<option value="istanbul">Istanbul</option>
<option value="dubai">Dubai</option>
<option value="mumbai">Mumbai</option>
<option value="singapore">Singapore</option>
<option value="bangkok">Bangkok</option>
<option value="shanghai">Shanghai</option>
<option value="tokyo">Tokyo</option>
<option value="seoul">Seoul</option>
<option value="sydney">Sydney</option>
<option value="new_york">New York</option>
<option value="los_angeles">Los Angeles</option>
<option value="chicago">Chicago</option>
<option value="toronto">Toronto</option>
<option value="mexico_city">Mexico City</option>
<option value="sao_paulo">São Paulo</option>
<option value="buenos_aires">Buenos Aires</option>
<option value="cairo">Cairo</option>
<option value="nairobi">Nairobi</option>
<option value="johannesburg">Johannesburg</option>
<option value="__other__">Other…</option>
</select>
<div id="locationOther" class="coords" hidden>
<input id="locationLatOther" type="number" step="0.0001" placeholder="lat" style="width:100px">
<input id="locationLonOther" type="number" step="0.0001" placeholder="lon" style="width:100px">
</div>
</div>
</div>
<div class="note">These reduce tracking and hide your IP, but a custom browser can still be fingerprinted. For maximum anonymity, use the Tor Browser.</div>
@ -935,7 +890,19 @@
}
const refreshAll = () => refresh().then(refreshExtra, refreshExtra);
el("torOn").addEventListener("change", () => { if (C.toggleTor) C.toggleTor().catch(() => {}); setTimeout(refreshExtra, 1200); setTimeout(refreshExtra, 4000); });
el("vpnRow").addEventListener("click", () => C.openPanel && C.openPanel("vpn:main"));
// VPN row: only offer it when the VPN add-on is installed AND its panel
// registered. Otherwise clicking it used to open whichever add-on was
// first in the sidebar list (usually Aegis). Hide it in that case.
(async () => {
const vpnRow = el("vpnRow");
try {
const list = await (C.listAddons ? C.listAddons() : Promise.resolve({}));
const panels = (list && list.sidebarPanels) || [];
const vpn = panels.find((p) => p.panelId === "vpn:main");
if (!vpn) { vpnRow.hidden = true; return; }
vpnRow.addEventListener("click", () => C.openPanel && C.openPanel("vpn:main"));
} catch { vpnRow.hidden = true; }
})();
document.addEventListener("section", (e) => { if (String(e.detail).startsWith("privacy")) refreshAll(); });
refreshAll();
})();
@ -973,13 +940,18 @@
// same languageMode/languageValue setting the Anti-fingerprinting Language
// row edits. "Auto" = languageMode="show" (follow OS); anything else
// switches to languageMode="manual" and pins languageValue.
// Same list the toolbar globe menu uses (kept in sync with
// WEBSITE_LANGUAGE_QUICK in main.js). Language name in its own script;
// the BCP-47 tag only surfaces as the 2-letter chip in the URL bar once
// picked, never in the picker. English is the UK original (en-GB) —
// the US variant is 90%+ the same text and shared "EN" anyway.
const WEB_LANG_QUICK = [
{ tag: "en-US", label: "English (US)" }, { tag: "en-GB", label: "English (UK)" },
{ tag: "en-GB", label: "English" },
{ tag: "es-ES", label: "Español (España)" }, { tag: "es-MX", label: "Español (México)" },
{ tag: "pt-BR", label: "Português (Brasil)" }, { tag: "pt-PT", label: "Português (Portugal)" },
{ tag: "fr-FR", label: "Français" }, { tag: "de-DE", label: "Deutsch" },
{ tag: "it-IT", label: "Italiano" }, { tag: "nl-NL", label: "Nederlands" },
{ tag: "ru-RU", label: "Русский" }, { tag: "uk-UA", label: "Українська" },
{ tag: "ru-RU", label: "Русский" },
{ tag: "pl-PL", label: "Polski" }, { tag: "cs-CZ", label: "Čeština" },
{ tag: "sv-SE", label: "Svenska" }, { tag: "fi-FI", label: "Suomi" },
{ tag: "tr-TR", label: "Türkçe" }, { tag: "el-GR", label: "Ελληνικά" },
@ -989,6 +961,13 @@
{ tag: "ko-KR", label: "한국어" }, { tag: "vi-VN", label: "Tiếng Việt" },
{ tag: "th-TH", label: "ไทย" }, { tag: "id-ID", label: "Bahasa Indonesia" },
];
// "English" for a locale tag ("en-US" → "English", "de-DE" → "German") via
// browser Intl. Used for the "Automatic (…)" label so it reads as a
// language name rather than a raw tag.
const langNameFor = (tag) => {
try { return new Intl.DisplayNames(["en"], { type: "language" }).of(tag) || tag; }
catch { return tag; }
};
(async () => {
const webLangPick = document.getElementById("webLangPick");
const webLangOther = document.getElementById("webLangOther");
@ -997,8 +976,10 @@
let osLoc = "en-US";
try { osLoc = (C.systemLocale && await C.systemLocale()) || "en-US"; } catch {}
// Build options: "Automatic" first, then the quick list, then "Other…".
const opts = [`<option value="__auto__">Automatic (OS: ${osLoc})</option>`,
...WEB_LANG_QUICK.map((L) => `<option value="${L.tag}">${L.label} — ${L.tag}</option>`),
// No BCP-47 tag in visible labels — the tag surfaces only as the 2-letter
// chip in the URL bar.
const opts = [`<option value="__auto__">Automatic (${langNameFor(osLoc)})</option>`,
...WEB_LANG_QUICK.map((L) => `<option value="${L.tag}">${L.label}</option>`),
`<option value="__other__">Other…</option>`];
webLangPick.innerHTML = opts.join("");
const paint = (cur) => {
@ -1006,15 +987,15 @@
if (mode !== "manual") {
webLangPick.value = "__auto__"; webLangOther.hidden = true;
hint.innerHTML = mode === "show"
? "Following your operating system. Also switchable from the globe icon in the address bar."
? `Following your operating system (${langNameFor(osLoc)}). Also switchable from the globe icon in the address bar.`
: `Anti-fingerprinting is currently overriding this (mode: <b>${mode}</b>). Change it under <b>Privacy → Anti-fingerprinting</b>.`;
return;
}
const tag = cur.languageValue || "en-US";
const tag = cur.languageValue || "en-GB";
const known = new Set(WEB_LANG_QUICK.map((L) => L.tag));
if (known.has(tag)) { webLangPick.value = tag; webLangOther.hidden = true; }
else { webLangPick.value = "__other__"; webLangOther.hidden = false; webLangOther.value = tag; }
hint.textContent = `Websites see you in ${tag}. Also switchable from the globe icon in the address bar.`;
hint.textContent = `Websites see you in ${langNameFor(tag)}. Also switchable from the globe icon in the address bar.`;
};
paint(s);
webLangPick.addEventListener("change", () => {
@ -1310,68 +1291,116 @@
lngMode.value = s.languageMode || "show";
applyLng();
lngMode.addEventListener("change", () => { C.set("languageMode", lngMode.value); applyLng(); });
// location: region dropdown when spoofing, city dropdown when manual.
// Manual writes lat/lon into settings via the CITIES map below; the raw
// coord inputs were dropped from the UI (nobody types them by hand).
const locMode = document.getElementById("locationMode");
const locRegion = document.getElementById("locationRegion");
const locCity = document.getElementById("locationCity");
locRegion.value = s.locationRegion || "europe";
locRegion.addEventListener("change", () => C.set("locationRegion", locRegion.value));
// Location manual reveals the city select; picking "Other…" reveals a
// lat/lon pair, whose values are pushed to locationLat/Lon settings.
const locOther = document.getElementById("locationOther");
const locLatOther = document.getElementById("locationLatOther");
const locLonOther = document.getElementById("locationLonOther");
const applyLoc = () => {
locRegion.hidden = locMode.value !== "spoof";
locCity.hidden = locMode.value !== "manual";
locOther.hidden = locMode.value !== "manual" || locCity.value !== "__other__";
// Location: three modes — Show real / Hide / Manual (pick a country).
// Manual writes the country's representative lat/lon into locationLat +
// locationLon, which is what navigator.geolocation returns to pages via
// the override in main.js. Legacy `spoof` (region) and free-form city
// picks are gone from the UI; effLocation() in main still handles a
// saved `spoof` mode so old profiles keep working until they change it.
// COUNTRIES: ISO-3166 alpha-2 → [ label, lat, lon ]. Coord is the
// country's capital (or biggest city) so pages that geocode the position
// land in the right country. Alphabetised for the dropdown.
const COUNTRIES = {
AR: ["Argentina", -34.6037, -58.3816],
AT: ["Austria", 48.2082, 16.3738],
AU: ["Australia", -33.8688, 151.2093],
BE: ["Belgium", 50.8503, 4.3517],
BR: ["Brazil", -23.5505, -46.6333],
CA: ["Canada", 43.6532, -79.3832],
CH: ["Switzerland", 47.3769, 8.5417],
CL: ["Chile", -33.4489, -70.6693],
CN: ["China", 31.2304, 121.4737],
CO: ["Colombia", 4.7110, -74.0721],
CZ: ["Czechia", 50.0755, 14.4378],
DE: ["Germany", 52.5200, 13.4050],
DK: ["Denmark", 55.6761, 12.5683],
EG: ["Egypt", 30.0444, 31.2357],
ES: ["Spain", 40.4168, -3.7038],
FI: ["Finland", 60.1699, 24.9384],
FR: ["France", 48.8566, 2.3522],
GB: ["United Kingdom", 51.5074, -0.1278],
GR: ["Greece", 37.9838, 23.7275],
HK: ["Hong Kong", 22.3193, 114.1694],
ID: ["Indonesia", -6.2088, 106.8456],
IE: ["Ireland", 53.3498, -6.2603],
IL: ["Israel", 32.0853, 34.7818],
IN: ["India", 19.0760, 72.8777],
IT: ["Italy", 41.9028, 12.4964],
JP: ["Japan", 35.6762, 139.6503],
KE: ["Kenya", -1.2921, 36.8219],
KR: ["South Korea", 37.5665, 126.9780],
MA: ["Morocco", 33.5731, -7.5898],
MX: ["Mexico", 19.4326, -99.1332],
MY: ["Malaysia", 3.1390, 101.6869],
NG: ["Nigeria", 6.5244, 3.3792],
NL: ["Netherlands", 52.3676, 4.9041],
NO: ["Norway", 59.9139, 10.7522],
NZ: ["New Zealand", -36.8485, 174.7633],
PE: ["Peru", -12.0464, -77.0428],
PH: ["Philippines", 14.5995, 120.9842],
PL: ["Poland", 52.2297, 21.0122],
PT: ["Portugal", 38.7223, -9.1393],
RU: ["Russia", 55.7558, 37.6173],
SA: ["Saudi Arabia", 24.7136, 46.6753],
SE: ["Sweden", 59.3293, 18.0686],
SG: ["Singapore", 1.3521, 103.8198],
TH: ["Thailand", 13.7563, 100.5018],
TR: ["Turkey", 41.0082, 28.9784],
TW: ["Taiwan", 25.0330, 121.5654],
UA: ["Ukraine", 50.4501, 30.5234],
US: ["United States", 40.7128, -74.0060],
VN: ["Vietnam", 10.8231, 106.6297],
ZA: ["South Africa", -26.2041, 28.0473],
};
locMode.value = s.locationMode || "show";
// Legacy region → representative country (Spoof mode is retired but
// migrating old profiles keeps them roughly where they were).
const REGION_TO_COUNTRY = {
europe: "DE", asia: "JP", north_america: "US", south_america: "BR",
africa: "KE", middle_east: "AE", australia: "AU",
};
const locMode = document.getElementById("locationMode");
const locCountry = document.getElementById("locationCountry");
// Build the country dropdown from the map above.
locCountry.innerHTML = Object.entries(COUNTRIES)
.sort((a, b) => a[1][0].localeCompare(b[1][0]))
.map(([code, [name]]) => `<option value="${code}">${name}</option>`)
.join("");
// Restore prior selection: prefer an explicit locationCountry; otherwise
// recover the country whose coords match the saved lat/lon; else Germany.
const initialCountry = (() => {
if (s.locationCountry && COUNTRIES[s.locationCountry]) return s.locationCountry;
const la = Number(s.locationLat), lo = Number(s.locationLon);
for (const [code, [, x, y]] of Object.entries(COUNTRIES)) {
if (Math.abs(x - la) < 0.05 && Math.abs(y - lo) < 0.05) return code;
}
return "DE";
})();
locCountry.value = initialCountry;
// Migrate legacy spoof/region on this render pass so the mode dropdown
// has a valid value and the settings file is normalised on next save.
const initialMode = (() => {
if (s.locationMode === "spoof") {
const code = REGION_TO_COUNTRY[s.locationRegion] || "DE";
const [, la, lo] = COUNTRIES[code];
C.set("locationCountry", code);
C.set("locationLat", String(la));
C.set("locationLon", String(lo));
C.set("locationMode", "manual");
locCountry.value = code;
return "manual";
}
return s.locationMode || "show";
})();
const applyLoc = () => { locCountry.hidden = locMode.value !== "manual"; };
locMode.value = initialMode;
applyLoc();
locMode.addEventListener("change", () => { C.set("locationMode", locMode.value); applyLoc(); });
locCity.addEventListener("change", applyLoc);
// Prefill Other lat/lon inputs from saved settings.
locLatOther.value = s.locationLat ?? "";
locLonOther.value = s.locationLon ?? "";
const saveOtherCoords = () => {
const la = String(locLatOther.value).trim();
const lo = String(locLonOther.value).trim();
if (la !== "") C.set("locationLat", la);
if (lo !== "") C.set("locationLon", lo);
C.set("locationCity", "__other__");
};
locLatOther.addEventListener("change", saveOtherCoords);
locLonOther.addEventListener("change", saveOtherCoords);
// If the previously-saved city was "__other__", set the select to it so the
// Other inputs stay visible on reload.
if (s.locationCity === "__other__") locCity.value = "__other__";
// Picking a city writes its lat/lon to settings — that's what
// navigator.geolocation returns to pages once "manual" mode is active.
const CITIES = {
london:[51.5074,-0.1278], berlin:[52.52,13.405], paris:[48.8566,2.3522],
madrid:[40.4168,-3.7038], rome:[41.9028,12.4964], moscow:[55.7558,37.6173],
istanbul:[41.0082,28.9784], dubai:[25.2048,55.2708], mumbai:[19.076,72.8777],
singapore:[1.3521,103.8198], bangkok:[13.7563,100.5018], shanghai:[31.2304,121.4737],
tokyo:[35.6762,139.6503], seoul:[37.5665,126.978], sydney:[-33.8688,151.2093],
new_york:[40.7128,-74.006], los_angeles:[34.0522,-118.2437], chicago:[41.8781,-87.6298],
toronto:[43.6532,-79.3832], mexico_city:[19.4326,-99.1332], sao_paulo:[-23.5505,-46.6333],
buenos_aires:[-34.6037,-58.3816], cairo:[30.0444,31.2357], nairobi:[-1.2921,36.8219],
johannesburg:[-26.2041,28.0473],
};
// Restore prior city selection when possible by matching stored lat/lon.
const restoreCity = () => {
const la = Number(s.locationLat), lo = Number(s.locationLon);
for (const [key, [x, y]] of Object.entries(CITIES))
if (Math.abs(x - la) < 0.01 && Math.abs(y - lo) < 0.01) { locCity.value = key; return; }
};
restoreCity();
locCity.addEventListener("change", () => {
const c = CITIES[locCity.value]; if (!c) return;
C.set("locationLat", String(c[0]));
C.set("locationLon", String(c[1]));
C.set("locationCity", locCity.value); // save the pick so we can restore it later
locCountry.addEventListener("change", () => {
const code = locCountry.value;
const c = COUNTRIES[code]; if (!c) return;
C.set("locationCountry", code);
C.set("locationLat", String(c[1]));
C.set("locationLon", String(c[2]));
});
// Storage: "Clear all now" wipes everything the toggles cover, without