From 36c89a70100c995487505ae039a8adbf1571ff88 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 6 Sep 2026 01:05:29 -0400 Subject: [PATCH 1/3] Add nearby community search and bilingual network overview --- README.md | 8 + data/community-search-anchors.json | 29 ++ docs/assets/canada-network-motif.svg | 9 + docs/assets/javascripts/communities.js | 312 +++++++++++++----- docs/assets/javascripts/network-status.js | 108 ++++++ docs/assets/regions/regions.css | 9 + docs/assets/regions/regions.js | 4 + docs/assets/styles/communities.css | 31 ++ docs/assets/styles/home.css | 20 ++ docs/assets/styles/network-status.css | 33 ++ docs/config/index.fr.md | 4 +- docs/config/index.md | 4 +- docs/config/map.fr.md | 4 +- docs/config/map.md | 4 +- docs/hardware/index.fr.md | 8 - docs/hardware/index.md | 6 - docs/index.fr.md | 12 + docs/index.md | 12 + docs/privacy.fr.md | 5 + docs/privacy.md | 5 + docs/provinces/alberta.fr.md | 2 +- docs/provinces/alberta.md | 2 +- docs/provinces/british-columbia.fr.md | 2 +- docs/provinces/british-columbia.md | 2 +- docs/provinces/index.fr.md | 75 +++-- docs/provinces/index.md | 99 ++++-- docs/provinces/manitoba.fr.md | 2 +- docs/provinces/manitoba.md | 2 +- docs/provinces/new-brunswick.fr.md | 2 +- docs/provinces/new-brunswick.md | 2 +- .../provinces/newfoundland-and-labrador.fr.md | 2 +- docs/provinces/newfoundland-and-labrador.md | 2 +- docs/provinces/nova-scotia.fr.md | 2 +- docs/provinces/nova-scotia.md | 2 +- docs/provinces/ontario.fr.md | 2 +- docs/provinces/ontario.md | 2 +- docs/provinces/prince-edward-island.fr.md | 2 +- docs/provinces/prince-edward-island.md | 2 +- docs/provinces/quebec.fr.md | 2 +- docs/provinces/quebec.md | 2 +- docs/provinces/saskatchewan.fr.md | 2 +- docs/provinces/saskatchewan.md | 2 +- docs/provinces/territories.fr.md | 2 +- docs/provinces/territories.md | 2 +- maintenance/site-audit-2026-09-04.md | 20 ++ mkdocs.yml | 2 + overrides/partials/header.html | 88 +++++ overrides/partials/network-status.html | 26 ++ scripts/generate-home-hero.py | 61 ++++ scripts/validate-communities.py | 102 ++++-- tests/browser/accessibility.spec.mjs | 2 +- tests/browser/all-pages-ui.spec.mjs | 2 +- tests/browser/audit-regressions.spec.mjs | 10 +- tests/browser/critical-journeys.spec.mjs | 2 +- tests/browser/repeater-hash-check.spec.mjs | 7 +- tests/browser/site-feedback.spec.mjs | 120 +++++++ tests/browser/site-fixtures.mjs | 15 + tests/content/browser-feedback.test.mjs | 74 +++++ tests/content/community-directory.test.mjs | 2 +- 59 files changed, 1143 insertions(+), 235 deletions(-) create mode 100644 data/community-search-anchors.json create mode 100644 docs/assets/canada-network-motif.svg create mode 100644 docs/assets/javascripts/network-status.js create mode 100644 docs/assets/styles/home.css create mode 100644 docs/assets/styles/network-status.css create mode 100644 overrides/partials/header.html create mode 100644 overrides/partials/network-status.html create mode 100644 scripts/generate-home-hero.py create mode 100644 tests/browser/site-feedback.spec.mjs create mode 100644 tests/browser/site-fixtures.mjs create mode 100644 tests/content/browser-feedback.test.mjs diff --git a/README.md b/README.md index e86b2355..b48d01a9 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,20 @@ Open `http://127.0.0.1:4173/`. Rebuild after editing. Build output belongs in - Community listings: `data/communities.json` and `data/communities.fr.json`. Run `python scripts/validate-communities.py --write` after editing the source. It generates the directory pages and `docs/assets/radio-profiles.json`. + Keep `data/community-search-anchors.json` in sync when adding listings. These + are approximate search references, not claimed radio locations or coverage. - Region tools: `docs/assets/regions/` and `docs/config/editor/`. Follow the boundary proposal workflow; do not hand-edit generated geography. - Broker settings: `docs/analyzer/observer-config.json`. The build generates the broker reference table from this file, including its no-JavaScript version. - Anonymous submissions: `tools/region-proposal-gateway/`. The site and gateway deploy separately; check the gateway README before changing their contract. +- Header totals: `docs/assets/javascripts/network-status.js` reads public Beacon + aggregates and caches them per tab for five minutes. City search uses Natural + Resources Canada's current Geolocator API, only when a search is submitted. +- Homepage art: run `python scripts/generate-home-hero.py` after installing the + region dependencies to regenerate the decorative SVG from existing geography. + Compare the custom header with Material's template when upgrading the theme. Never commit credentials, private keys, precise private locations, or test submissions containing personal information. Human maintainers review changes diff --git a/data/community-search-anchors.json b/data/community-search-anchors.json new file mode 100644 index 00000000..0bc6c429 --- /dev/null +++ b/data/community-search-anchors.json @@ -0,0 +1,29 @@ +{ + "description": "Approximate search reference points, not radio locations or coverage boundaries. Existing community coordinates take precedence. Regional references use the published canada-regions.json seed coordinates; broad communities may have several references.", + "communities": { + "bc-mesh": ["mvrd", "crd"], + "salish-mesh": ["mvrd", "crd"], + "alberta-meshcore-networks": ["cal", "edm", "leth"], + "airdrie-meshcore-network": [], + "calgary-area-meshcore": ["cal"], + "calgary-meshcore-network": [], + "edmonton-meshcore-network": [], + "yegmesh-ca": [], + "yqlmesh": [], + "southern-alberta": ["leth"], + "yyc-meshcore-discord": ["cal"], + "stoonmesh": ["stoon"], + "yqrmesh": ["regina"], + "greater-ottawa-mesh-enthusiasts": ["ott", "gatout"], + "gta-lora-meshes": ["tor"], + "quinte-mesh-network": ["has", "pec"], + "charlevoix-yml": [{"label": "La Malbaie", "lat": 47.652419, "lon": -70.14951, "source": "https://geolocator.api.geo.ca/?q=La%20Malbaie&lang=en&keys=geonames"}], + "mesh-quebec": ["mtl", "capnat", "saglac"], + "montreal-mesh": ["mtl"], + "reseau-mesh-capitale-yqb": ["capnat"], + "reseau-mesh-saguenay-lac-saint-jean-ytf": ["saglac"], + "reseau-libre": ["mtl"], + "southern-new-brunswick": ["york", "sj", "westm"], + "lunenburg-county-mesh": ["lun"] + } +} diff --git a/docs/assets/canada-network-motif.svg b/docs/assets/canada-network-motif.svg new file mode 100644 index 00000000..9afa390a --- /dev/null +++ b/docs/assets/canada-network-motif.svg @@ -0,0 +1,9 @@ + +Canada network motif +Decorative connections between regional reference points, not live links or radio coverage. Canada outline derived from Statistics Canada geography; see regions/NOTICE.txt. + + + + + + diff --git a/docs/assets/javascripts/communities.js b/docs/assets/javascripts/communities.js index 7aa1a8de..24a0a7f2 100644 --- a/docs/assets/javascripts/communities.js +++ b/docs/assets/javascripts/communities.js @@ -1,115 +1,265 @@ (function () { "use strict"; + var placeCache = new Map(); + var lastRequest = 0; + var provinces = { + AB: ["Alberta"], BC: ["British Columbia", "Colombie-Britannique"], + MB: ["Manitoba"], NB: ["New Brunswick", "Nouveau-Brunswick"], + NL: ["Newfoundland and Labrador", "Terre-Neuve-et-Labrador"], + NS: ["Nova Scotia", "Nouvelle-Écosse"], NT: ["Northwest Territories", "Territoires du Nord-Ouest"], + NU: ["Nunavut"], ON: ["Ontario"], PE: ["Prince Edward Island", "Île-du-Prince-Édouard"], + QC: ["Quebec", "Québec"], SK: ["Saskatchewan"], YT: ["Yukon"] + }; + + function decode(value) { + // GeoNames occasionally escapes accents inside an already-decoded JSON string. + return String(value || "").replace(/\\u([0-9a-f]{4})/gi, function (_, hex) { return String.fromCharCode(parseInt(hex, 16)); }); + } + function normalize(value) { - return String(value || "") - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .toLocaleLowerCase() - .replace(/\s+/g, " ") - .trim(); + return decode(value).normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLocaleLowerCase().replace(/\s+/g, " ").trim(); } - function initializeDirectory(root) { - if (!root || root.dataset.communityReady === "true") { - return; + function provinceCode(value) { + var name = normalize(value); + return Object.keys(provinces).find(function (code) { + return [code].concat(provinces[code]).some(function (alias) { return normalize(alias) === name; }); + }) || ""; + } + + function splitPlaceQuery(value) { + var query = value.trim(); + var normalized = normalize(query); + var aliases = Object.keys(provinces).flatMap(function (code) { + return [code].concat(provinces[code]).map(function (alias) { return { code: code, alias: normalize(alias) }; }); + }).sort(function (a, b) { return b.alias.length - a.alias.length; }); + for (var item of aliases) { + if (normalized.endsWith(" " + item.alias) || normalized.endsWith("," + item.alias)) { + return { name: query.slice(0, -item.alias.length).replace(/[,\s]+$/, ""), province: item.code }; + } } + return { name: query, province: "" }; + } + function distanceKm(a, b) { + var radians = Math.PI / 180; + var lat = (b.lat - a.lat) * radians; + var lon = (b.lon - a.lon) * radians; + var h = Math.sin(lat / 2) ** 2 + Math.cos(a.lat * radians) * Math.cos(b.lat * radians) * Math.sin(lon / 2) ** 2; + return 6371.0088 * 2 * Math.asin(Math.sqrt(Math.min(1, Math.max(0, h)))); + } + + function placeCandidates(rows, query) { + if (!Array.isArray(rows)) throw new Error("Invalid place response"); + var requested = splitPlaceQuery(query); + var seen = new Set(); + var places = rows.slice(0, 500).filter(function (row) { + var category = normalize(row.category); + return row.key === "geonames" && /^(city|town|ville|cite|village|hamlet|hameau|community|communaute|settlement|etablissement|locality|localite|urban community|agglomeration urbaine|dispersed rural community|collectivite rurale dispersee|municipality|municipalite|rural municipality|indian reserve|reserve indienne)$/.test(category); + }).map(function (row) { + return { name: decode(row.name).slice(0, 180), province: decode(row.province).slice(0, 80), lat: Number(row.lat), lon: Number(row.lng), category: normalize(row.category) }; + }).filter(function (place) { + var code = provinceCode(place.province); + var key = place.name + ":" + place.lat + ":" + place.lon; + if (!place.name || !code || !Number.isFinite(place.lat) || !Number.isFinite(place.lon) || place.lat < 41 || place.lat > 84 || place.lon < -142 || place.lon > -52 || (requested.province && requested.province !== code) || seen.has(key)) return false; + seen.add(key); + return true; + }); + var exact = places.filter(function (place) { return normalize(place.name) === normalize(requested.name); }); + if (exact.length) { + var cities = exact.filter(function (place) { return /^(city|town|ville|cite)$/.test(place.category); }); + // Prefer an exact city/town over similarly named bays, townships, or rural localities. + return (cities.length ? cities : exact).slice(0, 5); + } + return places.slice(0, 5); + } + + function initializeDirectory(root) { + if (!root || root.dataset.communityReady === "true") return; var search = root.querySelector("#community-search"); var status = root.querySelector("#community-status"); var override = root.querySelector("#community-override"); var count = root.querySelector("[data-community-count]"); + var lookup = root.querySelector("[data-community-lookup]"); + var choices = root.querySelector("[data-community-choices]"); + var credit = root.querySelector("[data-community-credit]"); + var submit = root.querySelector("[data-community-locate]"); + var showAll = root.querySelector("[data-community-show-all]"); var page = root.closest(".md-content") || document; - var cards = Array.prototype.slice.call( - page.querySelectorAll("[data-community-card]") - ); + var results = page.querySelector("[data-community-results]"); + var cards = Array.from(page.querySelectorAll("[data-community-card]")); var empty = page.querySelector("[data-community-empty]"); - var clearButtons = page.querySelectorAll("[data-community-clear]"); - var isFrench = document.documentElement.lang.toLowerCase().indexOf("fr") === 0; - - if (!search || !status || !override || !count || !empty || !cards.length) { - return; - } - + var isFrench = document.documentElement.lang.toLowerCase().startsWith("fr"); + if (!search || !status || !override || !count || !lookup || !choices || !submit || !results || !empty || !cards.length) return; root.dataset.communityReady = "true"; - - function updateUrl(query) { - if (!window.history || !window.history.replaceState) { - return; + var selectedPlace = null; + var expanded = false; + var controller = null; + var requestId = 0; + var points = new Map(cards.map(function (card) { return [card, JSON.parse(card.dataset.communityPoints)]; })); + cards.forEach(function (card) { + if (!card.querySelector("[data-community-distance]")) { + var line = document.createElement("p"); + line.className = "mc-community-distance"; + line.dataset.communityDistance = ""; + line.hidden = true; + card.querySelector(".mc-community-area").after(line); } - var url = new URL(window.location.href); - if (query) { - url.searchParams.set("community", query); - } else { - url.searchParams.delete("community"); + }); + var format = new Intl.NumberFormat(isFrench ? "fr-CA" : "en-CA", { maximumFractionDigits: 0 }); + + function saveQuery(updateHistory) { + function update(url) { + if (search.value.trim()) url.searchParams.set("community", search.value.trim()); + else url.searchParams.delete("community"); + if (selectedPlace) url.searchParams.set("nearby", "1"); + else url.searchParams.delete("nearby"); + return url; } - window.history.replaceState(null, "", url); + if (updateHistory) window.history.replaceState(null, "", update(new URL(window.location.href))); + document.querySelectorAll(".md-select__link[hreflang]").forEach(function (link) { link.href = update(new URL(link.href)).href; }); } - function applyFilters(options) { + function applyFilters(updateHistory) { var query = normalize(search.value); - var selectedStatus = status.value; - var requireOverride = override.checked; + var ordered = cards.map(function (card, index) { + var distances = selectedPlace ? points.get(card).map(function (point) { return { distance: distanceKm(selectedPlace, point), label: point.label }; }) : []; + distances.sort(function (a, b) { return a.distance - b.distance; }); + return { card: card, index: index, nearest: distances[0] }; + }); + if (selectedPlace) ordered.sort(function (a, b) { return a.nearest.distance - b.nearest.distance || a.index - b.index; }); + var matching = 0; var visible = 0; - - cards.forEach(function (card) { - var matchesQuery = - !query || normalize(card.dataset.communitySearch).indexOf(query) !== -1; - var matchesStatus = - !selectedStatus || card.dataset.communityStatus === selectedStatus; - var matchesOverride = - !requireOverride || card.dataset.communityOverride === "true"; - var show = matchesQuery && matchesStatus && matchesOverride; - card.hidden = !show; - if (show) { - visible += 1; + ordered.forEach(function (entry) { + var card = entry.card; + var matches = (selectedPlace || !query || normalize(card.dataset.communitySearch).includes(query)) && + (!status.value || card.dataset.communityStatus === status.value) && (!override.checked || card.dataset.communityOverride === "true"); + if (matches) matching += 1; + card.hidden = !matches || (selectedPlace && !expanded && matching > 5); + if (!card.hidden) visible += 1; + var line = card.querySelector("[data-community-distance]"); + line.hidden = !selectedPlace; + if (selectedPlace) { + line.textContent = "≈ " + format.format(entry.nearest.distance) + " km"; + line.title = (isFrench ? "Point de référence : " : "Reference point: ") + entry.nearest.label; } + results.appendChild(card); }); - empty.hidden = visible !== 0; - count.textContent = isFrench - ? visible + " " + (visible === 1 ? "communauté affichée" : "communautés affichées") - : "Showing " + visible + " " + (visible === 1 ? "community" : "communities"); - if (!options || options.updateUrl !== false) { - updateUrl(search.value.trim()); - } + showAll.hidden = !selectedPlace || expanded || matching <= 5; + count.textContent = selectedPlace + ? (isFrench ? visible + " communautés les plus proches de " : visible + " nearest communities to ") + selectedPlace.name + ", " + selectedPlace.province + : (isFrench ? visible + " " + (visible === 1 ? "communauté affichée" : "communautés affichées") : "Showing " + visible + " " + (visible === 1 ? "community" : "communities")); + saveQuery(updateHistory !== false); + return visible; } - function clearFilters() { - search.value = ""; - status.value = ""; - override.checked = false; - applyFilters(); - search.focus(); + function cancelLookup() { + requestId += 1; + if (controller) controller.abort(); + submit.disabled = false; + root.setAttribute("aria-busy", "false"); + choices.replaceChildren(); + choices.hidden = true; + lookup.textContent = ""; } - search.addEventListener("input", applyFilters); - status.addEventListener("change", applyFilters); - override.addEventListener("change", applyFilters); - clearButtons.forEach(function (button) { - button.addEventListener("click", clearFilters); - }); - - var initialQuery = new URL(window.location.href).searchParams.get("community"); - if (initialQuery) { - search.value = initialQuery; + function choosePlace(place) { + selectedPlace = place; + expanded = false; + search.value = place.name + ", " + place.province; + choices.hidden = true; + lookup.textContent = ""; + credit.hidden = false; + applyFilters(); } - applyFilters({ updateUrl: false }); - } - function initialize() { - document - .querySelectorAll("[data-community-directory]") - .forEach(initializeDirectory); - } + async function locate(event) { + if (event) event.preventDefault(); + if (submit.disabled) return; + var query = search.value.trim(); + if (query.length < 2) { + lookup.textContent = isFrench ? "Entrez une ville, avec la province au besoin." : "Enter a city, with its province if needed."; + search.focus(); + return; + } + cancelLookup(); + var current = requestId; + controller = new AbortController(); + var signal = controller.signal; + submit.disabled = true; + root.setAttribute("aria-busy", "true"); + credit.hidden = false; + lookup.textContent = isFrench ? "Recherche de la ville…" : "Looking up the city…"; + var timer; + try { + var key = (isFrench ? "fr:" : "en:") + normalize(query); + var places = placeCache.get(key); + if (!places) { + await new Promise(function (resolve) { window.setTimeout(resolve, Math.max(0, 1100 - (Date.now() - lastRequest))); }); + if (signal.aborted || current !== requestId) return; + lastRequest = Date.now(); + var requestController = controller; + timer = window.setTimeout(function () { requestController.abort(); }, 12000); + var url = new URL("https://geolocator.api.geo.ca/"); + url.search = new URLSearchParams({ q: splitPlaceQuery(query).name, lang: isFrench ? "fr" : "en", keys: "geonames" }).toString(); + var response = await fetch(url, { signal: signal, credentials: "omit", referrerPolicy: "strict-origin-when-cross-origin" }); + if (!response.ok) throw new Error("Place lookup failed"); + places = placeCandidates(await response.json(), query); + if (placeCache.size >= 20) placeCache.delete(placeCache.keys().next().value); + placeCache.set(key, places); + } + if (current !== requestId) return; + if (places.length === 1) choosePlace(places[0]); + else if (!places.length) lookup.textContent = isFrench ? "Aucune ville trouvée. Essayez le nom avec la province." : "No city found. Try its name and province."; + else { + lookup.textContent = isFrench ? "Quel lieu cherchez-vous ?" : "Which place do you mean?"; + choices.hidden = false; + places.forEach(function (place) { + var button = document.createElement("button"); + button.type = "button"; + button.className = "md-button"; + button.textContent = place.name + ", " + place.province; + button.addEventListener("click", function () { choosePlace(place); }); + choices.appendChild(button); + }); + } + } catch (_) { + if (current === requestId) lookup.textContent = isFrench + ? "La recherche de villes est indisponible. Vous pouvez toujours filtrer la liste par province ou communauté." + : "City lookup is unavailable. You can still filter the list by province or community."; + } finally { + window.clearTimeout(timer); + if (current === requestId) { + submit.disabled = false; + root.setAttribute("aria-busy", "false"); + } + } + } - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", initialize, { once: true }); - } else { - initialize(); + search.addEventListener("input", function () { cancelLookup(); selectedPlace = null; expanded = false; credit.hidden = true; applyFilters(); }); + root.addEventListener("submit", locate); + status.addEventListener("change", function () { applyFilters(); }); + override.addEventListener("change", function () { applyFilters(); }); + showAll.addEventListener("click", function () { expanded = true; applyFilters(); }); + page.querySelectorAll("[data-community-clear]").forEach(function (button) { + button.addEventListener("click", function () { + cancelLookup(); selectedPlace = null; expanded = false; credit.hidden = true; + search.value = ""; status.value = ""; override.checked = false; + applyFilters(); search.focus(); + }); + }); + var params = new URL(window.location.href).searchParams; + search.value = params.get("community") || ""; + applyFilters(false); + if (search.value && params.get("nearby") === "1") locate(); } - if (typeof window.document$ !== "undefined") { - window.document$.subscribe(initialize); - } + globalThis.MeshCoreCommunitySearch = { distanceKm: distanceKm, placeCandidates: placeCandidates, splitPlaceQuery: splitPlaceQuery }; + function initialize() { document.querySelectorAll("[data-community-directory]").forEach(initializeDirectory); } + if (typeof document === "undefined") return; + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", initialize, { once: true }); + else initialize(); + if (typeof window.document$ !== "undefined") window.document$.subscribe(initialize); })(); diff --git a/docs/assets/javascripts/network-status.js b/docs/assets/javascripts/network-status.js new file mode 100644 index 00000000..c525237e --- /dev/null +++ b/docs/assets/javascripts/network-status.js @@ -0,0 +1,108 @@ +(function () { + "use strict"; + var MAX_AGE = 5 * 60 * 1000; + var CACHE_KEY = "meshcore-canada:network-totals:v1"; + var pending = null; + var cache = null; + + function count(value) { + if (!Number.isSafeInteger(value) || value < 0) throw new Error("Invalid network count"); + return value; + } + + function parseStats(overview, types) { + if (!overview || !Array.isArray(types) || types.length > 16) throw new Error("Invalid network statistics"); + var result = { repeaters: 0, companions: 0, rooms: 0, sensors: 0, other: 0, total: 0, + observers: count(overview.activeObservers), areas: count(overview.activeIatas), packets: count(overview.totalPackets), + hours: count(overview.windowHours) }; + if (result.hours < 1 || result.hours > 168) throw new Error("Invalid reporting window"); + var seen = new Set(); + types.forEach(function (item) { + var type = count(item.nodeType); + if (seen.has(type)) throw new Error("Duplicate device type"); + seen.add(type); + var field = ({ 1: "companions", 2: "repeaters", 3: "rooms", 4: "sensors" })[type] || "other"; + result[field] += count(item.count); + result.total += item.count; + }); + count(result.total); + return result; + } + + function readCache() { + try { + var saved = cache || JSON.parse(sessionStorage.getItem(CACHE_KEY)); + if (!saved || !Number.isFinite(saved.at) || saved.at > Date.now() || Date.now() - saved.at >= MAX_AGE) return null; + ["repeaters", "companions", "rooms", "sensors", "other", "total", "observers", "areas", "packets", "hours"].forEach(function (key) { count(saved.stats[key]); }); + if (saved.stats.hours < 1 || saved.stats.hours > 168) return null; + return saved; + } catch (_) { return null; } + } + + async function loadStats() { + var saved = readCache(); + if (saved) return saved; + if (pending) return pending; + pending = (async function () { + var controller = new AbortController(); + var timer = window.setTimeout(function () { controller.abort(); }, 8000); + try { + var responses = await Promise.all(["overview", "node-types"].map(async function (path) { + var response = await fetch("https://dev.meshcore.ca/api/v1/stats/" + path, { signal: controller.signal, credentials: "omit" }); + if (!response.ok) throw new Error("Network statistics unavailable"); + return response.json(); + })); + cache = { at: Date.now(), stats: parseStats(responses[0], responses[1]) }; + try { sessionStorage.setItem(CACHE_KEY, JSON.stringify(cache)); } catch (_) { /* Counts work without storage. */ } + return cache; + } finally { window.clearTimeout(timer); controller.abort(); } + })().finally(function () { pending = null; }); + return pending; + } + + function initialize() { + var root = document.querySelector("[data-network-summary]"); + if (!root || root.dataset.networkReady) return; + root.dataset.networkReady = "true"; + var french = document.documentElement.lang.startsWith("fr"); + var locale = french ? "fr-CA" : "en-CA"; + var numbers = new Intl.NumberFormat(locale); + var compact = new Intl.NumberFormat(locale, { notation: "compact", maximumFractionDigits: 1 }); + var summary = root.querySelector("summary"); + var total = root.querySelector("[data-network-total]"); + var updated = root.querySelector("[data-network-updated]"); + async function refresh() { + if (document.visibilityState === "hidden") return; + try { + var data = await loadStats(); + total.textContent = compact.format(data.stats.total); + summary.setAttribute("aria-label", (french ? "Statistiques du réseau : " : "Network statistics: ") + numbers.format(data.stats.total) + (french ? " appareils connus" : " known devices")); + root.querySelectorAll("[data-network-count]").forEach(function (element) { + element.textContent = numbers.format(data.stats[element.dataset.networkCount]); + }); + root.querySelectorAll("[data-network-other]").forEach(function (element) { element.hidden = !data.stats.other; }); + root.querySelector("[data-network-period]").textContent = french ? "Dernières " + data.stats.hours + " h" : "Last " + data.stats.hours + " hours"; + updated.textContent = (french ? "Mis à jour à " : "Updated ") + new Date(data.at).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }); + root.dataset.networkState = "ready"; + } catch (_) { + total.textContent = "—"; + summary.setAttribute("aria-label", french ? "Statistiques du réseau indisponibles" : "Network statistics unavailable"); + root.querySelectorAll("[data-network-count]").forEach(function (element) { element.textContent = "—"; }); + root.querySelectorAll("[data-network-other]").forEach(function (element) { element.hidden = true; }); + updated.textContent = french ? "Totaux indisponibles pour le moment." : "Network totals are temporarily unavailable."; + root.dataset.networkState = "unavailable"; + } + } + document.addEventListener("pointerdown", function (event) { if (!root.contains(event.target)) root.open = false; }); + root.addEventListener("focusout", function (event) { if (event.relatedTarget && !root.contains(event.relatedTarget)) root.open = false; }); + root.addEventListener("keydown", function (event) { if (event.key === "Escape" && root.open) { root.open = false; summary.focus(); } }); + document.addEventListener("visibilitychange", refresh); + window.setInterval(refresh, MAX_AGE); + refresh(); + } + + globalThis.MeshCoreNetworkStatus = { parseStats: parseStats }; + if (typeof document === "undefined") return; + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", initialize, { once: true }); + else initialize(); +})(); diff --git a/docs/assets/regions/regions.css b/docs/assets/regions/regions.css index 9138fcc6..80531348 100644 --- a/docs/assets/regions/regions.css +++ b/docs/assets/regions/regions.css @@ -1135,6 +1135,15 @@ body:has([data-mcc-regions]) .md-typeset h3 { background: var(--mcc-surface); } +.mcc-map-canvas .leaflet-control-attribution { + color: var(--mcc-text); + background: var(--mcc-surface); +} + +.mcc-map-canvas .leaflet-control-attribution a { + color: var(--md-typeset-a-color); +} + .mcc-region-borders { image-rendering: auto; } diff --git a/docs/assets/regions/regions.js b/docs/assets/regions/regions.js index 75adff68..96240f01 100644 --- a/docs/assets/regions/regions.js +++ b/docs/assets/regions/regions.js @@ -172,6 +172,8 @@ "Step 1 of 4": "Étape 1 sur 4", "What are you configuring?": "Quel appareil configurez-vous?", "We will recommend forwarding paths, then show how to apply them.": "Nous recommanderons les chemins à relayer, puis nous vous montrerons comment les appliquer.", + "Browse the region map": "Parcourir la carte des régions", + "Open the region editor": "Ouvrir l’éditeur de régions", "Device and experience": "Appareil et niveau d’expérience", "Repeater": "Répéteur", "Recommended for most operators": "Recommandé pour la plupart des exploitants", @@ -2022,6 +2024,8 @@ '

Step 1 of 4

' + '

What are you configuring?

' + '

We will recommend forwarding paths, then show how to apply them.

' + + '

Browse the region map · ' + + 'Open the region editor

' + '
' + '' + '' + diff --git a/docs/assets/styles/communities.css b/docs/assets/styles/communities.css index 8192ffe8..b879e77f 100644 --- a/docs/assets/styles/communities.css +++ b/docs/assets/styles/communities.css @@ -78,6 +78,35 @@ color: var(--mc-color-text-muted); } +.mc-directory-actions, +.mc-directory-choices { + display: flex; + flex-wrap: wrap; + gap: var(--mc-space-2); +} +.mc-directory-actions { grid-column: 1; grid-row: 2; } +.mc-directory-tools__check { grid-column: 2; grid-row: 2; } + +.mc-directory-lookup, +.mc-directory-choices, +.mc-directory-credit { + grid-column: 1 / -1; +} + +.mc-directory-lookup:empty { display: none; } +.mc-directory-tools [hidden] { display: none !important; } + +.mc-directory-tools .mc-directory-credit { + margin: 0; + color: var(--mc-color-text-muted); + font-size: 0.82rem; +} + +.mc-community-distance { + color: var(--mc-color-action); + font-weight: 650; +} + .mc-community-grid, .mc-province-grid { display: grid; @@ -233,6 +262,8 @@ } @media screen and (max-width: 44.9844em) { + .mc-directory-actions, + .mc-directory-tools__check { grid-column: auto; grid-row: auto; } .mc-directory-tools { grid-template-columns: 1fr; padding: var(--mc-space-4); diff --git a/docs/assets/styles/home.css b/docs/assets/styles/home.css new file mode 100644 index 00000000..3f669b9e --- /dev/null +++ b/docs/assets/styles/home.css @@ -0,0 +1,20 @@ +.mc-home-hero { + position: relative; isolation: isolate; overflow: hidden; + min-height: 11rem; padding: 1.5rem; margin: 1rem 0 2rem; + border: 1px solid var(--mc-color-border); border-radius: var(--mc-radius-md); + background: linear-gradient(115deg, var(--mc-color-surface), var(--mc-color-surface-raised)); +} +.mc-home-hero > .mc-home-hero__art { + position: absolute; z-index: -1; top: 0; right: 0; height: 100%; width: 68%; + object-fit: contain; pointer-events: none; opacity: 0.9; + mask-image: linear-gradient(to right, transparent, #000 28%); +} +.mc-home-hero__art img { width: 100%; height: 100%; object-fit: contain; } +.mc-home-hero > p { position: relative; max-width: 38ch; margin: 0; } +.mc-home-hero .mc-home-hero__credit { margin-top: 1rem; font-size: 0.7rem; color: var(--mc-color-text-muted); } +.mc-preset-note { font-size: 0.85rem; color: var(--mc-color-text-muted); } +@media (max-width: 45em) { + .mc-home-hero { padding: 1.25rem; min-height: 10rem; } + .mc-home-hero > .mc-home-hero__art { width: 100%; opacity: 0.25; mask-image: none; } + .mc-home-hero > p { max-width: none; } +} diff --git a/docs/assets/styles/network-status.css b/docs/assets/styles/network-status.css new file mode 100644 index 00000000..7e8c88d2 --- /dev/null +++ b/docs/assets/styles/network-status.css @@ -0,0 +1,33 @@ +.mc-network { position: relative; flex: 0 0 auto; margin-inline: 0.3rem; } +.mc-network > summary { + display: flex; align-items: center; gap: 0.35rem; min-height: 2.75rem; + padding: 0.25rem 0.35rem; border-radius: var(--mc-radius-sm); + color: #fff; cursor: pointer; list-style: none; font-size: 0.75rem; +} +.mc-network > summary::-webkit-details-marker { display: none; } +.mc-network > summary:hover, .mc-network[open] > summary { background: rgb(255 255 255 / 8%); } +.mc-network__icon { display: flex; } +.mc-network__icon svg { width: 1.1rem; height: 1.1rem; fill: currentColor; } +.mc-network__total { display: inline-block; min-width: 3.2rem; text-align: end; font-variant-numeric: tabular-nums; } +.mc-network__panel { + position: absolute; top: calc(100% + 0.25rem); right: 0; z-index: 5; + width: 19rem; max-width: calc(100vw - 1.5rem); padding: 1rem; + color: var(--mc-color-text); background: var(--mc-color-surface); + border: 1px solid var(--mc-color-border); border-radius: var(--mc-radius-md); + box-shadow: var(--mc-shadow-2); font-size: 0.85rem; line-height: 1.5; +} +.mc-network__panel p { margin: 0 0 0.6rem; } +.mc-network__panel dl { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 0.25rem 1rem; margin: 0 0 0.9rem; } +.mc-network__panel dt { color: var(--mc-color-text-muted); } +.mc-network__panel dd { margin: 0; font-variant-numeric: tabular-nums; font-weight: 650; } +.mc-network__panel .mc-network__period { border-top: 1px solid var(--mc-color-border); padding-top: 0.6rem; font-weight: 650; } +.mc-network__panel .mc-network__updated { color: var(--mc-color-text-muted); font-size: 0.75rem; } +.mc-network__panel a { color: var(--mc-color-action); text-decoration: underline; } +.mc-network [hidden] { display: none !important; } +@media (max-width: 72em) { .mc-network__label { display: none; } } +@media (max-width: 45em) { + .mc-network { margin-inline: 0; } + .mc-network__total { display: none; } + .mc-network > summary { min-width: 2rem; justify-content: center; padding: 0.25rem; } + .mc-network__panel { position: fixed; top: 3.5rem; right: 0.75rem; } +} diff --git a/docs/config/index.fr.md b/docs/config/index.fr.md index 13c0c3cd..28b8095b 100644 --- a/docs/config/index.fr.md +++ b/docs/config/index.fr.md @@ -14,11 +14,11 @@ tested_with: difficulty: intermediate estimated_time: 5-10 minutes page_styles: - - assets/regions/regions.css?v=20260904-1 + - assets/regions/regions.css?v=20260905-1 page_scripts: - assets/javascripts/radio-profiles.js?v=20260904-1 - assets/regions/modules/configurator-support.js?v=20260904-1 - - assets/regions/regions.js?v=20260904-1 + - assets/regions/regions.js?v=20260905-1 hide: - navigation - toc diff --git a/docs/config/index.md b/docs/config/index.md index c7ee9003..375f7e72 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -14,11 +14,11 @@ tested_with: difficulty: intermediate estimated_time: 5-10 minutes page_styles: - - assets/regions/regions.css?v=20260904-1 + - assets/regions/regions.css?v=20260905-1 page_scripts: - assets/javascripts/radio-profiles.js?v=20260904-1 - assets/regions/modules/configurator-support.js?v=20260904-1 - - assets/regions/regions.js?v=20260904-1 + - assets/regions/regions.js?v=20260905-1 hide: - navigation - toc diff --git a/docs/config/map.fr.md b/docs/config/map.fr.md index 4e2fa778..ffa4ca5f 100644 --- a/docs/config/map.fr.md +++ b/docs/config/map.fr.md @@ -15,11 +15,11 @@ tested_with: region_catalog: national-partition-2026-07-19 difficulty: beginner page_styles: - - assets/regions/regions.css?v=20260904-1 + - assets/regions/regions.css?v=20260905-1 page_scripts: - assets/javascripts/radio-profiles.js?v=20260904-1 - assets/regions/modules/configurator-support.js?v=20260904-1 - - assets/regions/regions.js?v=20260904-1 + - assets/regions/regions.js?v=20260905-1 hide: - toc --- diff --git a/docs/config/map.md b/docs/config/map.md index cee67591..27454db0 100644 --- a/docs/config/map.md +++ b/docs/config/map.md @@ -15,11 +15,11 @@ tested_with: region_catalog: national-partition-2026-07-19 difficulty: beginner page_styles: - - assets/regions/regions.css?v=20260904-1 + - assets/regions/regions.css?v=20260905-1 page_scripts: - assets/javascripts/radio-profiles.js?v=20260904-1 - assets/regions/modules/configurator-support.js?v=20260904-1 - - assets/regions/regions.js?v=20260904-1 + - assets/regions/regions.js?v=20260905-1 hide: - toc --- diff --git a/docs/hardware/index.fr.md b/docs/hardware/index.fr.md index de5dd671..80592913 100644 --- a/docs/hardware/index.fr.md +++ b/docs/hardware/index.fr.md @@ -24,14 +24,6 @@ Choisissez d’abord le rôle de l’appareil. Avant de l’acheter, confirmez e la carte exacte et la cible du micrologiciel dans le [programme officiel de mise à jour MeshCore](https://meshcore.io/flasher). -
- -**Vérifiez avant d’acheter.** Les révisions de produits et la prise en charge -du micrologiciel changent. Les appareils liés sont des options à comparer, et -non des garanties de compatibilité. - -
- ## Choisir un type d’appareil
diff --git a/docs/hardware/index.md b/docs/hardware/index.md index 1e675c1b..329c83d7 100644 --- a/docs/hardware/index.md +++ b/docs/hardware/index.md @@ -22,12 +22,6 @@ page_styles: Choose what the device will do, then confirm the exact board and firmware target in the [official MeshCore flasher](https://meshcore.io/flasher) before buying. -
- -**Check before buying.** Product revisions and firmware support change. Treat the linked devices as options to compare, not compatibility guarantees. - -
- ## Choose a device type
diff --git a/docs/index.fr.md b/docs/index.fr.md index 56111eaa..622fe0f4 100644 --- a/docs/index.fr.md +++ b/docs/index.fr.md @@ -15,16 +15,26 @@ tested_with: difficulty: beginner estimated_time: 1-2 minutes destructive: false +page_styles: + - assets/styles/home.css?v=20260905-1 hide: - toc --- # MeshCore Canada +
+ + + Bienvenue! Nous améliorons activement ce site. Vous avez trouvé quelque chose de difficile à comprendre ou de désuet? [Signalez-le sur GitHub](https://github.com/MeshCore-ca/MeshCore-Canada/issues/new/choose). +

Réseau illustratif · Sources cartographiques

+ +
+ ## Que cherchez-vous? { #start-with-your-goal }
@@ -107,6 +117,8 @@ Utilisez ces paramètres, sauf si votre communauté en indique d’autres.
+

Prévu : un préréglage Canada distinct, avec les mêmes paramètres radio que USA/Canada et un hachage de 3 octets par défaut pour tous les types d’appareils. Suivre la discussion.

+ ## Améliorer MeshCore Canada Quelque chose manque ou porte à confusion? diff --git a/docs/index.md b/docs/index.md index b30557cf..e4ef2177 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,15 +15,25 @@ tested_with: difficulty: beginner estimated_time: 1-2 minutes destructive: false +page_styles: + - assets/styles/home.css?v=20260905-1 hide: - toc --- # MeshCore Canada +
+ + + Welcome! We're improving this site. Found something unclear or outdated? [Open a GitHub issue](https://github.com/MeshCore-ca/MeshCore-Canada/issues/new/choose). +

Illustrative network · Map sources

+ +
+ ## What are you looking for? { #start-with-your-goal }
@@ -105,6 +115,8 @@ Use these defaults unless your local community lists different settings.
+

Planned: a separate Canada preset with the same radio settings as USA/Canada and 3-byte hash mode by default for all device types. Follow the preset discussion.

+ ## Improve MeshCore Canada Found something missing or confusing? [Share an idea](submit-idea.md) or diff --git a/docs/privacy.fr.md b/docs/privacy.fr.md index 3a552fbd..764cb7d1 100644 --- a/docs/privacy.fr.md +++ b/docs/privacy.fr.md @@ -24,6 +24,8 @@ Vous pouvez consulter ce site sans compte MeshCore Canada ni compte GitHub. | Action | Données transmises | Destination | |---|---|---| | Rechercher dans cette documentation | Vos mots de recherche restent dans le navigateur | Nulle part | +| Trouver des communautés à proximité | La ville recherchée | API Géolocalisateur de Ressources naturelles Canada; les distances sont calculées dans votre navigateur | +| Charger les totaux du réseau dans l’en-tête | Votre adresse IP et les renseignements habituels d’une requête Web, sans texte de recherche | Beacon à dev.meshcore.ca | | Rechercher un lieu dans les outils de région | Le lieu, le code d’aéroport ou le code postal saisi | Vérification locale d’abord, puis OpenStreetMap ou geocoder.ca au besoin | | Ouvrir la carte interactive des régions | Votre adresse IP et la partie visible de la carte | OpenStreetMap | | Vérifier un identifiant de parcours de répéteur | Le premier octet de la clé publique | Beacon à dev.meshcore.ca; les comparaisons régionales et des préfixes plus longs restent dans votre navigateur | @@ -56,6 +58,9 @@ dans votre navigateur lorsque vous utilisez ces fonctions. Les clés publiques des répéteurs, les mots de passe, les clés privées, les jetons antipourriel et les recherches d’emplacement n’y sont pas enregistrés. +Les totaux de l’en-tête sont mis en cache pendant cinq minutes dans l’onglet courant. +La ville recherchée dans le répertoire apparaît dans l’URL pour partager ou retrouver les résultats. + Vous pouvez effacer les données enregistrées par ce site dans les paramètres de votre navigateur. Le formulaire d’idée offre aussi l’action **Effacer le brouillon enregistré**. diff --git a/docs/privacy.md b/docs/privacy.md index f9647749..9a7e5e32 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -24,6 +24,8 @@ You can read this site without a MeshCore Canada or GitHub account. | Action | What is sent | Where it goes | |---|---|---| | Search this documentation | Your search words stay in the browser | Nowhere | +| Find nearby communities | The city you submit | Natural Resources Canada’s Geolocator API; distances are calculated in your browser | +| Load header network totals | Your IP address and standard web request information, without search text | Beacon at dev.meshcore.ca | | Search for a place in the region tools | The place, airport code, or postal code you enter | Checked locally first, then sent to OpenStreetMap or geocoder.ca if needed | | Open the interactive region map | Your IP address and the visible map area | OpenStreetMap | | Check a repeater path ID | The first byte of the public key | Beacon at dev.meshcore.ca; regional and longer-prefix comparisons stay in your browser | @@ -53,6 +55,9 @@ repeater-ID checker are saved in your browser only when you use those features. Repeater public keys, passwords, private keys, anti-spam tokens, and location searches are not saved there. +Aggregate header counts are cached for five minutes in the current browser tab. +Community city searches appear in the page URL so you can share or revisit the results. + You can clear saved site data through your browser settings. The idea form also provides a **Clear draft** action. diff --git a/docs/provinces/alberta.fr.md b/docs/provinces/alberta.fr.md index 05cf45d9..a4f6d2c6 100644 --- a/docs/provinces/alberta.fr.md +++ b/docs/provinces/alberta.fr.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/alberta.md b/docs/provinces/alberta.md index fd540cf6..97c40578 100644 --- a/docs/provinces/alberta.md +++ b/docs/provinces/alberta.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/british-columbia.fr.md b/docs/provinces/british-columbia.fr.md index 744294ce..4b4ebe49 100644 --- a/docs/provinces/british-columbia.fr.md +++ b/docs/provinces/british-columbia.fr.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/british-columbia.md b/docs/provinces/british-columbia.md index b5a68166..ab5b787b 100644 --- a/docs/provinces/british-columbia.md +++ b/docs/provinces/british-columbia.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/index.fr.md b/docs/provinces/index.fr.md index d2cd6660..1fea6ff3 100644 --- a/docs/provinces/index.fr.md +++ b/docs/provinces/index.fr.md @@ -14,21 +14,21 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 +status_notice: false page_scripts: - - assets/javascripts/communities.js?v=20260722-2 + - assets/javascripts/communities.js?v=20260905-1 --- # Trouver une communauté MeshCore au Canada -Recherchez par lieu, province, nom de communauté ou alias courant. La liste -complète fonctionne sans carte, autorisation de localisation ni compte GitHub. +Filtrez la liste par communauté ou province, ou cherchez votre ville pour trouver les communautés les plus proches. + !!! note "Les renseignements sur les communautés peuvent changer" - 0 fiches sur 24 n’ont pas fait l’objet d’une vérification récente des coordonnées. - Confirmez les réglages et les coordonnées importants avant de vous y fier. + Ces renseignements pourraient être périmés !
24 fiches @@ -37,10 +37,10 @@ complète fonctionne sans carte, autorisation de localisation ni compte GitHub. 1 fiche avec des réglages locaux différents
-
+
@@ -54,15 +54,20 @@ complète fonctionne sans carte, autorisation de localisation ni compte GitHub. Possède des réglages locaux différents - +
+
24 communautés affichées -
+
+ + + +
@@ -70,7 +75,7 @@ complète fonctionne sans carte, autorisation de localisation ni compte GitHub. ## Communautés
-
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+ -
+

Lunenburg County Mesh

Active diff --git a/docs/provinces/index.md b/docs/provinces/index.md index 7ba6b8f2..3fdb30b9 100644 --- a/docs/provinces/index.md +++ b/docs/provinces/index.md @@ -14,21 +14,21 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 +status_notice: false page_scripts: - - assets/javascripts/communities.js?v=20260722-2 + - assets/javascripts/communities.js?v=20260905-1 --- # Find a MeshCore community -Search by place, province, community name, or a common alias. The full list -works without a map, location permission, or a GitHub account. +Type a community or province to filter the list, or look up your city to find the closest communities. + !!! note "Community information can change" - 0 of 24 listings do not have a recent contact check. - Confirm important settings and contacts before relying on them. + This information could be out of date!
24 listings @@ -37,10 +37,10 @@ works without a map, location permission, or a GitHub account. 1 with different local settings
-
+
@@ -54,15 +54,20 @@ works without a map, location permission, or a GitHub account. Has a local settings override - +
+
Showing 24 communities -
+
+ + + +
@@ -70,12 +75,13 @@ works without a map, location permission, or a GitHub account. ## Communities
-
+

BC Mesh

Active

Lower Mainland and Vancouver Island

+

Province: British Columbia

Settings: Different local settings — Radio preset: Custom; Raw radio values: 910.425 MHz / 62.5 kHz / SF7 / CR5

Listing reviewed: 2026-08-29

@@ -84,12 +90,13 @@ works without a map, location permission, or a GitHub account.

View listing details

-
+

Salish Mesh

Active

Salish Sea and surrounding area

+

Province: British Columbia

Settings: Uses the Canada defaults

Listing reviewed: 2026-08-29

@@ -98,12 +105,13 @@ works without a map, location permission, or a GitHub account.

View listing details

-
+

Alberta

+

Building Alberta's community-operated off-grid LoRa mesh network

Province: Alberta

Settings: Uses the Canada defaults

@@ -120,12 +128,13 @@ works without a map, location permission, or a GitHub account.

View listing details

-
+

Airdrie and Calgary

+

Uses the YYC regional identifier shared with the Calgary regional network

Province: Alberta

Settings: Uses the Canada defaults

@@ -139,12 +148,13 @@ works without a map, location permission, or a GitHub account.

View listing details

-
+ -
+ -
+ -
+

yegmesh.ca

Active

Edmonton

+

Edmonton mesh network community focused on MeshCore

Province: Alberta

Settings: Uses the Canada defaults

@@ -209,12 +222,13 @@ works without a map, location permission, or a GitHub account.

View listing details

-
+

YQLMesh

Active

Lethbridge

+

Connecting Lethbridge, one node at a time

Province: Alberta

Settings: Uses the Canada defaults

@@ -231,12 +245,13 @@ works without a map, location permission, or a GitHub account.

View listing details

-
+

Southern Alberta, including Cardston, Magrath, Raymond, and nearby areas

+

Province: Alberta

Settings: Uses the Canada defaults

Listing reviewed: 2026-08-29

@@ -246,12 +261,13 @@ works without a map, location permission, or a GitHub account.

View listing details

-
+ -
+

StoonMesh

Active

Central Saskatchewan

+

Province: Saskatchewan

Settings: Uses the Canada defaults

Listing reviewed: 2026-08-29

@@ -275,12 +292,13 @@ works without a map, location permission, or a GitHub account.

View listing details

-
+

YQRMesh

Active

Regina and Southern Saskatchewan

+

Province: Saskatchewan

Settings: Uses the Canada defaults

Listing reviewed: 2026-08-29

@@ -288,12 +306,13 @@ works without a map, location permission, or a GitHub account.

View listing details

-
+ -
+

Southern Ontario

+

Province: Ontario

Settings: Uses the Canada defaults

Listing reviewed: 2026-08-29

@@ -317,12 +337,13 @@ works without a map, location permission, or a GitHub account.

View listing details

-
+

Quinte Region, including Belleville, Trenton, Prince Edward County, and nearby areas

+

Province: Ontario

Settings: Uses the Canada defaults

Listing reviewed: 2026-08-29

@@ -332,12 +353,13 @@ works without a map, location permission, or a GitHub account.

View listing details

-
+ -
+

Mesh Quebec

Active

Quebec

+

Province: Quebec

Settings: Uses the Canada defaults

Listing reviewed: 2026-08-29

@@ -360,12 +383,13 @@ works without a map, location permission, or a GitHub account.

View listing details

-
+

Montreal Mesh

Active

Greater Montreal

+

Province: Quebec

Settings: Uses the Canada defaults

Listing reviewed: 2026-08-29

@@ -374,12 +398,13 @@ works without a map, location permission, or a GitHub account.

View listing details

-
+ -
+ -
+

Montreal

+

Province: Quebec

Settings: Uses the Canada defaults

Listing reviewed: 2026-08-29

@@ -417,12 +444,13 @@ works without a map, location permission, or a GitHub account.

View listing details

-
+

Fredericton, Saint John, Moncton, and nearby areas

+

Province: New Brunswick

Settings: Uses the Canada defaults

Listing reviewed: 2026-08-29

@@ -431,12 +459,13 @@ works without a map, location permission, or a GitHub account.

View listing details

-
+

Lunenburg County

+

Province: Nova Scotia

Settings: Uses the Canada defaults

Listing reviewed: 2026-08-29

diff --git a/docs/provinces/manitoba.fr.md b/docs/provinces/manitoba.fr.md index 2557eb1d..f0619997 100644 --- a/docs/provinces/manitoba.fr.md +++ b/docs/provinces/manitoba.fr.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/manitoba.md b/docs/provinces/manitoba.md index 5bec5374..5fabda48 100644 --- a/docs/provinces/manitoba.md +++ b/docs/provinces/manitoba.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/new-brunswick.fr.md b/docs/provinces/new-brunswick.fr.md index 92d5c11e..5a9f866b 100644 --- a/docs/provinces/new-brunswick.fr.md +++ b/docs/provinces/new-brunswick.fr.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/new-brunswick.md b/docs/provinces/new-brunswick.md index 1688a88e..43ccd023 100644 --- a/docs/provinces/new-brunswick.md +++ b/docs/provinces/new-brunswick.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/newfoundland-and-labrador.fr.md b/docs/provinces/newfoundland-and-labrador.fr.md index ff037fd2..04c4bb76 100644 --- a/docs/provinces/newfoundland-and-labrador.fr.md +++ b/docs/provinces/newfoundland-and-labrador.fr.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/newfoundland-and-labrador.md b/docs/provinces/newfoundland-and-labrador.md index ae73257d..7f1592a3 100644 --- a/docs/provinces/newfoundland-and-labrador.md +++ b/docs/provinces/newfoundland-and-labrador.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/nova-scotia.fr.md b/docs/provinces/nova-scotia.fr.md index 59f03290..8d4c4ec2 100644 --- a/docs/provinces/nova-scotia.fr.md +++ b/docs/provinces/nova-scotia.fr.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/nova-scotia.md b/docs/provinces/nova-scotia.md index fa56513f..bcf0449c 100644 --- a/docs/provinces/nova-scotia.md +++ b/docs/provinces/nova-scotia.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/ontario.fr.md b/docs/provinces/ontario.fr.md index e4424e1b..de8cf3c7 100644 --- a/docs/provinces/ontario.fr.md +++ b/docs/provinces/ontario.fr.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/ontario.md b/docs/provinces/ontario.md index c21d2354..451a4d68 100644 --- a/docs/provinces/ontario.md +++ b/docs/provinces/ontario.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/prince-edward-island.fr.md b/docs/provinces/prince-edward-island.fr.md index 28d7e7e9..eb075c94 100644 --- a/docs/provinces/prince-edward-island.fr.md +++ b/docs/provinces/prince-edward-island.fr.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/prince-edward-island.md b/docs/provinces/prince-edward-island.md index 26fc9472..099cf8d2 100644 --- a/docs/provinces/prince-edward-island.md +++ b/docs/provinces/prince-edward-island.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/quebec.fr.md b/docs/provinces/quebec.fr.md index 3eec9f39..abd7bd39 100644 --- a/docs/provinces/quebec.fr.md +++ b/docs/provinces/quebec.fr.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/quebec.md b/docs/provinces/quebec.md index f060bb93..021d5a41 100644 --- a/docs/provinces/quebec.md +++ b/docs/provinces/quebec.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/saskatchewan.fr.md b/docs/provinces/saskatchewan.fr.md index 23c733fb..3ad1950c 100644 --- a/docs/provinces/saskatchewan.fr.md +++ b/docs/provinces/saskatchewan.fr.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/saskatchewan.md b/docs/provinces/saskatchewan.md index 768ef475..2f6ffc85 100644 --- a/docs/provinces/saskatchewan.md +++ b/docs/provinces/saskatchewan.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/territories.fr.md b/docs/provinces/territories.fr.md index 0dc1cf8b..2f2ff8cc 100644 --- a/docs/provinces/territories.fr.md +++ b/docs/provinces/territories.fr.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/docs/provinces/territories.md b/docs/provinces/territories.md index 77c25041..b6ec7d2e 100644 --- a/docs/provinces/territories.md +++ b/docs/provinces/territories.md @@ -14,7 +14,7 @@ difficulty: beginner estimated_time: 2-5 minutes destructive: false page_styles: - - assets/styles/communities.css?v=20260722-2 + - assets/styles/communities.css?v=20260905-1 --- diff --git a/maintenance/site-audit-2026-09-04.md b/maintenance/site-audit-2026-09-04.md index 620bff8a..51ca4ecb 100644 --- a/maintenance/site-audit-2026-09-04.md +++ b/maintenance/site-audit-2026-09-04.md @@ -55,6 +55,26 @@ contract is unchanged. No deployment is part of this PR. ## Review checklist +### September 5 browser feedback + +- The directory landing page hides its review banner and uses the requested short + out-of-date notice. Individual listing review dates and safety metadata remain. +- City lookup uses Natural Resources Canada's current Geolocator API with the + official place-name source. Exact cities take priority over similarly named + geographic features; ambiguous results offer a choice. Nearby results use + approximate reference points for all 24 communities, not radio coverage. +- The hardware landing page no longer repeats the purchase-warning card. +- The first configurator step links directly to the map and region editor. +- Header totals use public Beacon aggregates: known devices by type, plus + observers, areas, and packets for the overview's reported time window. Counts + are cached per tab for five minutes; errors show unavailable values, not zeroes. +- The homepage adds a theme-aware SVG motif derived from the existing Canadian + geography, and a brief note about the planned separate Canada preset. The note + links to upstream issue #3302; no release date or installed default is claimed. +- Neither broker services nor the production site are deployed by these changes. + +### Before approval + - Inspect explicit Canada/BC Mesh commands and keep-current defaults. - Check the map → configuration → French journey on a phone and desktop. - Review short-form feedback in both languages, including the originating page. diff --git a/mkdocs.yml b/mkdocs.yml index 9f10ce58..6eb05777 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,10 +46,12 @@ extra_css: - assets/styles/base.css?v=20260821-1 - assets/styles/shell.css?v=20260821-1 - assets/styles/components.css?v=20260728-1 + - assets/styles/network-status.css?v=20260905-1 extra_javascript: - assets/javascripts/i18n-runtime.js?v=20260829-1 - assets/javascripts/bootstrap.js?v=20260821-1 + - assets/javascripts/network-status.js?v=20260905-1 extra: github_stars: 19 diff --git a/overrides/partials/header.html b/overrides/partials/header.html new file mode 100644 index 00000000..d4a7ffa0 --- /dev/null +++ b/overrides/partials/header.html @@ -0,0 +1,88 @@ +{# Header adapted from Material for MkDocs 9.7.6. +Copyright (c) 2016-2025 Martin Donath + +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 NON-INFRINGEMENT. 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. +#} +{% set class = "md-header" %} +{% if "navigation.tabs.sticky" in features %} + {% set class = class ~ " md-header--shadow md-header--lifted" %} +{% elif "navigation.tabs" not in features %} + {% set class = class ~ " md-header--shadow" %} +{% endif %} +
+ + {% if "navigation.tabs.sticky" in features %} + {% if "navigation.tabs" in features %} + {% include "partials/tabs.html" %} + {% endif %} + {% endif %} +
diff --git a/overrides/partials/network-status.html b/overrides/partials/network-status.html new file mode 100644 index 00000000..7cc82d51 --- /dev/null +++ b/overrides/partials/network-status.html @@ -0,0 +1,26 @@ +{% set network_fr = i18n_page_locale | default(config.theme.language, true) == "fr" %} +
+ + + {{ 'Réseau' if network_fr else 'Network' }} + — + +
+

{{ 'Appareils connus de Beacon' if network_fr else 'Devices known to Beacon' }}

+
+
{{ 'Répéteurs' if network_fr else 'Repeaters' }}
—
+
{{ 'Compagnons' if network_fr else 'Companions' }}
—
+
{{ 'Serveurs de salon' if network_fr else 'Room servers' }}
—
+
{{ 'Capteurs' if network_fr else 'Sensors' }}
—
+ +
+

{{ 'Dernières 24 h' if network_fr else 'Last 24 hours' }}

+
+
{{ 'Observateurs actifs' if network_fr else 'Active observers' }}
—
+
{{ 'Zones observées' if network_fr else 'Observed areas' }}
—
+
{{ 'Paquets' if network_fr else 'Packets' }}
—
+
+

{{ 'Ouvrez Beacon pour les totaux actuels.' if network_fr else 'Open Beacon for current totals.' }}

+ {{ 'Ouvrir Beacon' if network_fr else 'Open Beacon' }} ↗ +
+
diff --git a/scripts/generate-home-hero.py b/scripts/generate-home-hero.py new file mode 100644 index 00000000..e079419a --- /dev/null +++ b/scripts/generate-home-hero.py @@ -0,0 +1,61 @@ +"""Build a decorative Canada motif from the site's existing geographic data.""" + +import html +import json +from pathlib import Path + +from pyproj import Transformer +from shapely.geometry import shape +from shapely.ops import transform, unary_union + + +ROOT = Path(__file__).resolve().parents[1] +REGIONS = ROOT / "docs/assets/regions" +OUT = ROOT / "docs/assets/canada-network-motif.svg" + + +def generate(): + catalog = json.loads((REGIONS / "canada-regions.json").read_text(encoding="utf-8")) + features = json.loads((REGIONS / "canada-region-partition.geojson").read_text(encoding="utf-8"))["features"] + projection = Transformer.from_crs("EPSG:4326", "EPSG:3347", always_xy=True).transform + country = transform(projection, unary_union([shape(feature["geometry"]) for feature in features])).simplify(7000, preserve_topology=True) + west, south, east, north = country.bounds + scale = min(912 / (east - west), 384 / (north - south)) + offset = (960 - (east - west) * scale) / 2 + + def point(x, y): + return (offset + (x - west) * scale, 18 + (north - y) * scale) + + paths = [] + for polygon in getattr(country, "geoms", [country]): + if polygon.area < 800_000_000: + continue + coords = [point(x, y) for x, y in polygon.exterior.coords] + paths.append("M" + " L".join(f"{x:.1f},{y:.1f}" for x, y in coords) + " Z") + tags = ["crd", "mvrd", "whse", "cal", "edm", "nsl", "stoon", "regina", "wpg", "tby", "tor", "ott", "mtl", "capnat", "qik", "york", "hfx", "avalon"] + seeds = {seed["tag"]: point(*projection(seed["lon"], seed["lat"])) for seed in catalog["seeds"] if seed["tag"] in tags} + edges = [("crd", "mvrd"), ("mvrd", "cal"), ("mvrd", "whse"), ("whse", "nsl"), ("cal", "edm"), ("edm", "nsl"), ("edm", "stoon"), ("cal", "regina"), ("stoon", "regina"), ("regina", "wpg"), ("wpg", "tby"), ("tby", "tor"), ("tor", "ott"), ("ott", "mtl"), ("mtl", "capnat"), ("nsl", "qik"), ("qik", "capnat"), ("capnat", "york"), ("york", "hfx"), ("hfx", "avalon")] + lines = [] + for first, second in edges: + x1, y1 = seeds[first] + x2, y2 = seeds[second] + bend = min(24, abs(x2 - x1) * 0.15) + lines.append(f'') + dots = [f'' for tag in tags] + svg = '\n'.join([ + '', + 'Canada network motif', + 'Decorative connections between regional reference points, not live links or radio coverage. Canada outline derived from Statistics Canada geography; see regions/NOTICE.txt.', + '', + '', + '' + ''.join(lines) + '', + '' + ''.join(dots) + '', + '' + ''.join(dots) + '', + '', '' + ]) + OUT.write_text(svg, encoding="utf-8") + print(f"Generated {OUT.name}: {len(svg.encode('utf-8')):,} bytes") + + +if __name__ == "__main__": + generate() diff --git a/scripts/validate-communities.py b/scripts/validate-communities.py index 003b31dc..1d35c189 100644 --- a/scripts/validate-communities.py +++ b/scripts/validate-communities.py @@ -6,11 +6,13 @@ import argparse import html import json +import math import re import sys import unicodedata from collections import Counter from datetime import date +from functools import cache from pathlib import Path from typing import Any from urllib.parse import urlparse, quote @@ -503,13 +505,14 @@ def front_matter(*, title: str, description: str, task: str, metadata: dict[str, "estimated_time: 2-5 minutes", "destructive: false", "page_styles:", - " - assets/styles/communities.css?v=20260722-2", + " - assets/styles/communities.css?v=20260905-1", ] if scripts: lines.extend( [ + "status_notice: false", "page_scripts:", - " - assets/javascripts/communities.js?v=20260722-2", + " - assets/javascripts/communities.js?v=20260905-1", ] ) lines.extend(["---", ""]) @@ -618,6 +621,39 @@ def render_settings(community: dict[str, Any], *, compact: bool = False) -> str: return prefix + "; ".join(values) +@cache +def search_reference_points() -> dict[str, list[dict[str, Any]]]: + catalog = json.loads((ROOT / "docs/assets/regions/canada-regions.json").read_text(encoding="utf-8")) + references = json.loads((ROOT / "data/community-search-anchors.json").read_text(encoding="utf-8"))["communities"] + communities = json.loads(DATA_PATH.read_text(encoding="utf-8"))["communities"] + if set(references) != {community["id"] for community in communities}: + raise ValueError("Community search references must match the directory IDs") + seeds = {seed["tag"]: seed for seed in catalog["seeds"]} + result = {} + for community in communities: + location = community["location"] + if location["latitude"] is not None and location["longitude"] is not None: + points = [{"label": community["service_area"], "lat": location["latitude"], "lon": location["longitude"]}] + else: + points = [] + for reference in references[community["id"]]: + if isinstance(reference, str): + seed = seeds[reference] + points.append({"label": catalog["hierarchy"][reference]["label"], "lat": seed["lat"], "lon": seed["lon"]}) + else: + points.append({key: reference[key] for key in ("label", "lat", "lon")}) + if not points or any(not isinstance(point["label"], str) or not point["label"] or + not all(isinstance(point[key], (int, float)) and math.isfinite(point[key]) for key in ("lat", "lon")) or + not 41 <= point["lat"] <= 84 or not -142 <= point["lon"] <= -52 for point in points): + raise ValueError(f"Invalid community search reference for {community['id']}") + result[community["id"]] = points + return result + + +def search_points_attribute(community: dict[str, Any]) -> str: + return html.escape(json.dumps(search_reference_points()[community["id"]], ensure_ascii=False, separators=(",", ":")), quote=True) + + def render_directory_card(community: dict[str, Any], page: dict[str, Any]) -> str: search = html.escape(search_text(community, page), quote=True) has_override = str(bool(community["settings"]["overrides"])).lower() @@ -625,7 +661,8 @@ def render_directory_card(community: dict[str, Any], page: dict[str, Any]) -> st ( f'
' + f'data-community-override="{has_override}" data-community-search="{search}" ' + f'data-community-points="{search_points_attribute(community)}">' ), '
', ( @@ -638,6 +675,7 @@ def render_directory_card(community: dict[str, Any], page: dict[str, Any]) -> st ), "
", f'

{html.escape(community["service_area"])}

', + '', ] if community.get("summary"): lines.append(f'

{html.escape(community["summary"])}

') @@ -670,7 +708,6 @@ def render_index(data: dict[str, Any]) -> str: active = sum(item["status"] == "active" for item in communities) forming = sum(item["status"] == "forming" for item in communities) overrides = sum(bool(item["settings"]["overrides"]) for item in communities) - unverified = sum(item["verified_at"] is None for item in communities) lines = [ front_matter( @@ -685,12 +722,11 @@ def render_index(data: dict[str, Any]) -> str: "", "# Find a MeshCore community", "", - "Search by place, province, community name, or a common alias. The full list", - "works without a map, location permission, or a GitHub account.", + "Type a community or province to filter the list, or look up your city to find the closest communities.", + "", "", '!!! note "Community information can change"', - f" {unverified} of {len(communities)} listings do not have a recent contact check.", - " Confirm important settings and contacts before relying on them.", + " This information could be out of date!", "", '
', f"{len(communities)} listings", @@ -699,12 +735,12 @@ def render_index(data: dict[str, Any]) -> str: f"{overrides} with different local settings", "
", "", - '
', + '
', ' ", '
', @@ -719,15 +755,20 @@ def render_index(data: dict[str, Any]) -> str: ' ', " Has a local settings override", " ", - ' ', + '
', + '
', ' ', f" Showing {len(communities)} communities", " ", - "
", + '
', + ' ', + ' ', + ' ', + "
", "", '", @@ -1194,7 +1235,8 @@ def render_directory_card_fr( ( f'
' + f'data-community-override="{has_override}" data-community-search="{search}" ' + f'data-community-points="{search_points_attribute(community)}">' ), '
', ( @@ -1241,16 +1283,6 @@ def render_index_fr(data: dict[str, Any], french: dict[str, Any]) -> str: active = sum(item["status"] == "active" for item in communities) forming = sum(item["status"] == "forming" for item in communities) overrides = sum(bool(item["settings"]["overrides"]) for item in communities) - unverified = sum(item["verified_at"] is None for item in communities) - if unverified == 1: - verification_summary = ( - f"1 fiche sur {len(communities)} n’a pas fait l’objet d’une vérification récente des coordonnées." - ) - else: - verification_summary = ( - f"{unverified} fiches sur {len(communities)} n’ont pas fait l’objet d’une vérification récente des coordonnées." - ) - lines = [ front_matter( title="Trouver une communauté MeshCore au Canada", @@ -1270,12 +1302,11 @@ def render_index_fr(data: dict[str, Any], french: dict[str, Any]) -> str: "", "# Trouver une communauté MeshCore au Canada", "", - "Recherchez par lieu, province, nom de communauté ou alias courant. La liste", - "complète fonctionne sans carte, autorisation de localisation ni compte GitHub.", + "Filtrez la liste par communauté ou province, ou cherchez votre ville pour trouver les communautés les plus proches.", + "", "", '!!! note "Les renseignements sur les communautés peuvent changer"', - f" {verification_summary}", - " Confirmez les réglages et les coordonnées importants avant de vous y fier.", + " Ces renseignements pourraient être périmés !", "", '
', f"{len(communities)} {'fiche' if len(communities) == 1 else 'fiches'}", @@ -1287,12 +1318,12 @@ def render_index_fr(data: dict[str, Any], french: dict[str, Any]) -> str: ), "
", "", - '
', + '
', ' ", '
', @@ -1307,15 +1338,20 @@ def render_index_fr(data: dict[str, Any], french: dict[str, Any]) -> str: ' ', " Possède des réglages locaux différents", " ", - ' ', + '
', + '
', ' ', f" {len(communities)} communautés affichées", " ", - "
", + '
', + ' ', + ' ', + ' ', + "
", "", '", diff --git a/tests/browser/accessibility.spec.mjs b/tests/browser/accessibility.spec.mjs index 0288f858..2a96bd1e 100644 --- a/tests/browser/accessibility.spec.mjs +++ b/tests/browser/accessibility.spec.mjs @@ -1,5 +1,5 @@ import AxeBuilder from "@axe-core/playwright"; -import { expect, test } from "@playwright/test"; +import { expect, test } from "./site-fixtures.mjs"; import { siteRoute } from "./site-route.mjs"; const routes = [ diff --git a/tests/browser/all-pages-ui.spec.mjs b/tests/browser/all-pages-ui.spec.mjs index b817652b..edf58fa7 100644 --- a/tests/browser/all-pages-ui.spec.mjs +++ b/tests/browser/all-pages-ui.spec.mjs @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./site-fixtures.mjs"; import { readdirSync } from "node:fs"; import { dirname, join, relative, sep } from "node:path"; import { fileURLToPath } from "node:url"; diff --git a/tests/browser/audit-regressions.spec.mjs b/tests/browser/audit-regressions.spec.mjs index c50e75fe..337c4cd3 100644 --- a/tests/browser/audit-regressions.spec.mjs +++ b/tests/browser/audit-regressions.spec.mjs @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./site-fixtures.mjs"; import AxeBuilder from "@axe-core/playwright"; import { siteRoute } from "./site-route.mjs"; import { submissionSha256 } from "../../docs/config/editor/issue.js"; @@ -83,9 +83,17 @@ for (const locale of ["", "fr/"]) { for (const scheme of ["default", "slate"]) { test(`${locale || "en/"} ${scheme} focused map controls remain readable`, async ({ page }) => { + await page.route("https://tile.openstreetmap.org/**", (route) => route.fulfill({ + contentType: "image/png", + body: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64") + })); await page.goto(siteRoute(`/${locale}config/map/?tag=ott`)); await expect(page.locator('[data-role="map-text-result"]')).toContainText("Ottawa"); await page.locator(`input[data-md-color-scheme="${scheme}"]`).evaluate((element) => element.click()); + await page.locator(".mcc-map-stage").scrollIntoViewIfNeeded(); + await expect(page.locator(".mcc-map-stage")).toHaveAttribute("aria-busy", "false"); + const mapContrast = await new AxeBuilder({ page }).include(".mcc-map-stage").withRules(["color-contrast"]).analyze(); + expect(mapContrast.violations).toEqual([]); const find = page.locator('[data-action="map-locate"]'); await find.focus(); await find.hover(); diff --git a/tests/browser/critical-journeys.spec.mjs b/tests/browser/critical-journeys.spec.mjs index 17f1b834..03137f17 100644 --- a/tests/browser/critical-journeys.spec.mjs +++ b/tests/browser/critical-journeys.spec.mjs @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./site-fixtures.mjs"; import { normalizeSiteBaseUrl, resolveSiteRoute, siteRoute } from "./site-route.mjs"; const criticalRoutes = [ diff --git a/tests/browser/repeater-hash-check.spec.mjs b/tests/browser/repeater-hash-check.spec.mjs index 11c0c333..cbe59ab9 100644 --- a/tests/browser/repeater-hash-check.spec.mjs +++ b/tests/browser/repeater-hash-check.spec.mjs @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./site-fixtures.mjs"; import { siteRoute } from "./site-route.mjs"; const fullKey = `05DE00${"11".repeat(29)}`; @@ -147,7 +147,10 @@ test("the repeater checker mounts without a disruptive layout shift", async ({ p test("the general region configurator does not show the optional ID checker", async ({ page }) => { const beaconRequests = []; page.on("request", (request) => { - if (request.url().startsWith("https://dev.meshcore.ca/")) beaconRequests.push(request.url()); + const url = request.url(); + if (url.startsWith("https://dev.meshcore.ca/") && !url.startsWith("https://dev.meshcore.ca/api/v1/stats/")) { + beaconRequests.push(url); + } }); await page.goto(siteRoute("/config/?place=Ottawa"), { waitUntil: "domcontentloaded" }); await expect(page.locator("[data-mcc-regions='config'] [data-role='status']")).toContainText("Region found."); diff --git a/tests/browser/site-feedback.spec.mjs b/tests/browser/site-feedback.spec.mjs new file mode 100644 index 00000000..2a48495e --- /dev/null +++ b/tests/browser/site-feedback.spec.mjs @@ -0,0 +1,120 @@ +import { expect, test } from "./site-fixtures.mjs"; +import AxeBuilder from "@axe-core/playwright"; +import { siteRoute } from "./site-route.mjs"; + +const overview = { activeObservers: 12, activeIatas: 4, totalPackets: 900, windowHours: 24 }; +const types = [{ nodeType: 2, count: 50 }, { nodeType: 1, count: 20 }, { nodeType: 3, count: 3 }, { nodeType: 4, count: 2 }]; + +async function mockNetwork(page, requests = []) { + await page.route("https://dev.meshcore.ca/api/v1/stats/**", route => { + requests.push(route.request().url()); + return route.fulfill({ json: route.request().url().endsWith("overview") ? overview : types }); + }); +} + +for (const locale of ["", "fr/"]) { + test(`${locale || "en/"} Cambridge finds nearby communities without a matching listing`, async ({ page }) => { + await mockNetwork(page); + const lookups = []; + await page.route("https://geolocator.api.geo.ca/**", route => { + lookups.push(route.request().url()); + return route.fulfill({ json: [ + { key: "geonames", name: "Cambridge Bay", province: "Nunavut", category: "Hamlet", lat: 69.113889, lng: -105.05278 }, + { key: "geonames", name: "Cambridge", province: "Ontario", category: locale ? "Ville" : "City", lat: 43.397222, lng: -80.311389 } + ] }); + }); + await page.goto(siteRoute(`/${locale}provinces/`)); + await expect(page.locator(".mc-page-status")).toHaveCount(0); + await expect(page.locator(".admonition.note")).toContainText(locale ? "Ces renseignements pourraient être périmés" : "This information could be out of date!"); + await page.locator("#community-search").fill("Cambridge"); + expect(lookups).toHaveLength(0); + await page.locator("#community-search").press("Enter"); + await expect(page.locator("[data-community-count]")).toContainText("5"); + await expect(page.locator("[data-community-count]")).toContainText("Cambridge, Ontario"); + const cards = page.locator("[data-community-card]:visible"); + await expect(cards).toHaveCount(5); + await expect(cards.first()).toHaveAttribute("id", "directory-gta-lora-meshes"); + await expect(cards.first().locator("[data-community-distance]")).toContainText("km"); + const query = new URL(lookups[0]); + expect(query.searchParams.get("keys")).toBe("geonames"); + expect(query.searchParams.get("lang")).toBe(locale ? "fr" : "en"); + await page.locator("[data-community-show-all]").click(); + await expect(cards).toHaveCount(24); + await page.locator("[data-community-clear]").first().click(); + await expect(page.locator("#community-search")).toHaveValue(""); + await expect(cards).toHaveCount(24); + await expect(cards.first()).toHaveAttribute("id", "directory-bc-mesh"); + expect(lookups).toHaveLength(1); + }); + + test(`${locale || "en/"} city lookup failure keeps ordinary directory filters usable`, async ({ page }) => { + await mockNetwork(page); + await page.route("https://geolocator.api.geo.ca/**", route => route.abort()); + await page.goto(siteRoute(`/${locale}provinces/`)); + await page.locator("#community-search").fill("Cambridge"); + await page.locator("[data-community-locate]").click(); + await expect(page.locator("[data-community-lookup]")).toContainText(locale ? "indisponible" : "unavailable"); + await page.locator("[data-community-clear]").first().click(); + await page.locator("#community-search").fill("YQL"); + await expect(page.locator("[data-community-card]:visible")).toHaveCount(1); + await expect(page.locator("[data-community-card]:visible")).toContainText("YQLMesh"); + }); + + test(`${locale || "en/"} homepage motif and first-step browsing links remain accessible`, async ({ page }) => { + await mockNetwork(page); + await page.goto(siteRoute(`/${locale}`)); + const hero = page.locator(".mc-home-hero"); + await expect(hero).toBeVisible(); + await expect.poll(() => hero.locator("img").evaluate(image => image.naturalWidth)).toBeGreaterThan(0); + expect((await hero.boundingBox()).height).toBeLessThan(300); + await expect(page.locator(".mc-preset-note")).toContainText(locale ? "3 octets" : "3-byte"); + await page.goto(siteRoute(`/${locale}hardware/`)); + await expect(page.locator(".mc-guide-status")).toHaveCount(0); + await page.goto(siteRoute(`/${locale}config/`)); + const step = page.locator('[data-wizard-step="1"]'); + const map = step.locator('[data-action="view-map"]'); + const editor = step.locator('a[href*="/config/editor/"]'); + await expect(map).toBeVisible(); + await expect(editor).toBeVisible(); + await expect(map).toHaveAttribute("href", new RegExp(`/${locale}config/map/$`)); + await expect(editor).toHaveAttribute("href", new RegExp(`/${locale}config/editor/$`)); + }); + + test(`${locale || "en/"} compact header totals are cached, readable, and keyboard accessible`, async ({ page }) => { + const requests = []; + await mockNetwork(page, requests); + await page.goto(siteRoute(`/${locale}`)); + const network = page.locator("[data-network-summary]"); + await expect(network).toHaveAttribute("data-network-state", "ready"); + await expect(network.locator("[data-network-total]")).toHaveText("75"); + await network.locator("summary").focus(); + await network.locator("summary").press("Enter"); + await expect(network).toHaveAttribute("open", ""); + await expect(network.locator('[data-network-count="repeaters"]')).toHaveText("50"); + await expect(network.locator('[data-network-count="observers"]')).toHaveText("12"); + for (const scheme of ["default", "slate"]) { + await page.locator(`input[data-md-color-scheme="${scheme}"]`).evaluate(element => element.click()); + const result = await new AxeBuilder({ page }).include("[data-network-summary]").withRules(["color-contrast"]).analyze(); + expect(result.violations).toEqual([]); + } + await network.locator("summary").focus(); + await network.locator("summary").press("Escape"); + await expect(network).not.toHaveAttribute("open", ""); + await page.goto(siteRoute(`/${locale}hardware/`)); + await expect(page.locator("[data-network-summary]")).toHaveAttribute("data-network-state", "ready"); + expect(requests).toHaveLength(2); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth); + expect(overflow).toBeFalsy(); + }); +} + +test("unavailable header statistics never appear as zero active devices", async ({ page }) => { + await page.route("https://dev.meshcore.ca/api/v1/stats/**", route => route.fulfill({ status: 503, body: "Unavailable" })); + await page.goto(siteRoute("/")); + const network = page.locator("[data-network-summary]"); + await expect(network).toHaveAttribute("data-network-state", "unavailable"); + await network.locator("summary").click(); + await expect(network.locator("[data-network-total]")).toHaveText("—"); + await expect(network.locator('[data-network-count="repeaters"]')).toHaveText("—"); + await expect(network.locator('a[href="https://dev.meshcore.ca/"]')).toBeVisible(); +}); diff --git a/tests/browser/site-fixtures.mjs b/tests/browser/site-fixtures.mjs new file mode 100644 index 00000000..b6959f31 --- /dev/null +++ b/tests/browser/site-fixtures.mjs @@ -0,0 +1,15 @@ +import { test as base } from "@playwright/test"; +export { expect } from "@playwright/test"; + +// Keep routine browser tests off the public aggregate API. Individual tests +// can override this route to exercise errors, payloads, and cache behavior. +export const test = base.extend({ + page: async ({ page }, use) => { + await page.route("https://dev.meshcore.ca/api/v1/stats/**", route => route.fulfill({ + json: route.request().url().endsWith("overview") + ? { activeObservers: 12, activeIatas: 4, totalPackets: 900, windowHours: 24 } + : [{ nodeType: 2, count: 50 }, { nodeType: 1, count: 20 }, { nodeType: 3, count: 3 }, { nodeType: 4, count: 2 }] + })); + await use(page); + } +}); diff --git a/tests/content/browser-feedback.test.mjs b/tests/content/browser-feedback.test.mjs new file mode 100644 index 00000000..24ab4bdb --- /dev/null +++ b/tests/content/browser-feedback.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import test from "node:test"; + +function api(path, name) { + const context = vm.createContext({}); + vm.runInContext(readFileSync(path, "utf8"), context); + return context[name]; +} +const community = api("docs/assets/javascripts/communities.js", "MeshCoreCommunitySearch"); +const network = api("docs/assets/javascripts/network-status.js", "MeshCoreNetworkStatus"); + +test("city lookup prefers Cambridge Ontario to Cambridge Bay and rejects non-Canadian coordinates", () => { + const rows = [ + { key: "geonames", name: "Cambridge Bay", province: "Nunavut", category: "Hamlet", lat: 69.113889, lng: -105.05278 }, + { key: "geonames", name: "Cambridge", province: "Ontario", category: "City", lat: 43.397222, lng: -80.311389 }, + { key: "geonames", name: "Cambridge", province: "Nova Scotia", category: "Community", lat: 45.20094, lng: -64.092892 }, + { key: "geonames", name: "Cambridge", province: "Ontario", category: "City", lat: 99, lng: -80 } + ]; + assert.equal(community.placeCandidates(rows, "Cambridge")[0].province, "Ontario"); + assert.equal(community.placeCandidates(rows, "Cambridge,NS")[0].province, "Nova Scotia"); + assert.equal(community.placeCandidates(rows, "Cambridge, BC").length, 0); + assert.equal(community.placeCandidates(rows, "Cambridge").length, 1); +}); + +test("Canadian city names accept French accents and province suffixes", () => { + assert.equal(community.splitPlaceQuery("Québec, Québec").name, "Québec"); + assert.equal(community.splitPlaceQuery("Québec, Québec").province, "QC"); + assert.equal(community.splitPlaceQuery("Cambridge ON").name, "Cambridge"); + const rows = [{ key: "geonames", name: "Montr\\u00e9al", province: "Qu\\u00e9bec", category: "Ville", lat: 45.5, lng: -73.57 }]; + assert.equal(community.placeCandidates(rows, "Montréal, QC")[0].name, "Montréal"); +}); + +test("community distance is symmetric, bounded, and ranks Toronto closer than Ottawa to Cambridge", () => { + const city = { lat: 43.397222, lon: -80.311389 }; + const toronto = { lat: 43.6532, lon: -79.3832 }; + const ottawa = { lat: 45.4215, lon: -75.6972 }; + assert.equal(community.distanceKm(city, city), 0); + assert.equal(community.distanceKm(city, toronto), community.distanceKm(toronto, city)); + assert.ok(community.distanceKm(city, toronto) < community.distanceKm(city, ottawa)); + assert.ok(Number.isFinite(community.distanceKm({ lat: 0, lon: 0 }, { lat: 0, lon: 180 }))); +}); + +test("Beacon totals distinguish known devices from the overview time window", () => { + const overview = { activeObservers: 12, activeIatas: 4, totalPackets: 900, windowHours: 24 }; + const types = [{ nodeType: 2, count: 50 }, { nodeType: 1, count: 20 }, { nodeType: 3, count: 3 }, { nodeType: 4, count: 2 }, { nodeType: 0, count: 1 }]; + const stats = network.parseStats(overview, types); + assert.equal(stats.total, 76); + assert.equal(stats.repeaters, 50); + assert.equal(stats.other, 1); + assert.equal(stats.observers, 12); + assert.equal(stats.hours, 24); + for (const bad of [-1, null, "900", Infinity]) assert.throws(() => network.parseStats({ ...overview, totalPackets: bad }, types)); + assert.throws(() => network.parseStats(overview, [types[0], types[0]])); + assert.throws(() => network.parseStats({ ...overview, windowHours: 0 }, types)); +}); + +test("all 24 listings have finite geographic search references without changing their source locations", () => { + const directory = JSON.parse(readFileSync("data/communities.json", "utf8")); + const anchors = JSON.parse(readFileSync("data/community-search-anchors.json", "utf8")); + assert.deepEqual(Object.keys(anchors.communities).sort(), directory.communities.map(item => item.id).sort()); + for (const locale of ["index.md", "index.fr.md"]) { + const page = readFileSync(`docs/provinces/${locale}`, "utf8"); + assert.match(page, /status_notice: false/); + const points = [...page.matchAll(/data-community-points="([^"]+)"/g)]; + assert.equal(points.length, 24); + for (const [, encoded] of points) { + const list = JSON.parse(encoded.replaceAll(""", '"').replaceAll("'", "'").replaceAll("&", "&")); + assert.ok(list.length > 0); + assert.ok(list.every(point => Number.isFinite(point.lat) && Number.isFinite(point.lon))); + } + } +}); diff --git a/tests/content/community-directory.test.mjs b/tests/content/community-directory.test.mjs index 67404a13..620e9b98 100644 --- a/tests/content/community-directory.test.mjs +++ b/tests/content/community-directory.test.mjs @@ -232,7 +232,7 @@ test("search covers reviewed place names and common aliases", () => { test("the list remains complete without JavaScript or a map", () => { const index = readFileSync(join(provinceDir, "index.md"), "utf8"); - assert.match(index, /works without a map, location permission, or a GitHub account/); + assert.match(index, /