diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 08c5ca9..f04a57a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,5 @@ {"id":"tilfluktsrom-7zc","title":"Badge-kontrast under WCAG 2.2 AA — bytt shelter_primary til warning_bg","description":"Mirror av Forgejo-issue #18.\n\nBadge introdusert i 1fb9f14 (#13) bruker hvit fet 11sp tekst på shelter_primary (#FF6B35). Målt kontrast: ~2.84:1.\n\nWCAG 2.2 AA SC 1.4.3:\n- Normaltekst: ≥4.5:1\n- Large text (≥14pt fet eller ≥18pt regular): ≥3.0:1\n\n11sp fet teller som normaltekst → feiler AA-terskelen, og marginalt også 3:1 for large text.\n\nFiks: én linje i app/src/main/res/layout/item_shelter.xml — bytt @color/shelter_primary til @color/warning_bg (#BF360C, dokumentert ~5.5:1 vs hvit i colors.xml-kommentar).\n\nAlternativer som ikke fungerer:\n- shelter_primary_dark (#E55A2B) ~3.6:1 — fortsatt under AA normaltekst\n- Svart tekst på orange — ~7:1, men bryter visuell konsistens\n\nRelevant for offentlig-sektor-godkjenning (Uutilsynet/WAD/EN 301 549).\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/18","acceptance_criteria":"Badge-bakgrunnen på item_shelter.xml outsideNearestBadge er endret til warning_bg. Visuelt verifisert at hvit fet 11sp tekst på den nye bakgrunnen gir ≥4.5:1 målt kontrast (med en kontrast-sjekker eller WCAG-verktøy).","status":"closed","priority":2,"issue_type":"bug","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-29T14:54:00Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T14:55:09Z","started_at":"2026-04-29T14:54:56Z","closed_at":"2026-04-29T14:55:09Z","close_reason":"Bytta @color/shelter_primary til @color/warning_bg på outsideNearestBadge i item_shelter.xml. Kontrast: ~2.84:1 → ~5.6:1 (over WCAG 2.2 AA SC 1.4.3 4.5:1-terskel). Visuell verifisering på enhet/emulator gjenstår.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"tilfluktsrom-efo","title":"PWA: dyplenket tilfluktsrom utenfor topp-N vises som om det er nærmest","description":"Mirror av Forgejo-issue #17.\n\nParallell til Android-fiksen i 1fb9f14 (#13).\n\nNåværende oppførsel: når dyplenken peker på et tilfluktsrom utenfor de N nærmeste, gjør pwa/src/app.ts:266-285 unshift() inn på indeks 0 og setter selectedShelterIndex=0. Resultat: det dyplenkede vises *som om* det er det nærmeste — uten badge, uten separator, uten forklaring. Lista bryter sin egen sort-på-avstand-invariant.\n\nHybrid-fiks (parallell til Android):\n1. Append (push) i stedet for unshift på app.ts:274\n2. Badge i shelter-list.ts på den appendede raden — bruk samme nøkkel shelter_outside_nearest_badge i pwa/src/i18n/{en,nb,nn}.ts\n3. updateList() får outsideNearestRomnr-parameter (eller wrap-objekt parallelt med Android sin ShelterListItem)\n4. Badge-teksten suffikses i aria-label (skjermleser-paritet med Android)\n5. selectedItem.scrollIntoView({block:'nearest', behavior:'smooth'}) etter updateList\n6. Tilgjengelighet: badge ≥4.5:1 kontrast (WCAG 2.2 AA SC 1.4.3). #FF6B35+hvit tekst er ~3.5:1 — *under terskel*. Bruk shelter_primary_dark #E55A2B som badge-bakgrunn, eller svart tekst på orange (~7:1)\n\nUt av skopet: endring av selve dyplenke-formatet (fortsatt /shelter/{romnr}), klyngevisning (#10).\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/17","acceptance_criteria":"Dyplenket tilfluktsrom utenfor topp-N appendes på siste rad i lista (ikke unshift). Den appendede raden har et synlig 'Valgt – utenfor nærområdet'-badge med ≥4.5:1 kontrast. Lista scroller automatisk til valgt rad. Skjermleser leser badge-teksten som del av aria-label. Verifisert manuelt med en deep-link til et tilfluktsrom langt fra brukerens posisjon.","status":"open","priority":2,"issue_type":"bug","owner":"olemd@glemt.net","created_at":"2026-04-29T14:52:08Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T14:52:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"tilfluktsrom-efo","title":"PWA: dyplenket tilfluktsrom utenfor topp-N vises som om det er nærmest","description":"Mirror av Forgejo-issue #17.\n\nParallell til Android-fiksen i 1fb9f14 (#13).\n\nNåværende oppførsel: når dyplenken peker på et tilfluktsrom utenfor de N nærmeste, gjør pwa/src/app.ts:266-285 unshift() inn på indeks 0 og setter selectedShelterIndex=0. Resultat: det dyplenkede vises *som om* det er det nærmeste — uten badge, uten separator, uten forklaring. Lista bryter sin egen sort-på-avstand-invariant.\n\nHybrid-fiks (parallell til Android):\n1. Append (push) i stedet for unshift på app.ts:274\n2. Badge i shelter-list.ts på den appendede raden — bruk samme nøkkel shelter_outside_nearest_badge i pwa/src/i18n/{en,nb,nn}.ts\n3. updateList() får outsideNearestRomnr-parameter (eller wrap-objekt parallelt med Android sin ShelterListItem)\n4. Badge-teksten suffikses i aria-label (skjermleser-paritet med Android)\n5. selectedItem.scrollIntoView({block:'nearest', behavior:'smooth'}) etter updateList\n6. Tilgjengelighet: badge ≥4.5:1 kontrast (WCAG 2.2 AA SC 1.4.3). #FF6B35+hvit tekst er ~3.5:1 — *under terskel*. Bruk shelter_primary_dark #E55A2B som badge-bakgrunn, eller svart tekst på orange (~7:1)\n\nUt av skopet: endring av selve dyplenke-formatet (fortsatt /shelter/{romnr}), klyngevisning (#10).\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/17","acceptance_criteria":"Dyplenket tilfluktsrom utenfor topp-N appendes på siste rad i lista (ikke unshift). Den appendede raden har et synlig 'Valgt – utenfor nærområdet'-badge med ≥4.5:1 kontrast. Lista scroller automatisk til valgt rad. Skjermleser leser badge-teksten som del av aria-label. Verifisert manuelt med en deep-link til et tilfluktsrom langt fra brukerens posisjon.","status":"closed","priority":2,"issue_type":"bug","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-29T14:52:08Z","created_by":"Ole-Morten Duesund","updated_at":"2026-08-17T10:48:32Z","started_at":"2026-08-17T10:42:26Z","closed_at":"2026-08-17T10:48:32Z","close_reason":"Fikset: push i stedet for unshift, badge (#BF360C, ~5.6:1) + aria-prefiks + scrollIntoView i shelter-list.ts. Verifisert i Chromium via Playwright med dyplenke /shelter/1681 fra Oslo-posisjon.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"tilfluktsrom-5vc","title":"Brukerens retningspil i kartet er hvit og forsvinner på lyse underlag","description":"Mirror av Forgejo-issue #16.\n\nNår brukeren beveger seg raskt nok til at OSMDroid bytter fra person- til retningspil-ikonet i MyLocationNewOverlay, blir pilen tilnærmet usynlig: heltrukken hvit uten kontur eller skygge. På lyse tiles (snø, sand, brede veifyll, lyse OSM-temaer) forsvinner den helt.\n\nÅrsak: MainActivity.kt:190 instansierer MyLocationNewOverlay uten å overstyre setDirectionIcon()/setPersonIcon() — OSMDroid bruker da stock hvite bitmaps.\n\nForslag:\n- Egen vector i res/drawable/ic_user_direction.xml med kontrastfarget fyll + motsatt-farge kontur\n- Kall myLocationOverlay.setDirectionIcon(...) + setPersonIcon(...) eksplisitt\n- Vurder halo-ring (blå pil i hvit ring, Google Maps-stil) for variert underlag\n- Sjekk PWA-en (Leaflet) for samme problem\n\nTilgjengelighet:\n- ≥3:1 kontrast mot både lyse og mørke tiles (WCAG 2.2 AA, ikke-tekstlig innhold)\n- Ikke avhengig av farge alene; konturen sikrer at formen faktisk synes\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/16","acceptance_criteria":"Pilen og person-ikonet i MyLocationNewOverlay er tydelig synlig mot både lyse og mørke kartunderlag (≥3:1 kontrast). Verifisert manuelt på minst ett lyst og ett mørkt kartområde, både stillestående (person) og i bevegelse (pil).","status":"closed","priority":2,"issue_type":"bug","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-29T14:06:57Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T14:15:13Z","started_at":"2026-04-29T14:09:43Z","closed_at":"2026-04-29T14:15:13Z","close_reason":"Fikset i d2291a2 — egne ikoner i res/drawable/ic_user_dot.xml og ic_user_arrow.xml, satt på MyLocationNewOverlay med setPersonIcon/setDirectionIcon + (0.5, 0.5)-anker. Visuell verifisering på enhet/emulator gjenstår.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"tilfluktsrom-5s7","title":"PWA: manuell testing mangler","description":"Mirror av Forgejo-issue #1.\n\nPWA-versjonen (pwa/) er skrevet, men ikke manuelt testet i nettleser. Enhetstestene passerer, men appen må verifiseres i praksis:\n\n- Start utviklingsserver og test i Chrome/Firefox\n- Test offline-modus (service worker)\n- Test kompass (iOS Safari + Android Chrome)\n- Test installasjon via «Legg til på startskjerm»\n- Test kartbufring og offline kartvisning\n- Test på fysisk iPhone (iOS-spesifikk kompasshåndtering)\n- Test i18n (norsk bokmål, nynorsk, engelsk)\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/1","status":"closed","priority":2,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-29T13:57:04Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T14:02:57Z","closed_at":"2026-04-29T14:02:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"tilfluktsrom-nt8","title":"Åpne i kartapp for gangvei til tilfluktsrom","description":"Mirror av Forgejo-issue #2.\n\nLegg til knapp (på bunnark og/eller kompassvisning) som åpner gangveibeskrivelse til valgt tilfluktsrom i ekstern kartapp.\n\nImplementasjon:\n- ACTION_VIEW intent med geo: URI: geo:lat,lon?q=lat,lon(Tilfluktsrom - adresse)\n- geo: håndteres av tilgjengelig kartapp (OsmAnd, Organic Maps, Google Maps, ...)\n- OsmAnd og Organic Maps støtter offline-navigasjon med geo: — ideelt for degradert nett\n- IKKE hardkode Google Maps-URL-er — bruk geo:\n- Faller pent tilbake hvis ingen kartapp er installert (Toast med koordinater å kopiere)\n- Knapp ved siden av tilfluktsrom-adresse i bunnarket\n\nI en akuttsituasjon er det å finne tilfluktsrommet på kartet bare halve problemet — du må vite gangveien dit. geo:-intent fungerer med offline-kapable kartapper, kritisk når nettet er nede.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/2","status":"open","priority":2,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:57:03Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:57:03Z","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/pwa/src/app.ts b/pwa/src/app.ts index d5fcabb..cc31fd6 100644 --- a/pwa/src/app.ts +++ b/pwa/src/app.ts @@ -28,6 +28,11 @@ const NEAREST_COUNT = 3; let allShelters: Shelter[] = []; let nearestShelters: ShelterWithDistance[] = []; let selectedShelterIndex = 0; +// romnr of a user-selected shelter that has been appended to the list because +// it is outside the geographic top-N (deep link, marker tap). null when the +// selection is a genuine top-N entry. Drives the "outside nearest" badge in +// the list — mirrors ShelterListItem.isOutsideNearest in the Android app. +let outsideNearestRomnr: number | null = null; let currentLocation: LatLon | null = null; let deviceHeading = 0; let isCompassMode = false; @@ -248,6 +253,15 @@ function startLocationUpdates(): void { ); } +/** Re-render the bottom-sheet list from current module state. */ +function renderShelterList(): void { + shelterList.updateList( + nearestShelters, + selectedShelterIndex, + outsideNearestRomnr, + ); +} + function updateNearestShelters(location: LatLon): void { if (allShelters.length === 0) return; @@ -268,10 +282,14 @@ function updateNearestShelters(location: LatLon): void { ); if (inList >= 0) { selectedShelterIndex = inList; + outsideNearestRomnr = null; } else { const shelter = allShelters.find((s) => s.romnr === selectedRomnr); if (shelter) { - nearestShelters.unshift({ + // Append (not prepend) so the list keeps its sorted-by-distance + // invariant; the badge explains why this row is here. Same hybrid + // approach as MainActivity.rebuildShelterList() on Android. + nearestShelters.push({ shelter, distanceMeters: distanceMeters( location.latitude, location.longitude, @@ -282,20 +300,23 @@ function updateNearestShelters(location: LatLon): void { shelter.latitude, shelter.longitude, ), }); - selectedShelterIndex = 0; + selectedShelterIndex = nearestShelters.length - 1; + outsideNearestRomnr = shelter.romnr; } else { // Selected shelter no longer exists in the dataset (e.g. DSB // decommissioned it). Fall back to nearest. selectedRomnr = null; userSelectedShelter = false; selectedShelterIndex = 0; + outsideNearestRomnr = null; } } } else { selectedShelterIndex = 0; + outsideNearestRomnr = null; } - shelterList.updateList(nearestShelters, selectedShelterIndex); + renderShelterList(); updateSelectedShelter(false); statusBar.setStatus(t('status_shelters_loaded', allShelters.length)); @@ -337,7 +358,7 @@ function updateSelectedShelter(isUserAction: boolean): void { compassView.setNorthAngle(-deviceHeading); // Update shelter list selection - shelterList.updateList(nearestShelters, selectedShelterIndex); + renderShelterList(); // Update map: highlight selected and optionally fit view if (isUserAction) { @@ -503,13 +524,15 @@ function selectShelterByData(shelter: Shelter): void { ) : NaN; - nearestShelters.unshift({ + // Append rather than prepend — see updateNearestShelters(). + nearestShelters.push({ shelter, distanceMeters: dist, bearingDegrees: bearing, }); - selectedShelterIndex = 0; - shelterList.updateList(nearestShelters, selectedShelterIndex); + selectedShelterIndex = nearestShelters.length - 1; + outsideNearestRomnr = shelter.romnr; + renderShelterList(); } updateSelectedShelter(true); diff --git a/pwa/src/i18n/en.ts b/pwa/src/i18n/en.ts index dc7bdfd..0544850 100644 --- a/pwa/src/i18n/en.ts +++ b/pwa/src/i18n/en.ts @@ -21,6 +21,7 @@ export const en: Record = { // Shelter info shelter_capacity: '%d places', shelter_room_nr: 'Room %d', + shelter_outside_nearest_badge: 'Selected (outside nearest)', nearest_shelter: 'Nearest shelter', no_shelters: 'No shelter data available', diff --git a/pwa/src/i18n/nb.ts b/pwa/src/i18n/nb.ts index d077162..747c7a7 100644 --- a/pwa/src/i18n/nb.ts +++ b/pwa/src/i18n/nb.ts @@ -18,6 +18,7 @@ export const nb: Record = { shelter_capacity: '%d plasser', shelter_room_nr: 'Rom %d', + shelter_outside_nearest_badge: 'Valgt – utenfor nærområdet', nearest_shelter: 'Nærmeste tilfluktsrom', no_shelters: 'Ingen tilfluktsromdata tilgjengelig', diff --git a/pwa/src/i18n/nn.ts b/pwa/src/i18n/nn.ts index 95723de..93d6836 100644 --- a/pwa/src/i18n/nn.ts +++ b/pwa/src/i18n/nn.ts @@ -18,6 +18,7 @@ export const nn: Record = { shelter_capacity: '%d plassar', shelter_room_nr: 'Rom %d', + shelter_outside_nearest_badge: 'Vald – utanfor nærområdet', nearest_shelter: 'Næraste tilfluktsrom', no_shelters: 'Ingen tilfluktsromdata tilgjengeleg', diff --git a/pwa/src/styles/main.css b/pwa/src/styles/main.css index f045d71..990ff76 100644 --- a/pwa/src/styles/main.css +++ b/pwa/src/styles/main.css @@ -355,6 +355,24 @@ html, body { margin-top: 2px; } +/* + * "Selected (outside nearest)" badge on a row appended below the top-N. + * #BF360C = warning_bg in the Android colors.xml: ~5.6:1 against white, + * clearing WCAG 2.2 AA SC 1.4.3 (4.5:1) for this small bold text. The plain + * shelter orange (#FF6B35) only reaches ~2.8:1 and must not be used here. + */ +.shelter-item-badge { + align-self: flex-start; + background: #BF360C; + color: #FFFFFF; + font-size: 11px; + font-weight: 700; + line-height: 1.4; + padding: 1px 8px; + border-radius: 4px; + margin-bottom: 4px; +} + /* --- Loading overlay --- */ #loading-overlay { display: none; diff --git a/pwa/src/ui/shelter-list.ts b/pwa/src/ui/shelter-list.ts index ce9f0cf..733e971 100644 --- a/pwa/src/ui/shelter-list.ts +++ b/pwa/src/ui/shelter-list.ts @@ -20,10 +20,19 @@ export function initShelterList( onSelect = onShelterSelect; } -/** Render the list of nearest shelters using safe DOM methods. */ +/** + * Render the list of nearest shelters using safe DOM methods. + * + * @param outsideNearestRomnr romnr of a row that was appended because the + * user selected it (deep link / marker tap) even though it is outside the + * geographic top-N. That row gets a visible badge and a screen-reader + * prefix so the list's sort-by-distance invariant is explained rather than + * silently broken. null when every row is a genuine top-N entry. + */ export function updateList( shelters: ShelterWithDistance[], currentSelectedIndex: number, + outsideNearestRomnr: number | null = null, ): void { if (!container) return; selectedIndex = currentSelectedIndex; @@ -46,7 +55,26 @@ export function updateList( t('shelter_room_nr', swd.shelter.romnr), ].join(' \u00B7 '); - item.setAttribute('aria-label', `${swd.shelter.adresse}, ${details}`); + const isOutsideNearest = + outsideNearestRomnr !== null && swd.shelter.romnr === outsideNearestRomnr; + + // Prefix (not suffix) the badge text so screen-reader users hear the + // caveat before the address — same order as contentDescription in + // ShelterListAdapter.kt on Android. + const baseLabel = `${swd.shelter.adresse}, ${details}`; + item.setAttribute( + 'aria-label', + isOutsideNearest + ? `${t('shelter_outside_nearest_badge')}. ${baseLabel}` + : baseLabel, + ); + + if (isOutsideNearest) { + const badge = document.createElement('span'); + badge.className = 'shelter-item-badge'; + badge.textContent = t('shelter_outside_nearest_badge'); + item.appendChild(badge); + } const addressSpan = document.createElement('span'); addressSpan.className = 'shelter-item-address'; @@ -64,4 +92,20 @@ export function updateList( }); container!.appendChild(item); }); + + // Keep the selected row visible — matters when the selection was appended + // below the fold, or when the user scrolled the list away from it. + const selectedEl = container.children[selectedIndex] as HTMLElement | undefined; + selectedEl?.scrollIntoView?.({ + block: 'nearest', + behavior: prefersReducedMotion() ? 'auto' : 'smooth', + }); +} + +function prefersReducedMotion(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); }