Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
"title": "Quieter send sounds",
"body": "The pops for Discover and trace-pings are a third as loud and shorter, so a weak reception is heard over them."
},
{
"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-29-map-names-relays-like-app",
"date": "2026-09-29",
Expand Down
2 changes: 1 addition & 1 deletion app/src/barfilters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions changelog.d/2026-09-29-01-bar-one-row-sideways.json
Original file line number Diff line number Diff line change
@@ -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."
}
20 changes: 10 additions & 10 deletions docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`).
Expand Down Expand Up @@ -385,15 +385,15 @@ 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

- **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
Expand Down
2 changes: 1 addition & 1 deletion web/barfilters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions web/barnarrow.js
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion web/barnarrow.test.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
})
})
7 changes: 7 additions & 0 deletions web/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
"title": "Quieter send sounds",
"body": "The pops for Discover and trace-pings are a third as loud and shorter, so a weak reception is heard over them."
},
{
"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-29-map-names-relays-like-app",
"date": "2026-09-29",
Expand Down
55 changes: 49 additions & 6 deletions web/e2e/barlayout.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
})
2 changes: 1 addition & 1 deletion web/e2e/fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions web/e2e/popover.spec.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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('/')
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading