From 29623c757dddb1f690b92df036808769908b6222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Tue, 29 Sep 2026 17:31:50 +0200 Subject: [PATCH] fix(web): keep the map bar on one row between 641 and 767 px Between 641 and 729 px the wide bar did not fit: 90 px for a guest and 76 px for a member, on a phone held sideways or in a narrow window. The narrow layout now applies up to 767 px, from one constant the bar, the ticker and the notices share. The one-row test measures 660 and 720 px as well, for both roles. Closes #727 Co-Authored-By: Claude Fable 5.1 --- app/changelog.json | 7 +++ app/src/barfilters.js | 2 +- app/src/styles/app.css | 2 +- .../2026-09-29-01-bar-one-row-sideways.json | 7 +++ docs/design-system.md | 20 +++---- web/barfilters.js | 2 +- web/barnarrow.js | 11 +++- web/barnarrow.test.js | 20 ++++++- web/changelog.json | 7 +++ web/e2e/barlayout.spec.js | 55 +++++++++++++++++-- web/e2e/fixtures.js | 2 +- web/e2e/popover.spec.js | 10 +++- web/e2e/ticker-place.spec.js | 10 ++-- web/index.html | 10 ++-- web/map.js | 18 +++--- web/noticeplace.test.js | 2 +- web/parity.test.js | 4 +- web/style.css | 26 +++++---- web/tickerplace.js | 4 +- web/tickerplace.test.js | 6 +- 20 files changed, 162 insertions(+), 63 deletions(-) create mode 100644 changelog.d/2026-09-29-01-bar-one-row-sideways.json diff --git a/app/changelog.json b/app/changelog.json index b2ca7c2a..96f8443e 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -1,4 +1,11 @@ [ + { + "id": "2026-09-29-bar-one-row-sideways", + "date": "2026-09-29", + "where": "map", + "title": "One-row bar on a phone held sideways", + "body": "Below 768 px wide the map uses the phone layout, so the bar no longer wraps to three rows." + }, { "id": "2026-09-27-hud-bottom-space", "date": "2026-09-27", diff --git a/app/src/barfilters.js b/app/src/barfilters.js index 178d7318..3755da89 100644 --- a/app/src/barfilters.js +++ b/app/src/barfilters.js @@ -2,7 +2,7 @@ // presentation). // // Fourteen type chips, the id-class chips, two checkboxes, the layer -// controls and Clear used to lay out inline in #bar above 640px — a full +// controls and Clear used to lay out inline in #bar on a wide screen — a full // bar row that read as one long strip without groups. The bar now keeps // five controls (hunters, sender, time, the Filters pill, the right-side // meta) and everything else lives in this panel at every width, in named diff --git a/app/src/styles/app.css b/app/src/styles/app.css index 98e3e830..79a63b17 100644 --- a/app/src/styles/app.css +++ b/app/src/styles/app.css @@ -377,7 +377,7 @@ html, body { margin: 0; height: 100%; background: var(--ch-bg); color: var(--ch- font: inherit; cursor: pointer; } .fs-types-more[hidden] { display: none; } /* Fifteen chips are four rows on a phone, and then the rest of the sheet is - under the fold -- the same rule the map applies below 640px (#423), which is + under the fold -- the same rule the map applies below 768px (#423, #727), which is every screen here. An ACTIVE chip always shows, whatever its position: a filter that is on and off screen is what the panel exists to prevent. data-overflow rather than :nth-child, because the All chip is a child too and diff --git a/changelog.d/2026-09-29-01-bar-one-row-sideways.json b/changelog.d/2026-09-29-01-bar-one-row-sideways.json new file mode 100644 index 00000000..d507f3b5 --- /dev/null +++ b/changelog.d/2026-09-29-01-bar-one-row-sideways.json @@ -0,0 +1,7 @@ +{ + "id": "2026-09-29-bar-one-row-sideways", + "date": "2026-09-29", + "where": "map", + "title": "One-row bar on a phone held sideways", + "body": "Below 768 px wide the map uses the phone layout, so the bar no longer wraps to three rows." +} diff --git a/docs/design-system.md b/docs/design-system.md index 2ac81ce9..2d0f41ce 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -114,9 +114,9 @@ the zoom buttons. Wherever 46px fits it is kept, as at 844x390; near the limit, bar's rendered height decides, so a taller font shrinks the buttons a little. Overlays keep the rail's column clear (`--ch-rail-clear`, 14 + 46 + 8px): the readout, the -Locate card and the node-positions notice stop at it. On a phone held sideways, narrower -than 641px, the column reaches up to the bar, so the pinned ticker and the notices stop at -it too. +Locate card and the node-positions notice stop at it. Narrower than 768px and lower than 721px, +the column reaches up to where the full ticker ends, so the pinned ticker and the notices stop +at it too (#727). **Guard:** `e2e/barlayout.spec.js` hits every rail button at its centre at 375x812, 390x844, 768x1024, 1280x800 and 844x390 as a guest and as a member, and at five sideways @@ -164,7 +164,7 @@ hit-testable and invisible, which is the worst of the three: a target nobody can see that does something when pressed by accident. The ticker's drag frame is two 6px strips shown on hover (#424), and dragging is -dropped below 640px rather than given a touch handle. Why is under **Panels**. +dropped below 768px rather than given a touch handle. Why is under **Panels**. ### Native form controls take the accent @@ -203,7 +203,7 @@ Anything floating over the map must be movable aside, shrinkable and dismissible property of the **surface**, not of the ticker: the next interaction popup added to `web/` owes the reader the same. -**How it moves aside is a width question, not a touch one.** Above 640px it drags. Below, +**How it moves aside is a width question, not a touch one.** From 768px it drags. Below, the card is pinned as in the app: centred under the bar at `calc(100vw - 20px)` (#643). It spans the map, so there is no "out of the way" to drag it to, and its stops and its cross are what move it aside there, which is what the app does at every width (#561). The position dragged on a wide @@ -250,7 +250,7 @@ from 0 to 60 — including which lane the marker lands on for each row — and c | | app | map | reason | |---|---|---|---| -| position | fixed, centred | placed and dragged above 640px; centred below, as in the app | surface rule above | +| position | fixed, centred | placed and dragged from 768px; centred below, as in the app | surface rule above | | pointer events | caught | passed through | surface rule above | Everything else is the same, including the collapse stops and the cross with its bar button. @@ -280,7 +280,7 @@ Everything that narrows the view is in it, at every width. A filter reachable on somewhere else is a filter the panel lies about: the map's ignore list lived in the settings sheet, which is where you go to change how the app behaves, not what it shows. -The bar may carry a **shortcut** to a control the panel owns. Below 640px it carries none: +The bar may carry a **shortcut** to a control the panel owns. Below 768px it carries none: what does not fit moves into the panel (see **Bars**). ### "Everything" is one state, drawn as an All chip @@ -337,7 +337,7 @@ zoom buttons inside the dead strip. A bar that cannot wrap cannot grow. *Standard pattern: the overflow menu.* -Below 640px the map's bar keeps the mark, two segments of the connected group +Below 768px the map's bar keeps the mark, two segments of the connected group (`Select target`, `Filters`) and the icon buttons — the same two the app's group has carried at that width since #305. Everything else moves: what narrows the view into the filter panel, what acts into the menu (`web/barnarrow.js`). @@ -385,7 +385,7 @@ right: a centred column at most 560px wide, never wider than the room between the rail's clearance on both sides. When the ticker lies in that column, the notices move to the band beside it if that band is at least 320px wide, and under it if not (`noticeplace.js`), so a notice, which paints over the ticker, -never takes the clicks on its cross or its chevron. Below 640px they span the +never takes the clicks on its cross or its chevron. Below 768px they span the width under the bar (8px from each edge), under the pinned ticker. ## Copy and marks @@ -393,7 +393,7 @@ width under the bar (8px from each edge), under the pinned ticker. - **One brand mark**, the PWA icon (`app/public/icon.svg`), on every surface (#539), drawn inline so its strokes take `--ch-accent` and the signal tiers rather than the file's fixed palette. **Every surface names the product**: - mark plus wordmark on a wide screen, the mark alone below 640px with the name + mark plus wordmark on a wide screen, the mark alone below 768px with the name in the menu, which is what a mobile app bar does. The map had neither until #561, and the map is the surface people are sent a link to. - **Drawn icons, not emoji.** An emoji renders differently per platform and cannot take a token diff --git a/web/barfilters.js b/web/barfilters.js index 178d7318..3755da89 100644 --- a/web/barfilters.js +++ b/web/barfilters.js @@ -2,7 +2,7 @@ // presentation). // // Fourteen type chips, the id-class chips, two checkboxes, the layer -// controls and Clear used to lay out inline in #bar above 640px — a full +// controls and Clear used to lay out inline in #bar on a wide screen — a full // bar row that read as one long strip without groups. The bar now keeps // five controls (hunters, sender, time, the Filters pill, the right-side // meta) and everything else lives in this panel at every width, in named diff --git a/web/barnarrow.js b/web/barnarrow.js index 66686007..e7e50e29 100644 --- a/web/barnarrow.js +++ b/web/barnarrow.js @@ -1,4 +1,4 @@ -// What the map's bar hands off below 640px (#561). +// What the map's bar hands off below 768px (#561, #727). // // The bar is one row at every width now, and at 375px a row cannot hold four // filter controls, a brand, a primary action, a login and two icon buttons: @@ -107,9 +107,16 @@ export function applyNarrowBar(narrow) { } } +// The one line the map answers "is this a phone" with: the bar, the ticker and +// the notices all hang off it. Up to 767px, below a tablet. It was 640 until +// #727: between 641 and 729px the wide bar wrapped to two or three rows (76 to +// 90px) with CI's wider font, which is a phone held sideways. The stylesheet +// repeats the number in its @media rules, since CSS cannot import it. +export const NARROW_MEDIA = '(max-width: 767px)' + // Wires the rule to the viewport and applies it once. The listener is never // removed: the bar lives as long as the page does. -export function wireNarrowBar(mq = window.matchMedia('(max-width: 640px)')) { +export function wireNarrowBar(mq = window.matchMedia(NARROW_MEDIA)) { applyNarrowBar(mq.matches) mq.addEventListener('change', (e) => applyNarrowBar(e.matches)) return mq diff --git a/web/barnarrow.test.js b/web/barnarrow.test.js index c107b712..89079215 100644 --- a/web/barnarrow.test.js +++ b/web/barnarrow.test.js @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { readFileSync } from 'node:fs' -import { NARROW_SLOTS, NARROW_CONTAINERS, findControl } from './barnarrow.js' +import { NARROW_SLOTS, NARROW_CONTAINERS, findControl, NARROW_MEDIA } from './barnarrow.js' // There is no jsdom in this suite (see focustrap.test.js), so the move itself is // driven in e2e/barlayout.spec.js by a real browser at a real width. What is @@ -123,3 +123,21 @@ describe('finding the control a slot moves', () => { expect(found).toEqual(['time range', 'hunter picker', 'Start mapping', 'Log in']) }) }) + +// The narrow line is one number in JS (NARROW_MEDIA, which the ticker and the +// bar answer by) and in every media query of the stylesheet. #727 moved it +// from 640 to 767; a query left behind lays the bar out for one width and +// places the ticker for another. +describe('the stylesheet\'s widths are NARROW_MEDIA\'s', () => { + const CSS = readFileSync(new URL('./style.css', import.meta.url), 'utf8') + const queries = [...CSS.matchAll(/@media([^{]+)\{/g)].map((m) => m[1]) + it('finds the media queries at all', () => { + expect(queries.length).toBeGreaterThan(2) + }) + it('uses one max-width, the one NARROW_MEDIA names', () => { + const narrow = Number(NARROW_MEDIA.match(/(\d+)px/)[1]) + const widths = queries.flatMap((q) => [...q.matchAll(/(?:max|min)-width:\s*(\d+)px/g)].map((m) => Number(m[1]))) + expect(widths.length).toBeGreaterThan(0) + for (const w of widths) expect(w, `a media query at ${w}px`).toBe(narrow) + }) +}) diff --git a/web/changelog.json b/web/changelog.json index b2ca7c2a..96f8443e 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -1,4 +1,11 @@ [ + { + "id": "2026-09-29-bar-one-row-sideways", + "date": "2026-09-29", + "where": "map", + "title": "One-row bar on a phone held sideways", + "body": "Below 768 px wide the map uses the phone layout, so the bar no longer wraps to three rows." + }, { "id": "2026-09-27-hud-bottom-space", "date": "2026-09-27", diff --git a/web/e2e/barlayout.spec.js b/web/e2e/barlayout.spec.js index e63e7062..9663feca 100644 --- a/web/e2e/barlayout.spec.js +++ b/web/e2e/barlayout.spec.js @@ -148,14 +148,22 @@ const barShape = (page) => page.evaluate(() => { } }) -for (const [w, h, label] of [[375, 812, 'a phone'], [768, 1024, 'a tablet'], [1280, 800, 'a desktop']]) { - test(`the bar is one row on ${label}, as a guest`, async ({ page }) => { - await asGuest(page) +// #727: between 641 and 729px the wide bar did not fit, 76 to 90px on a phone +// held sideways (667px) or a narrow window. 660 and 720 sit in that band; the +// member too, whose bar is shorter but wrapped there as well. +const ONE_ROW = [[375, 812, 'a phone', 'guest'], [660, 375, 'a phone held sideways', 'guest'], + [660, 375, 'a phone held sideways', 'member'], [720, 800, 'a narrow window', 'guest'], + [720, 800, 'a narrow window', 'member'], [768, 1024, 'a tablet', 'guest'], [1280, 800, 'a desktop', 'guest']] +for (const [w, h, label, role] of ONE_ROW) { + test(`the bar is one row on ${label} at ${w}px, as a ${role}`, async ({ page }) => { + if (role === 'guest') await asGuest(page) await page.setViewportSize({ width: w, height: h }) await page.goto('/') // Wait for the late arrivals that used to grow it: the guest notice and the - // node counts both land after first paint. - await expect(page.locator('#guest-notice')).toBeVisible() + // node counts both land after first paint. The member has no notice; the + // bar test's own beforeEach answers /api/auth/me as one. + if (role === 'guest') await expect(page.locator('#guest-notice')).toBeVisible() + else await expect(page.locator('#rx-log')).toBeVisible() const s = await barShape(page) expect(s.centreSpread, `one centre line, got ${JSON.stringify(s.ids)}`).toBeLessThanOrEqual(2) expect(s.height, 'the bar is a row, not a block').toBeLessThanOrEqual(64) @@ -194,7 +202,7 @@ for (const [w, h] of [[375, 812], [390, 844], [768, 1024], [1280, 800], [844, 39 // that is shorter than that ran it up under the bar (740x360: the bar's lower // edge at 62, the rail's top at 54), and narrower than 760 its column stood on // the right end of the ticker, where the cross and the chevron are. Narrower -// than 641 the ticker and the notices span the screen (#643), so the column +// than 768 the ticker and the notices span the screen (#643, #727), so the column // stood on both: at 568x320 #zoom-in was under #settings-btn and the compass // under the guest notice, and at 667x375 the notice's right end reached into // the column. Every button is hit at its centre, not only the column's box. @@ -392,3 +400,38 @@ for (const [w, h] of [[1280, 800], [844, 390], [390, 844], [375, 812]]) { }) } } + +// Below 768px the ticker spans the screen, and a full one, remembered from a +// wide screen or from a shared link (rx=…,0), ends about 350px down. On a +// screen shorter than about 676px that is past the rail's top, so there the +// ticker stops at the rail (#727: at 720x600 the rail stood on it). A tall +// phone held upright keeps it across the screen. +const fullTicker = async (page, w, h) => { + await asGuest(page) + const rows = Array.from({ length: 20 }, (_, i) => ({ lat: 51, lon: 4, rssi: -90, snr: -8, sender_id: 'aa11bb22', sender_label: 'NEO7HI', + hunter_name: 'Hunter 1', packet_type: 'Advert', rx_at: new Date(Date.now() - 5000 - i * 1000).toISOString() })) + await page.route('**/api/points*', (r) => r.fulfill({ json: { points: rows } })) + await page.setViewportSize({ width: w, height: h }) + await page.goto('/?rx=0,0,0') + await expect(page.locator('#rx-log .rx-ln')).toHaveCount(20, { timeout: 10000 }) + return page.evaluate(() => { + const r = (s) => { const b = document.querySelector(s).getBoundingClientRect(); return { left: b.left, right: b.right, top: b.top, bottom: b.bottom } } + const hitOf = (el) => { const b = el.getBoundingClientRect(); const top = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2); return top && (top === el || el.contains(top)) } + return { vw: innerWidth, rail: r('#map-rail'), ticker: r('#rx-log'), + controls: ['.rx-fold', '.rx-close'].map((s) => [s, hitOf(document.querySelector('#rx-log ' + s))]) } + }) +} +for (const [w, h] of [[720, 600], [640, 600], [375, 667]]) { + test(`a full ticker at ${w}x${h} stops at the rail`, async ({ page }) => { + const g = await fullTicker(page, w, h) + if (w === 720) expect(g.ticker.bottom, 'the ticker has to reach the rail\'s height for this to measure anything').toBeGreaterThan(g.rail.top) + const apart = g.ticker.right <= g.rail.left || g.ticker.bottom <= g.rail.top + expect(apart, `ticker ${JSON.stringify(g.ticker)} under rail ${JSON.stringify(g.rail)}`).toBe(true) + for (const [s, hit] of g.controls) expect(hit, `${s} is covered`).toBe(true) + }) +} +test('a full ticker on a tall phone held upright spans the screen, clear of the rail', async ({ page }) => { + const g = await fullTicker(page, 412, 915) + expect(g.ticker.bottom).toBeLessThanOrEqual(g.rail.top) + expect(g.ticker.right).toBeCloseTo(g.vw - 10, 0) +}) diff --git a/web/e2e/fixtures.js b/web/e2e/fixtures.js index c6b55c80..80ab3793 100644 --- a/web/e2e/fixtures.js +++ b/web/e2e/fixtures.js @@ -164,7 +164,7 @@ export async function openSettings(page, tab = 'settings') { // setFilter toggles one of the secondary filters, at any width. // -// Since #423 those controls live behind the Filters pill below 640px, so a +// Since #423 those controls live behind the Filters pill below 768px, so a // phone-width test has to open it the way a user does; above the breakpoint the // pill is not rendered and this is a plain check(). The panel is shut again // afterwards because it overlays the map, and most callers go on to assert diff --git a/web/e2e/popover.spec.js b/web/e2e/popover.spec.js index 94f60ea9..831c2555 100644 --- a/web/e2e/popover.spec.js +++ b/web/e2e/popover.spec.js @@ -1,4 +1,8 @@ import { test, expect, openPicker, openFilters } from './fixtures.js' +import { NARROW_MEDIA } from '../barnarrow.js' + +// The width at and under which the bar and the ticker are narrow. +const NARROW_MAX = Number(NARROW_MEDIA.match(/(\d+)px/)[1]) // #372: on a phone #bar wrapped, #tr-toggle started its own row, and the // right-anchored time-range panel grew off the left edge — what showed was its @@ -34,7 +38,7 @@ async function expectOnScreen(page, selector) { }, { message: `${selector} on screen` }).toBe('on screen') } -// Below 640px the time range and the hunter picker are reached through Filters +// Below 768px the time range and the hunter picker are reached through Filters // (#561): the bar's group keeps Select target and the pill at that width, and // the other two live in the panel. Which is where the popovers have to open // fully on screen from now -- the constraint #372 named has not changed, only @@ -45,7 +49,7 @@ const reach = async (page, toggle, panel, narrow) => { } for (const [label, width, height] of [['a phone', 412, 915], ['a desktop', 1280, 720]]) { - const narrow = width <= 640 + const narrow = width <= NARROW_MAX test(`the time-range picker opens fully on screen on ${label}`, async ({ page }) => { await page.setViewportSize({ width, height }) await page.goto('/') @@ -77,7 +81,7 @@ for (const [label, width, height] of [['a phone', 412, 915], ['a desktop', 1280, test('an open panel follows its toggle when a resize moves it', async ({ page }) => { // The bar no longer rewraps (#561), so the toggle does not change rows. What - // it does at 640px is move house: #tr-wrap leaves the bar's group for the + // it does at 768px is move house: #tr-wrap leaves the bar's group for the // filter panel. An open popover cannot follow it there -- the panel is shut, // and a popover inside a shut panel is a control that has silently vanished // while its toggle still claims to be expanded. It closes instead. diff --git a/web/e2e/ticker-place.spec.js b/web/e2e/ticker-place.spec.js index f80fe320..39031d34 100644 --- a/web/e2e/ticker-place.spec.js +++ b/web/e2e/ticker-place.spec.js @@ -66,7 +66,7 @@ test('drags by its frame and remembers where it was left', async ({ page }) => { test('a ticker left at the edge of a wide screen is still reachable on a narrow one', async ({ page }) => { // The safety net the issue asks for: dragging replaces the anchor, so there // is no "put it back" and an off-screen ticker would be lost for good. - // 720, not a phone: below 640px the card is pinned rather than clamped + // 800, not a phone: below 768px the card is pinned rather than clamped // (#643), and that has its own case below. await page.setViewportSize({ width: 1400, height: 900 }) await page.goto('/') @@ -77,11 +77,11 @@ test('a ticker left at the edge of a wide screen is still reachable on a narrow await page.mouse.move(1380, 860, { steps: 8 }) await page.mouse.up() - await page.setViewportSize({ width: 720, height: 700 }) + await page.setViewportSize({ width: 800, height: 700 }) // Polled: the clamp runs from the resize handler, so asserting on the first // measurement races it. Polling also proves it actually settles rather than // happening to be right at one instant. - await expect.poll(async () => (await box(page)).right, { timeout: 5000 }).toBeLessThanOrEqual(720) + await expect.poll(async () => (await box(page)).right, { timeout: 5000 }).toBeLessThanOrEqual(800) const b = await box(page) expect(b.x).toBeGreaterThanOrEqual(0) expect(b.right, 'stranded off the right edge').toBeLessThanOrEqual(b.vw) @@ -89,7 +89,7 @@ test('a ticker left at the edge of a wide screen is still reachable on a narrow expect(b.y).toBeGreaterThanOrEqual(b.barBottom - 1) }) -// #643: below 640px the card is pinned under the bar like the app's, and the +// #643: below 768px the card is pinned under the bar like the app's, and the // position dragged on a wide screen is kept for when it is wide again. The // stored x,y is the part that used to be lost: urlstate writes the placement // back on every load, so clamping it against the phone overwrote it for good. @@ -370,7 +370,7 @@ test('the frame is invisible at rest and never covers the map', async ({ page }) } }) -// Dragging is a wide-screen affordance (#561). The card is pinned below 640px +// Dragging is a wide-screen affordance (#561). The card is pinned below 768px // at the app's width (#643), spanning the map -- there is no "out of the way" // to drag it to. Shrinking and dismissing are what move it // aside there, which is what the app does at every width. diff --git a/web/index.html b/web/index.html index ccc26ebc..252c4d7f 100644 --- a/web/index.html +++ b/web/index.html @@ -102,7 +102,7 @@ it lived in document.title and in the walkthrough panel, so the surface a newcomer is most likely to be sent a link to had no wordmark at all. The mark is app/public/icon.svg, drawn inline so it can take theme - colours; below 640px the mark stands alone and the name is in the menu, + colours; below 768px the mark stands alone and the name is in the menu, which is the standard mobile app-bar pattern. -->

