From a0b1f7ac5481b0eedf4b1908fab2e5edd12ee563 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:40:05 +0000 Subject: [PATCH] Make every page work from 320px phones to 3840px screens - Desktop atlas is exactly one screen tall; panels scroll inside it (the 221-entry index had stretched page and map to ~18,700px) - Fluid side-panel widths; gentle UI scale-up from 1800px wide - Landscape phones / short windows: compact header, one-line footer, record as an inline-end side sheet (mirrored in RTL); tablet sheet 62% - Pin centred in the part of the map the record sheet leaves visible - Leaflet controls no longer paint over the mobile filters drawer - Swipeable single-row nav/footer on phones; dvh units; safe-area insets (viewport-fit=cover); 44px touch targets under pointer: coarse - Map re-measures on container resize (ResizeObserver -> invalidateSize) - Audit gains 9 viewport checks (69/69); testing doc + CHANGELOG updated Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0134nrWUakNL58qJMzCVDcn6 --- CHANGELOG.md | 29 +++ about.html | 2 +- css/about.css | 61 ++++++- css/style.css | 311 ++++++++++++++++++++++++++++++-- docs/testing/BROWSER-TESTING.md | 35 +++- gaps.html | 2 +- index.html | 2 +- js/app.js | 50 ++++- languages.html | 2 +- tests/browser/audit.mjs | 46 +++++ themes.html | 2 +- 11 files changed, 512 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b6603..92c9de1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,35 @@ broken-source changes, and taxonomy changes (METHODOLOGY §10, §12). - The creator's ORCID iD (0009-0009-4672-4956) is in `.zenodo.json`, `CITATION.cff` and the atlas JSON-LD, so DataCite can link future Zenodo versions to ORCID. +### Atlas +- **Every screen size**, checked on a 13-viewport matrix (320×568 phone to + 3840×2160) plus landscape phones, 200 % / 400 % zoom and Arabic: + - Desktop (≥ 980 px): the atlas is now exactly one screen tall and the + side panels scroll inside it. Before, the 221-entry index stretched the + page — and the map — to about 18,700 px, so the map's centre and the + selected pin were far below the fold. + - Side panels size with the viewport (18–24 rem filters, 18–30 rem + record), so the map gets 448 px instead of 320 px at 1024 wide and + panels grow on wide screens; from 1800 px wide the whole UI scales up + gently (≈ 19 px text at 2560, 24 px at 3840). + - Landscape phones and short windows: compact header, one-line footer, + and the record opens as a side sheet (inline-end, mirrored in Arabic) + instead of a bottom sheet covering the map. Tablet portrait sheet is + 62 % tall instead of 80 %. On phones and tablets the map centres a + selected pin in the part the sheet leaves visible. + - Mobile: Leaflet's zoom buttons no longer paint over the open filters + drawer; header nav (reading pages) and atlas footer are single + swipeable rows; `dvh` units so browser chrome doesn't hide content; + safe-area insets for notched phones (`viewport-fit=cover`). + - Touch screens: buttons, selects, map zoom, popup close, footer and + header links are ≥ 44 px; pins get a 44 px hit area; form fields are + 16 px so iOS doesn't zoom on focus. + - Reading pages: header and footer align to a centred 90 rem frame on + ultra-wide screens. + - The map re-measures itself when its box changes size + (`ResizeObserver` → `invalidateSize`), e.g. on rotation. + - `tests/browser/audit.mjs` gains 9 viewport checks (69 in all). + ### Tooling - `.github/workflows/release.yml` creates a GitHub release `v` whenever the dataset version changes on `main` (or on demand), with diff --git a/about.html b/about.html index e67966f..8c73f3d 100644 --- a/about.html +++ b/about.html @@ -2,7 +2,7 @@ - + About Mapping Voices — research question, method, data, ethics diff --git a/css/about.css b/css/about.css index e811c27..83369e5 100644 --- a/css/about.css +++ b/css/about.css @@ -52,19 +52,72 @@ @media (max-width: 760px) { .page .header-inner { flex-wrap: wrap; + row-gap: 0.4rem; } + /* One swipeable row instead of three wrapped rows of links; the + "Open the atlas" button leads so it is always visible. */ .page-nav { - margin-inline-start: 0; + margin-inline: calc(-1 * var(--gutter)); + padding-inline: var(--gutter); + padding-block: 0.15rem; justify-content: flex-start; - width: 100%; + width: calc(100% + 2 * var(--gutter)); + flex-wrap: nowrap; + overflow-x: auto; + scrollbar-width: none; + -webkit-mask-image: linear-gradient(to right, #000 85%, transparent); + mask-image: linear-gradient(to right, #000 85%, transparent); + } + + html[dir="rtl"] .page-nav { + -webkit-mask-image: linear-gradient(to left, #000 85%, transparent); + mask-image: linear-gradient(to left, #000 85%, transparent); + } + + .page-nav::-webkit-scrollbar { + display: none; + } + + .page-nav a { + white-space: nowrap; + } + + .page-nav a:last-child { + margin-inline-end: 2rem; + } + + .page-nav .page-nav-cta { + order: -1; + margin-inline-end: 0.25rem; + } +} + +/* Wide screens: keep header and footer aligned with a centred frame rather + than pinned to the far edges of a 2560/3840px window. */ +.page .header-inner { + max-width: 90rem; +} + +/* Touch screens: header links and the Explorer's atlas links become + 44px-tall targets. */ +@media (pointer: coarse) { + .page-nav a, + .lx-atlas-link { + display: inline-flex; + align-items: center; + min-height: var(--tap); + } + + .page-nav { + row-gap: 0; } } .page-main { max-width: 44rem; margin: 0 auto; - padding: 2.5rem 1rem 3rem; + padding: clamp(1.5rem, 4vw, 2.5rem) max(1rem, env(safe-area-inset-right, 0px)) 3rem max(1rem, env(safe-area-inset-left, 0px)); font-size: 1.02rem; line-height: 1.65; } @@ -253,7 +306,7 @@ flex-direction: column; align-items: flex-start; gap: 0.25rem; - padding: 1rem; + padding: 1rem max(var(--pad-right), calc((100% - 90rem) / 2 + var(--gutter))) max(1rem, env(safe-area-inset-bottom, 0px)) max(var(--pad-left), calc((100% - 90rem) / 2 + var(--gutter))); } .page-footer p { diff --git a/css/style.css b/css/style.css index 3965e76..dfe8625 100644 --- a/css/style.css +++ b/css/style.css @@ -23,8 +23,16 @@ --header-h: 4.75rem; --footer-h: 2.5rem; - --panel-w: 21rem; - --detail-w: 23rem; + /* Side panels grow with the viewport so the map keeps a usable share on + small laptops / portrait tablets and panels aren't lost on wide screens. */ + --panel-w: clamp(18rem, 24vw, 24rem); + --detail-w: clamp(18rem, 26vw, 30rem); + /* Horizontal gutter that also clears notches / rounded corners + (viewport-fit=cover is set in every page's viewport meta). */ + --gutter: 1.25rem; + --pad-left: max(var(--gutter), env(safe-area-inset-left, 0px)); + --pad-right: max(var(--gutter), env(safe-area-inset-right, 0px)); + --tap: 2.75rem; /* 44px minimum touch target */ --radius: 4px; --transition-fast: 180ms ease; --transition-slow: 260ms cubic-bezier(0.2, 0.7, 0.3, 1); @@ -39,12 +47,27 @@ body { height: 100%; } +html { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +/* Large and ultra-wide screens: scale the whole rem-based UI up gently + (16px at 1800px wide, ~19px at 2560, 24px at 3840) so text and panels + don't shrink to specks. Percent/rem bounds keep the user's own font-size + preference and browser zoom in charge. */ +@media (min-width: 1800px) { + html { + font-size: clamp(100%, 0.5625rem + 0.39vw, 150%); + } +} + body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--font-ui); - font-size: 16px; + font-size: 1rem; line-height: 1.5; -webkit-font-smoothing: antialiased; } @@ -112,9 +135,20 @@ button { display: flex; flex-direction: column; min-height: 100vh; + min-height: 100dvh; /* dvh: mobile browser chrome doesn't hide the footer */ overflow-x: hidden; } +/* Desktop: the atlas is exactly one screen tall and the side panels scroll + on their own. Without a fixed height the index list (200+ entries) made + the grid row, and so the map, thousands of pixels tall. */ +@media (min-width: 980px) { + .app { + height: max(30rem, 100vh); + height: max(30rem, 100dvh); + } +} + /* ---------------------------------- header ---------------------------------- */ .site-header { @@ -129,7 +163,8 @@ button { .header-inner { max-width: 100%; margin: 0 auto; - padding: 0.75rem 1.25rem; + padding: 0.75rem var(--pad-right) 0.75rem var(--pad-left); + padding-top: max(0.75rem, env(safe-area-inset-top, 0px)); display: flex; align-items: center; gap: 1rem; @@ -140,6 +175,7 @@ button { display: flex; flex-direction: column; gap: 0.1rem; + min-width: 0; } .eyebrow { @@ -263,14 +299,15 @@ button { position: relative; flex: 1 1 auto; display: grid; - grid-template-columns: var(--panel-w) 1fr var(--detail-w); + grid-template-columns: var(--panel-w) minmax(0, 1fr) var(--detail-w); + grid-template-rows: minmax(0, 1fr); grid-template-areas: "filters map detail"; min-height: 0; } @media (max-width: 979px) { .layout { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); grid-template-areas: "map"; } } @@ -398,13 +435,17 @@ button { } .dataset-stats dl { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 0.5rem; + /* flex-wrap rather than three fixed columns: long labels (or longer + translations) wrap to a new row in a narrow panel instead of breaking + mid-word. */ + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.25rem; margin: 0; } .dataset-stats dl > div { + flex: 1 0 auto; display: flex; flex-direction: column-reverse; justify-content: flex-end; /* packs to the top in column-reverse */ @@ -454,9 +495,9 @@ button { .results { padding: 0.9rem 1.25rem 1.25rem; - overflow-y: auto; - flex: 1 1 auto; - min-height: 0; + /* The whole filters panel scrolls as one; a nested scroller here was + squeezed to a sliver on laptop-height screens. */ + flex: 0 0 auto; } .results-header { @@ -531,6 +572,10 @@ button { .map-wrap { grid-area: map; position: relative; + /* Own stacking context: Leaflet's controls (z-index 800–1000) otherwise + paint over the mobile filters drawer, which comes earlier in the DOM. */ + isolation: isolate; + z-index: 0; min-height: 0; display: flex; flex-direction: column; @@ -539,7 +584,7 @@ button { .map { flex: 1 1 auto; - min-height: 320px; + min-height: min(20rem, 55vh); width: 100%; background: var(--paper-deep); } @@ -813,7 +858,8 @@ button { flex: 0 0 auto; border-top: 1px solid var(--rule); background: var(--paper-panel); - padding: 0.5rem 1.25rem; + padding: 0.5rem var(--pad-right) 0.5rem var(--pad-left); + padding-bottom: max(0.5rem, env(safe-area-inset-bottom, 0px)); min-height: var(--footer-h); display: flex; flex-wrap: wrap; @@ -850,6 +896,7 @@ button { z-index: 1000; width: min(88vw, 22rem); box-shadow: 0 0 30px var(--shadow); + overscroll-behavior: contain; } .filters-drawer { @@ -857,6 +904,9 @@ button { border-right: 1px solid var(--rule); transform: translateX(-100%); transition: transform var(--transition-slow); + padding-top: env(safe-area-inset-top, 0px); + padding-bottom: env(safe-area-inset-bottom, 0px); + padding-left: env(safe-area-inset-left, 0px); } .filters-drawer.is-open { @@ -868,7 +918,9 @@ button { top: auto; bottom: 0; height: min(80vh, 100%); + height: min(80dvh, 100%); width: 100%; + padding-bottom: env(safe-area-inset-bottom, 0px); border-left: none; border-top: 1px solid var(--rule); border-radius: 12px 12px 0 0; @@ -901,8 +953,13 @@ button { /* ---------------------------------- small mobile tightening ---------------------------------- */ @media (max-width: 480px) { + :root { + --gutter: 0.85rem; + } + .header-inner { - padding: 0.6rem 0.85rem; + padding-top: max(0.6rem, env(safe-area-inset-top, 0px)); + padding-bottom: 0.6rem; gap: 0.6rem; } @@ -1008,3 +1065,227 @@ html[dir="rtl"] .result-item[aria-current="true"] { border-left: none; } } + + +/* ========================================================================== + Responsive: every screen size (added after a viewport-matrix audit — + docs/testing/BROWSER-TESTING.md lists the sizes and results) + ========================================================================== */ + +/* Very narrow phones (320–360px): keep the eyebrow on one line. */ + + +/* Mobile + tablet atlas footer: one row of links that scrolls sideways + instead of wrapping into three rows of small links. */ +@media (max-width: 979px) { + .site-footer:not(.page-footer) { + flex-wrap: nowrap; + flex-direction: column; + align-items: stretch; + gap: 0; + padding-top: 0.15rem; + padding-bottom: max(0.35rem, env(safe-area-inset-bottom, 0px)); + } + + .site-footer:not(.page-footer) .footer-nav { + flex-wrap: nowrap; + overflow-x: auto; + scrollbar-width: none; + margin-inline: calc(-1 * var(--gutter)); + padding-inline: var(--gutter); + /* soft fade at the scrolling edge hints that there is more */ + -webkit-mask-image: linear-gradient(to right, #000 85%, transparent); + mask-image: linear-gradient(to right, #000 85%, transparent); + } + + html[dir="rtl"] .site-footer:not(.page-footer) .footer-nav { + -webkit-mask-image: linear-gradient(to left, #000 85%, transparent); + mask-image: linear-gradient(to left, #000 85%, transparent); + } + + .site-footer:not(.page-footer) .footer-nav::-webkit-scrollbar { + display: none; + } + + .footer-nav a { + white-space: nowrap; + padding-block: 0.4rem; + } + + .site-footer:not(.page-footer) .footer-nav a:last-child { + padding-inline-end: 2rem; + } +} + +/* Tablet portrait: the record sheet covers 60% rather than 80% of a tall + screen so the map and the pin stay in view above it. */ +@media (min-width: 600px) and (max-width: 979px) and (orientation: portrait) { + .detail-panel { + height: min(62vh, 100%); + height: min(62dvh, 100%); + } +} + +/* Landscape phones and short windows (also ~200–400% zoom on laptops): + compact header, no hint line, one-line footer, and the record opens as a + side sheet instead of a bottom sheet that would cover the whole map. */ +@media (max-width: 979px) and (orientation: landscape) { + .detail-panel { + top: 0; + bottom: 0; + left: auto; + right: 0; + height: auto; + width: min(26rem, 62vw); + border-top: none; + border-left: 1px solid var(--rule); + border-radius: 12px 0 0 12px; + transform: translateX(100%); + padding-top: env(safe-area-inset-top, 0px); + padding-right: env(safe-area-inset-right, 0px); + } + + .detail-panel.is-open { + transform: translateX(0); + } + + html[dir="rtl"] .detail-panel { + right: auto; + left: 0; + border-left: none; + border-right: 1px solid var(--rule); + border-radius: 0 12px 12px 0; + transform: translateX(-100%); + padding-right: 0; + padding-left: env(safe-area-inset-left, 0px); + } + + html[dir="rtl"] .detail-panel.is-open { + transform: translateX(0); + } +} + +@media (max-width: 979px) and (max-height: 540px) and (orientation: landscape) { + :root { + --header-h: 3rem; + } + + .header-inner { + padding-top: max(0.3rem, env(safe-area-inset-top, 0px)); + padding-bottom: 0.3rem; + } + + .app .eyebrow, + .map-hint { + display: none; + } + + .app .wordmark { + font-size: 1.3rem; + } + + .map { + min-height: 12rem; + } + + /* OpenStreetMap is still credited in the map's own attribution control. */ + .site-footer:not(.page-footer) > p { + display: none; + } +} + +/* Keep Leaflet's corner controls clear of notches in landscape. */ +.leaflet-top.leaflet-left, +.leaflet-bottom.leaflet-left { + left: env(safe-area-inset-left, 0px); +} + +.leaflet-top.leaflet-right, +.leaflet-bottom.leaflet-right { + right: env(safe-area-inset-right, 0px); +} + +/* Touch screens: every control at least 44 × 44 CSS px. */ +@media (pointer: coarse) { + .icon-button { + width: var(--tap); + height: var(--tap); + } + + .lang-select, + .field select, + .field input[type="search"] { + min-height: var(--tap); + } + + /* 16px+ stops iOS Safari zooming the page when a field gets focus. */ + .field select, + .field input[type="search"], + .lang-select { + font-size: max(1rem, 16px); + } + + .text-button, + .footer-nav a { + display: inline-flex; + align-items: center; + min-height: var(--tap); + } + + .leaflet-touch .leaflet-bar a { + width: var(--tap); + height: var(--tap); + line-height: var(--tap); + } + + .leaflet-container a.leaflet-popup-close-button { + width: var(--tap); + height: var(--tap); + font: 1.5rem/var(--tap) Tahoma, Verdana, sans-serif; + } + + /* Pins stay 22px visually but get a 44px hit area. */ + .leaflet-marker-icon.mv-pin-icon::before { + content: ""; + position: absolute; + inset: -11px; + } +} + +@media (pointer: coarse) { + .detail-link { + min-height: var(--tap); + } +} + +/* Very narrow phones (320–360px): keep the eyebrow on one line and leave + the brand room next to the language switcher. Placed last so it wins + over the touch-size rules above. */ +@media (max-width: 360px) { + .eyebrow { + font-size: 0.58rem; + letter-spacing: 0.06em; + white-space: nowrap; + } + + .wordmark { + font-size: 1.2rem; + } + + .header-inner { + gap: 0.4rem; + } + + .lang-select, + html[dir="rtl"] .lang-select { + max-width: 5.4rem; + font-size: 0.85rem; + padding-inline: 0.45rem 1.4rem; + background-position: right 0.35rem center; + } + + html[dir="rtl"] .lang-select { + padding-inline: 0.45rem 1.4rem; + background-position: left 0.35rem center; + } +} diff --git a/docs/testing/BROWSER-TESTING.md b/docs/testing/BROWSER-TESTING.md index 6df143c..c79df34 100644 --- a/docs/testing/BROWSER-TESTING.md +++ b/docs/testing/BROWSER-TESTING.md @@ -1,6 +1,6 @@ # Browser, device, and accessibility testing -**Last run:** 2026-09-27, dataset v0.3.0 · **Automated result:** 42 / 42 checks passed +**Last run:** 2026-09-27, dataset v0.4.0 · **Automated result:** 69 / 69 checks passed ## What was tested, and how @@ -11,13 +11,38 @@ on third-party servers. | Area | Checks | Result | |---|---|---| -| Accessibility (axe-core, WCAG 2.1 A + AA) | Atlas; atlas with a record and map popup open; About; Language Explorer; atlas in Arabic (RTL) | ✅ 0 violations | -| JavaScript errors | All four page states | ✅ none | -| Device emulation | iPhone SE, iPhone 13, Pixel 7, Galaxy S9+, iPad (gen 7), iPad Pro 11 landscape × atlas / Language Explorer / About | ✅ no horizontal overflow (18/18) | -| Reflow (WCAG 1.4.10) | All three pages at 320 CSS px (≈ 400 % zoom) | ✅ no horizontal scrolling | +| Accessibility (axe-core, WCAG 2.1 A + AA) | Atlas; atlas with a record and map popup open; About; Language Explorer; Theme Explorer; Coverage gaps; atlas in Arabic (RTL) | ✅ 0 violations | +| JavaScript errors | All six page states | ✅ none | +| Device emulation | iPhone SE, iPhone 13, Pixel 7, Galaxy S9+, iPad (gen 7), iPad Pro 11 landscape × atlas / Language Explorer / Theme Explorer / Coverage gaps / About | ✅ no horizontal overflow (30/30) | +| Reflow (WCAG 1.4.10) | All five pages at 320 CSS px (≈ 400 % zoom) | ✅ no horizontal scrolling | | Keyboard | Skip link first; search reachable by Tab; result opens with Enter; visible focus; map pins are labelled, focusable buttons; mobile drawer takes focus, traps Tab, closes on Escape and restores focus; Explorer entries open with Enter | ✅ all | | Reduced motion | `prefers-reduced-motion` removes drawer animation | ✅ | | RTL | Arabic switches `dir=rtl`, keeps the open record, no overflow, passes axe | ✅ | +| Viewports | Atlas with a record open at 844×390, 667×375, 1024×768, 1440×900, 2560×1440, 3840×2160 (fits the screen on desktop, map usable, landscape record is a side sheet); About at 2560 and 3840 (text scales up, line length bounded); 44 px touch targets on iPhone 13 | ✅ | + +## Viewport matrix (responsive pass, 2026-09-27) + +Every page (atlas, atlas with a record open, atlas with the filters drawer +open, About, Languages, Themes, Coverage gaps) was screenshotted and +measured in Chromium at each size below, plus Arabic (RTL) at 390×844, +844×390 and 1440×900, and 200 % / 400 % zoom equivalents (640×400, +960×540, 720×450, 320×200). Touch emulation was on for widths up to 1024. +After the fixes: **no horizontal overflow in any of the 89 page states**. + +| Viewport | Before | After | +|---|---|---| +| 320×568 | Eyebrow wrapped to two lines; footer links 3 rows of 19 px targets | One-line eyebrow; single swipeable footer row, 44 px targets | +| 375×667, 390×844 | Leaflet zoom buttons painted **over** the open filters drawer; header controls 40 px / 25 px tall | Map is its own stacking context; controls ≥ 44 px | +| 844×390, 667×375 (landscape) | Header 83 px + map 320 px min-height > screen, page scrolled; record bottom sheet covered 80 % of the map, full width | Compact 55 px header, one-line footer, map fills the rest (282 / 267 px); record is a 416 px side sheet with the pin centred in the visible map | +| 768×1024 (tablet portrait) | Record sheet 80 % tall | 62 % tall, pin centred above it | +| 1024×768, 1024×1366 | 3-column atlas left the map 320 px wide; **page 18,823 px tall** | Map 448 px wide; atlas exactly one screen tall | +| 1280×800 – 1440×900 | Page and map ~18,700 px tall (index list stretched the grid row), map centre far below the fold | One screen tall; panels scroll inside | +| 1920, 2560, 3840 wide | Same 18,700 px page; 16 px text and 21 rem panels on a 3840 screen; reading pages' header pinned to the far edges | UI scales from 1800 px (24 px text at 3840); panels up to 24 / 30 rem; header/footer in a centred 90 rem frame | +| Reading pages, phones | Header nav wrapped to 3 rows (163 px) of 20 px-tall links | One swipeable row, "Open the atlas" first, 44 px targets (header 123 px) | +| 200 % / 400 % zoom | As the matching mobile / landscape sizes above | No horizontal scroll; at 320×200 the map keeps 192 px and the record opens as a side sheet | + +Inline links inside running text and data tables, and Leaflet's own +attribution links, are left at text size (WCAG 2.5.8 inline exception). One issue found and fixed in the test itself: axe initially flagged the map popup's text contrast because it measured during Leaflet's fade-in diff --git a/gaps.html b/gaps.html index 12a63a8..53f83e2 100644 --- a/gaps.html +++ b/gaps.html @@ -2,7 +2,7 @@ - + Coverage Gaps — Mapping Voices diff --git a/index.html b/index.html index 65bfd9b..7d167be 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + Mapping Voices — an atlas of oral-history archives diff --git a/js/app.js b/js/app.js index dd80104..507e9d8 100644 --- a/js/app.js +++ b/js/app.js @@ -433,6 +433,20 @@ zoomControl.addTo(map); markerLayer = L.layerGroup().addTo(map); + + // Leaflet only re-measures on window resize. The map box also changes + // size without one (orientation change, the mobile URL bar collapsing + // under 100dvh, the desktop/mobile layout switch), which would leave + // grey unrendered strips — so watch the container itself. + if (typeof window.ResizeObserver === "function") { + var resizeFrame = 0; + new window.ResizeObserver(function () { + window.cancelAnimationFrame(resizeFrame); + resizeFrame = window.requestAnimationFrame(function () { + map.invalidateSize({ pan: false }); + }); + }).observe(document.getElementById("map")); + } } function pinIcon(active) { @@ -1017,6 +1031,35 @@ // Selection + detail panel // ------------------------------------------------------------------ + // On phones and tablets the record sheet covers part of the map (the + // bottom in portrait, the inline-end side in landscape). Return the map + // centre that puts `latlng` in the middle of the part still visible, so + // the selected pin isn't hidden under the sheet. + function centerForUncoveredArea(latlng, zoom) { + var mapRect = document.getElementById("map").getBoundingClientRect(); + var sheetW = els.detailPanel.offsetWidth; + var sheetH = els.detailPanel.offsetHeight; + var x = mapRect.width / 2; + var y = mapRect.height / 2; + if (sheetW < window.innerWidth - 1) { + // side sheet (landscape) + var visibleW = mapRect.width - sheetW; + if (visibleW < 80) { + return latlng; + } + x = isRtl(activeLocale) ? sheetW + visibleW / 2 : visibleW / 2; + } else { + // bottom sheet (portrait) + var visibleBottom = Math.min(mapRect.bottom, window.innerHeight - sheetH) - mapRect.top; + if (visibleBottom < 80) { + return latlng; + } + y = visibleBottom / 2; + } + var offset = L.point(x - mapRect.width / 2, y - mapRect.height / 2); + return map.unproject(map.project(latlng, zoom).subtract(offset), zoom); + } + function selectCollection(id, opts) { opts = opts || {}; var c = findCollection(id); @@ -1045,7 +1088,12 @@ writeUrlState(); if (opts.panTo !== false && map) { - map.flyTo([c.lat, c.lng], Math.max(map.getZoom(), 5), { duration: 0.6 }); + var zoom = Math.max(map.getZoom(), 5); + var target = L.latLng(c.lat, c.lng); + if (opts.openDrawerOnMobile && narrowMQ().matches) { + target = centerForUncoveredArea(target, zoom); + } + map.flyTo(target, zoom, { duration: 0.6 }); } // On mobile the bottom sheet is the full detail surface; skip the tiny // map popup there so it doesn't peek out from behind the sheet. diff --git a/languages.html b/languages.html index 8a3ffd1..2303631 100644 --- a/languages.html +++ b/languages.html @@ -2,7 +2,7 @@ - + Language Explorer — Mapping Voices diff --git a/tests/browser/audit.mjs b/tests/browser/audit.mjs index a2b8c47..46923fa 100644 --- a/tests/browser/audit.mjs +++ b/tests/browser/audit.mjs @@ -92,6 +92,52 @@ async function run() { } } + // ---- viewport matrix: landscape phones, laptops, large/ultra-wide ---- + for (const [w, h, touch] of [[844, 390, true], [667, 375, true], [1024, 768, true], [1440, 900, false], [2560, 1440, false], [3840, 2160, false]]) { + const ctx = { viewport: { width: w, height: h }, hasTouch: touch, isMobile: touch && w < 980 }; + const { context, page } = await newPage(browser, ctx); + await page.goto(BASE + "/?c=MV-000023"); + await page.waitForSelector(".detail-title"); + await page.waitForTimeout(400); + const m = await page.evaluate(() => { + const map = document.getElementById("map").getBoundingClientRect(); + const sheet = document.getElementById("detail-panel").getBoundingClientRect(); + return { docH: document.documentElement.scrollHeight, mapH: map.height, mapW: map.width, sheetW: sheet.width }; + }); + const ok = (await noOverflow(page)) && m.mapH >= Math.min(260, h * 0.6) && m.mapW >= w * 0.4 && + // desktop: the atlas is exactly one screen tall (panels scroll inside it) + (w < 980 || m.docH <= h + 1) && + // landscape phones: the record opens as a side sheet, leaving map visible + (w >= 980 || m.sheetW < w * 0.7); + record("viewports", `${w}×${h} atlas with a record open: fits, map usable, no overflow`, ok, JSON.stringify(m)); + await context.close(); + } + for (const [w, h] of [[2560, 1440], [3840, 2160]]) { + const { context, page } = await newPage(browser, { viewport: { width: w, height: h } }); + await page.goto(BASE + "/about.html"); + await page.waitForSelector("#page-title"); + const r = await page.evaluate(() => ({ + fs: parseFloat(getComputedStyle(document.querySelector(".lede")).fontSize), + mainW: document.querySelector("main").getBoundingClientRect().width, + })); + // text scales up on huge screens, but the reading column stays readable + record("viewports", `${w}×${h} about: larger text, bounded line length, no overflow`, + (await noOverflow(page)) && r.fs > 18.5 && r.mainW / r.fs < 45, JSON.stringify(r)); + await context.close(); + } + { + const { context, page } = await newPage(browser, { ...pw.devices["iPhone 13"] }); + await page.goto(BASE + "/"); + await page.waitForSelector(".result-item", { state: "attached" }); + const small = await page.evaluate(() => + ["#filters-toggle", "#lang-select", ".leaflet-control-zoom-in", ".footer-nav a"] + .map((s) => [s, document.querySelector(s).getBoundingClientRect().height]) + .filter(([, hh]) => hh < 44) + ); + record("viewports", "touch screens: header, map and footer controls are ≥ 44px tall", small.length === 0, JSON.stringify(small)); + await context.close(); + } + // ---- reflow: 320 CSS px wide (WCAG 1.4.10) ---- for (const url of ["/", "/about.html", "/languages.html", "/themes.html", "/gaps.html"]) { const { context, page } = await newPage(browser, { viewport: { width: 320, height: 640 } }); diff --git a/themes.html b/themes.html index 8466de1..37e100b 100644 --- a/themes.html +++ b/themes.html @@ -2,7 +2,7 @@ - + Theme Explorer — Mapping Voices