Filters

- @@ -208,7 +208,7 @@

Filters

Traffic types
- @@ -423,7 +423,7 @@

Settings

- diff --git a/web/map.js b/web/map.js index 9cb6a87b..dc9a2368 100644 --- a/web/map.js +++ b/web/map.js @@ -30,7 +30,7 @@ import { QUICK_RANGES, COLD_START_RANGE, matchQuickRange, rangeLabelFor, rangeFo import { createReceptionTicker, receptionKey, tickerFilters, isLiveWindow, newestInRing, CAP as RX_CAP, nextCollapse, atLastCollapse, RX_FULL_LANES } from './receptionticker.js' import { initialPlacement, clampToViewport, clampUnlessNarrow, serialise, parse as parsePlacement } from './tickerplace.js' import { noticesPlacement } from './noticeplace.js' -import { wireNarrowBar } from './barnarrow.js' +import { wireNarrowBar, NARROW_MEDIA } from './barnarrow.js' import { hiddenChipCount, CHIP_CAP } from './chiprow.js' import { compassNeedleTransform, zoomButtonsDisabled, nodePosTap } from './maprail.js' import { NODEPOS_MODES, NODEPOS_LABELS, parseNodePosMode } from './nodeposmode.js' @@ -1372,10 +1372,10 @@ function showNodePosNotice({ on = nodePosCb.checked, reason = null, registry = n if (next.hideAfterMs) noticeTimer = setTimeout(() => { keyEl.hidden = true }, next.hideAfterMs) } -// Narrow is 640px, the line the bar and the ticker answer "is this a phone" -// with. matchMedia rather than innerWidth so the answer arrives as an event. -const narrowQuery = window.matchMedia('(max-width: 640px)') -// The bar's group keeps two of its four controls below 640px and the filter +// Narrow is NARROW_MEDIA, the line the bar and the ticker answer "is this a +// phone" with. matchMedia rather than innerWidth so the answer arrives as an event. +const narrowQuery = window.matchMedia(NARROW_MEDIA) +// The bar's group keeps two of its four controls below 768px and the filter // panel takes the other two (#561). Wired to the same query rather than a // second matchMedia, so the bar and everything else that answers "is this a // phone" cannot disagree about where the line is. @@ -1931,7 +1931,7 @@ urlstate.bindControl('direct', 'f-direct', { checkbox: true }) // the box is pulled inside the viewport, or a ticker left at the edge of a wide // monitor would be unreachable on a laptop. // -// Below 640px there is no placement at all (#643): the stylesheet pins the card +// Below 768px there is no placement at all (#643): the stylesheet pins the card // centred under the bar, as in the app. The stored x,y is kept rather than // clamped there, so the position dragged on a wide screen is still there when // the screen is wide again. @@ -1961,7 +1961,7 @@ if (rxLog) { }) function apply() { - // Removed rather than left stale below 640px: the narrow rule takes its top + // Removed rather than left stale below 768px: the narrow rule takes its top // from the var's own fallback, under the bar, so a written --rx-y would // hang the pinned card at the wide screen's height. if (narrowQuery.matches) { @@ -2019,7 +2019,7 @@ if (rxLog) { resized.observe(rxLog) if (notices) resized.observe(notices) - // Also what lands a resize across 640px (#643), either way: the crossing is a + // Also what lands a resize across 768px (#643), either way: the crossing is a // window resize and re-lays the bar, and both call this, so no listener on the // query itself is needed. matches is read live, so it is already the new side. function reflow() { @@ -2074,7 +2074,7 @@ if (rxLog) { // Pointer events rather than mouse so a drag works from a pen or a touch // screen on a wide display, where the frame is reached the same way. // - // Dragging stops below 640px (#561). The card is pinned there at + // Dragging stops below 768px (#561). The card is pinned there at // `calc(100vw - 20px)` (#643), so there is no "out of the way" to drag it to: // it spans the map whatever its height. Shrinking and dismissing are what // move it aside on a phone, which is exactly what the app does at every width. diff --git a/web/noticeplace.test.js b/web/noticeplace.test.js index 00b7d67f..dffe300d 100644 --- a/web/noticeplace.test.js +++ b/web/noticeplace.test.js @@ -56,7 +56,7 @@ describe('noticesPlacement', () => { expect(at({ vw: 1024, column: column(1024), railLeft: 964, ticker: tall })).toEqual({ left: 232, width: 560, top: 362 + NOTICE_GAP }) }) - // Below 640px the ticker is pinned across the width (#643), so there is never + // Below 768px the ticker is pinned across the width (#643), so there is never // a band: the notices go under it. it('drops under a ticker pinned across a phone', () => { const pinned = { left: 10, right: 365, top: 60, bottom: 122 } diff --git a/web/parity.test.js b/web/parity.test.js index b74f6bb6..45c64f1b 100644 --- a/web/parity.test.js +++ b/web/parity.test.js @@ -608,7 +608,7 @@ describe('receptions ticker CSS parity (#322)', () => { // lane counts, so it drifted until someone looked at the two side by side. // This pins the chrome itself. What is deliberately per-surface is #rx-log's // own box, which is placed and draggable on a wide map and centred in the app - // (and on a map below 640px, #643). + // (and on a map below 768px, #643). it('draws the same card on both surfaces', () => { const decls = (block, selector) => { const found = declBlock(block, selector) @@ -1397,7 +1397,7 @@ describe('--ch-building matches the app in both themes (#595)', () => { // "Types" where the other said "Traffic types". // // The map adds View after the shared five, and Hunters and Time carry the -// controls the bar hands over below 640px (#561). Those are the deliberate +// controls the bar hands over below 768px (#561). Those are the deliberate // difference: analysis is map-only, because the map is the superset. Overlays // went with #630: node positions, its one control, is a button in the map's FAB // rail now, as it is in the app's. diff --git a/web/style.css b/web/style.css index 617b68c6..ebadf66d 100644 --- a/web/style.css +++ b/web/style.css @@ -80,7 +80,7 @@ html,body { margin:0; height:100%; background:var(--ch-bg); color:var(--ch-text) Start-mapping/Log-in pair landed mid-bar — under the walkthrough's centred panel, where its two below-callouts could never be placed clear. Its own element since #561, rather than margin-left:auto on #rx-cta: that - button is hidden below 640px, and a gap that disappears with the control it + button is hidden below 768px, and a gap that disappears with the control it was attached to takes the layout with it. */ .bar-spacer { margin-left:auto; } @@ -311,7 +311,7 @@ html,body { margin:0; height:100%; background:var(--ch-bg); color:var(--ch-text) across the middle-top of the map, over the content, colliding with Leaflet's zoom control at the top-left. It is now a box the user drags, positioned from --rx-x/--rx-y which tickerplace.js clamps into the viewport on load and on - resize. Below 640px it is pinned instead (#643), in the narrow block. The + resize. Below 768px it is pinned instead (#643), in the narrow block. The 680px width is what .rx-hd/.rx-list already constrained themselves to, so the rows are unchanged; only the box around them moved. Short of the FAB rail's column since #630: below 760px a 680px card from its first-visit 12px @@ -464,8 +464,9 @@ html,body { margin:0; height:100%; background:var(--ch-bg); color:var(--ch-text) #map-notices > * { pointer-events:auto; max-width:100%; } /* bottom 44px stands the readout above MapLibre's attribution, which shares this corner: its 10px margin, one 24px line of credit and 10px of air. At - 26px it covered the open credit by 8px. Wider than 640px the longest credit - (3D adds the terrain source's) is one line, measured down to 641px. */ + 26px it covered the open credit by 8px. From 768px, where this rule + applies, the longest credit (3D adds the terrain source's) is one line; it + was measured down to 641px. */ #map-readout { position:fixed; right:var(--ch-rail-clear); bottom:44px; z-index:610; pointer-events:none; display:flex; align-items:center; gap:8px; flex-wrap:wrap; justify-content:flex-end; background:var(--ch-surface-thin); -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px); @@ -851,7 +852,7 @@ body.wb-onboarding-on #bar { z-index:710; } .bf-live { font-size: 12px; color: var(--ch-muted); } #clear-filters { margin-left: auto; color: var(--ch-accent-2); border-color: var(--ch-accent-2); } -@media (max-width: 640px) { +@media (max-width: 767px) { /* Full-width sheet on a phone, and the six least-used type chips behind "+N more": fourteen 44px chips are four rows at 412px, and then the rest of the panel is under the fold. Active chips always show — a hidden @@ -889,7 +890,7 @@ body.wb-onboarding-on #bar { z-index:710; } .ss-close:hover { color: var(--ch-text); } .ss-panel { min-height: 0; overflow-y: auto; padding: 16px; } .ss-panel[hidden] { display: none; } -/* Start mapping and Log in, moved here below 640px (#561). They arrive with the +/* Start mapping and Log in, moved here below 768px (#561). They arrive with the bar's styling stripped by location alone -- `#bar #rx-cta` no longer matches -- so this is where they get a menu row's box instead. */ .ss-actions { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; } @@ -938,7 +939,7 @@ body.wb-onboarding-on #bar { z-index:710; } sheet anchored to the bottom, which is also where a thumb is. The first breakpoint in this stylesheet -- see #423 for the bar itself, which is the bigger half of the same problem. */ -@media (max-width: 640px) { +@media (max-width: 767px) { .rx-cta-long { display:none; } .rx-cta-short { display:inline; } .lc-modal:has(.ss-card) { place-items: end stretch; } @@ -953,7 +954,7 @@ body.wb-onboarding-on #bar { z-index:710; } The shape is the app's #topbar at the same width, which is the point: the surfaces share the bar, not just what is inside their panels. What the map has extra goes into the panel, which is already a full-width sheet here. */ -@media (max-width: 640px) { +@media (max-width: 767px) { /* The mark alone. The name is in the menu, which is what every mobile app bar does; the desktop bar keeps mark plus wordmark. */ #brand-name { display: none; } @@ -1017,8 +1018,13 @@ body.wb-onboarding-on #bar { z-index:710; } /* A phone held sideways (#630): the rail's column stands from under the bar to the bottom, so the pinned ticker and the notices, which span the screen above on a phone held upright, stop at the rail instead. At 640x360 the rail stood - on the ticker's cross and the compass was under the guest notice. */ -@media (max-width: 640px) and (max-height: 400px) { + on the ticker's cross and the compass was under the guest notice. Not only + sideways: the full ticker's bottom sits about 350px down and the rail's top + at the height less 326px, so the two meet below about 676px of height (at + 720x600 the ticker ended at 350 and the rail began at 274); 720 leaves room + for a bar that renders taller. A tall phone held upright keeps the ticker + across the whole screen. */ +@media (max-width: 767px) and (max-height: 720px) { #rx-log { left: 10px; transform: none; width: calc(100vw - 10px - var(--ch-rail-clear)); } #map-notices { right: var(--ch-rail-clear); } } diff --git a/web/tickerplace.js b/web/tickerplace.js index 29280d1f..f7cce4c7 100644 --- a/web/tickerplace.js +++ b/web/tickerplace.js @@ -35,7 +35,7 @@ export function clampToViewport({ x, y }, { w, h }, { vw, vh, top = 0 }) { } // clampUnlessNarrow is the clamp for a position that is stored, not just drawn. -// Below 640px the stylesheet pins the card centred under the bar, as in the app +// Below 768px the stylesheet pins the card centred under the bar, as in the app // (#643), so x,y are not where the card is: they are where it was left on a wide // screen. urlstate writes them back on every load and every save, so clamping // them against a phone would overwrite that position for good. The wide screen @@ -64,7 +64,7 @@ export const HIDDEN = 'hidden' // under the bar? That, not the width, is what "it covers the map" means. // // `narrow` is the other half of the same question and stays a width test, -// because below 640px the card is pinned at `calc(100vw - 20px)` (#643) and +// because below 768px the card is pinned at `calc(100vw - 20px)` (#643) and // covers the map from edge to edge whatever its height. // // The width alone was the whole rule until a phone was held sideways: 844x390 diff --git a/web/tickerplace.test.js b/web/tickerplace.test.js index 77085183..20013813 100644 --- a/web/tickerplace.test.js +++ b/web/tickerplace.test.js @@ -6,7 +6,7 @@ const DESKTOP = { vw: 1280, vh: 800, top: 48 } const PHONE = { vw: 390, vh: 780, top: 96 } // The card at its full height: ten lanes of 26px plus the 36px header. const FULL = { w: 680, h: 296 } -// A phone held sideways. Wider than the 640px breakpoint, so nothing about the +// A phone held sideways. Wider than the 767px breakpoint, so nothing about the // width says "phone", and only 309px of map under the bar. const LANDSCAPE = { vw: 844, vh: 390, top: 81 } @@ -38,7 +38,7 @@ describe('clampToViewport', () => { }) describe('clampUnlessNarrow', () => { - // #643: below 640px the CSS pins the card under the bar, so x,y are not where + // #643: below 768px the CSS pins the card under the bar, so x,y are not where // it is. They are where it was left on a wide screen, and urlstate saves them // on every load: clamping them here would overwrite that position with one // measured against a phone. @@ -77,7 +77,7 @@ describe('initialPlacement', () => { it('leaves a desktop position alone when it loads on a phone', () => { // The reload that used to lose it (#643): saved at 1100,700 on a monitor, - // opened below 640px, written back to the link and to storage as 0,580. + // opened below 768px, written back to the link and to storage as 0,580. const saved = { x: 1100, y: 700, collapse: 0, hidden: false } const p = initialPlacement({ saved, size: SIZE, viewport: PHONE, narrow: true }) expect(serialise(p)).toBe(serialise(saved))